You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iptv/scripts/utils.ts

296 lines
8.5 KiB
TypeScript

import { restEndpointMethods } from '@octokit/plugin-rest-endpoint-methods'
import axios, { AxiosProxyConfig, AxiosRequestConfig } from 'axios'
import { paginateGraphQL } from '@octokit/plugin-paginate-graphql'
import { parse as parsePlaylist, setOptions } from 'hls-parser'
import { paginateRest } from '@octokit/plugin-paginate-rest'
import { Collection, Dictionary } from '@freearhey/core'
import { SocksProxyAgent } from 'socks-proxy-agent'
import { parse as parseManifest } from 'mpd-parser'
import { TESTING, OWNER, REPO } from './constants'
import { ProxyParser, DataSet } from './core'
import { Discussion, Issue } from './models'
import normalizeUrl from 'normalize-url'
import { Octokit } from '@octokit/core'
import { orderBy } from 'es-toolkit'
import path from 'node:path'
import fs from 'node:fs'
export function isURI(string: string): boolean {
try {
const url = new URL(string)
return /^(http:|https:|mmsh:|rtsp:|rtmp:)/.test(url.protocol)
} catch {
return false
}
}
export function normalizeURL(url: string): string {
const normalized = normalizeUrl(url, { stripWWW: false })
return decodeURIComponent(normalized).replace(/\s/g, '+').toString()
}
export function truncate(string: string, limit: number = 100) {
if (!string) return string
if (string.length < limit) return string
return string.slice(0, limit - 3) + '...'
}
type StreamInfo = {
resolution: { width: number; height: number }
bandwidth: number
frameRate: number
codecs: string
}
export async function getStreamInfo(
url: string,
options: {
httpUserAgent?: string | null
httpReferrer?: string | null
timeout?: number
proxy?: string
}
): Promise<StreamInfo | undefined> {
let data: string | undefined
if (TESTING) {
if (url.includes('.m3u8')) {
data = fs.readFileSync(
path.resolve(__dirname, '../tests/__data__/input/playlist_update/playlist.m3u8'),
'utf8'
)
} else if (url.includes('.mpd')) {
data = fs.readFileSync(
path.resolve(__dirname, '../tests/__data__/input/playlist_update/manifest.mpd'),
'utf8'
)
}
} else {
try {
const timeout = options.timeout || 1000
let request: AxiosRequestConfig = {
signal: AbortSignal.timeout(timeout),
responseType: 'text',
headers: {
'User-Agent': options.httpUserAgent || 'Mozilla/5.0',
Referer: options.httpReferrer
}
}
if (options.proxy !== undefined) {
const proxyParser = new ProxyParser()
const proxy = proxyParser.parse(options.proxy) as AxiosProxyConfig
if (
proxy.protocol &&
['socks', 'socks5', 'socks5h', 'socks4', 'socks4a'].includes(String(proxy.protocol))
) {
const socksProxyAgent = new SocksProxyAgent(options.proxy)
request = { ...request, ...{ httpAgent: socksProxyAgent, httpsAgent: socksProxyAgent } }
} else {
request = { ...request, ...{ proxy } }
}
}
const response = await axios(url, request)
data = response.data
} catch {
// do nothing
}
}
if (!data) return undefined
let info: StreamInfo | undefined
if (url.includes('.m3u8')) {
setOptions({ silent: true })
try {
const playlist = parsePlaylist(data)
if (playlist && playlist.isMasterPlaylist && playlist.variants.length) {
const v = orderBy(playlist.variants, ['bandwidth'], ['desc'])[0]
if (v && v.resolution && v.frameRate && v.codecs) {
info = {
resolution: { width: v.resolution.width, height: v.resolution.height },
bandwidth: v.bandwidth,
frameRate: v.frameRate,
codecs: v.codecs
}
}
}
} catch {
// do nothing
}
} else if (url.includes('.mpd')) {
const manifest = parseManifest(data, {
manifestUri: url,
eventHandler: ({ type, message }) => console.log(`${type}: ${message}`)
})
const playlist = orderBy(manifest.playlists, [p => p.attributes.BANDWIDTH], ['desc'])[0]
if (playlist) {
const attr = playlist.attributes
info = {
resolution: { width: attr.RESOLUTION.width, height: attr.RESOLUTION.height },
bandwidth: attr.BANDWIDTH,
frameRate: attr['FRAME-RATE'],
codecs: attr.CODECS
}
}
}
return info
}
export async function loadIssues(props?: { labels: string | string[] }) {
const CustomOctokit = Octokit.plugin(paginateRest, restEndpointMethods)
const octokit = new CustomOctokit()
let labels = ''
if (props && props.labels) {
labels = Array.isArray(props.labels) ? props.labels.join(',') : props.labels
}
let issues: object[] = []
if (TESTING) {
issues = (await import('../tests/__data__/input/issues.js')).default
} else {
issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: OWNER,
repo: REPO,
per_page: 100,
labels,
status: 'open',
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
})
}
return new Collection(issues).map(parseIssue)
}
function parseIssue(issue: { number: number; body: string; labels: { name: string }[] }): Issue {
const FIELDS = new Dictionary({
'Stream ID': 'stream_id',
'Channel ID': 'channel_id',
'Feed ID': 'feed_id',
'Stream URL': 'stream_url',
Label: 'label',
Quality: 'quality',
'HTTP User-Agent': 'http_user_agent',
'HTTP User Agent': 'http_user_agent',
'HTTP Referrer': 'http_referrer',
'What happened to the stream?': 'reason',
Reason: 'reason',
Notes: 'notes'
})
const fields = typeof issue.body === 'string' ? issue.body.split('###') : []
const data = new Dictionary<string>()
fields.forEach((field: string) => {
const parsed = typeof field === 'string' ? field.split(/\r?\n/).filter(Boolean) : []
let _label = parsed.shift()
_label = _label ? _label.replace(/ \(optional\)| \(required\)/, '').trim() : ''
let _value = parsed.join('\r\n')
_value = _value ? _value.trim() : ''
if (!_label || !_value) return data
const id = FIELDS.get(_label)
const value: string = _value === '_No response_' || _value === 'None' ? '' : _value
if (!id) return
data.set(id, value)
})
const labels = issue.labels.map(label => label.name)
return new Issue({ number: issue.number, labels, data: new DataSet(data) })
}
export async function loadDiscussions() {
let discussions: object[] = []
if (TESTING) {
discussions = (await import('../tests/__data__/input/discussions.js')).default
} else {
const CustomOctokit = Octokit.plugin(paginateGraphQL)
const octokit = new CustomOctokit({
auth: process.env.GITHUB_TOKEN
})
const query = `
query ($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
discussions(first: 100, after: $cursor, states: OPEN) {
nodes {
number
body
category {
name
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
`
const result = await octokit.graphql.paginate(query, {
owner: 'iptv-org',
repo: 'iptv'
})
discussions = result.repository.discussions.nodes
}
return new Collection(discussions).map(parseDiscussion)
}
function parseDiscussion(discussion: {
number: number
category: { name: string }
body: string
}): Discussion {
const FIELDS = new Dictionary({
'Stream ID': 'stream_id'
})
const fields = typeof discussion.body === 'string' ? discussion.body.split('###') : []
const data = new Dictionary<string>()
fields.forEach((field: string) => {
const parsed = typeof field === 'string' ? field.split(/\r?\n/).filter(Boolean) : []
let _label = parsed.shift()
_label = _label ? _label.replace(/ \(optional\)| \(required\)/, '').trim() : ''
let _value = parsed.join('\r\n')
_value = _value ? _value.trim() : ''
if (!_label || !_value) return data
const id = FIELDS.get(_label)
const value: string = _value === '_No response_' || _value === 'None' ? '' : _value
if (!id) return
data.set(id, value)
})
return new Discussion({
number: discussion.number,
category: discussion.category.name,
data: new DataSet(data)
})
}