diff --git a/package-lock.json b/package-lock.json index f71641b2c0..3503626d19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@octokit/types": "^14.1.0", "@stylistic/eslint-plugin": "^5.2.2", "@swc/jest": "^0.2.39", + "@types/async": "^3.2.25", "@types/cli-progress": "^3.11.6", "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", @@ -41,12 +42,10 @@ "jest-expect-message": "^1.1.3", "lodash.uniqueid": "^4.0.1", "m3u-linter": "^0.4.2", + "mediainfo.js": "^0.3.6", "node-cleanup": "^2.1.2", + "socks-proxy-agent": "^8.0.5", "tsx": "^4.20.3" - }, - "devDependencies": { - "@types/async": "^3.2.25", - "mediainfo.js": "^0.3.6" } }, "node_modules/@alex_neo/jest-expect-message": { @@ -2746,8 +2745,7 @@ "node_modules/@types/async": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.25.tgz", - "integrity": "sha512-O6Th/DI18XjrL9TX8LO9F/g26qAz5vynmQqlXt/qLGrskvzCKXKc5/tATz3G2N6lM8eOf3M8/StB14FncAmocg==", - "dev": true + "integrity": "sha512-O6Th/DI18XjrL9TX8LO9F/g26qAz5vynmQqlXt/qLGrskvzCKXKc5/tATz3G2N6lM8eOf3M8/StB14FncAmocg==" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3435,6 +3433,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4694,7 +4700,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.1.tgz", "integrity": "sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==", - "dev": true, "engines": { "node": ">=18" }, @@ -4989,6 +4994,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "engines": { + "node": ">= 12" + } + }, "node_modules/iptv-playlist-parser": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/iptv-playlist-parser/-/iptv-playlist-parser-0.15.0.tgz", @@ -6373,7 +6386,6 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.6.tgz", "integrity": "sha512-3xVRlxwlVWIZV3z1q7pb8LzFOO7iKi/DXoRiFRZdOlrUEhPyJDaaRt0uK32yQJabArQicRBeq7cRxmdZlIBTyA==", - "dev": true, "dependencies": { "yargs": "^18.0.0" }, @@ -6388,7 +6400,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -6400,7 +6411,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", @@ -6413,14 +6423,12 @@ "node_modules/mediainfo.js/node_modules/emoji-regex": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", - "dev": true + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==" }, "node_modules/mediainfo.js/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -6437,7 +6445,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -6454,7 +6461,6 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", @@ -6471,7 +6477,6 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } @@ -7132,6 +7137,41 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 2ef6930e63..276eb517f9 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "m3u-linter": "^0.4.2", "mediainfo.js": "^0.3.6", "node-cleanup": "^2.1.2", + "socks-proxy-agent": "^8.0.5", "tsx": "^4.20.3" } } diff --git a/scripts/commands/playlist/test.ts b/scripts/commands/playlist/test.ts index 0e3b42d729..6cbd9ebc59 100644 --- a/scripts/commands/playlist/test.ts +++ b/scripts/commands/playlist/test.ts @@ -2,7 +2,7 @@ import { Logger, Storage, Collection } from '@freearhey/core' import { ROOT_DIR, STREAMS_DIR, DATA_DIR } from '../../constants' import { PlaylistParser, StreamTester, CliTable, DataProcessor, DataLoader } from '../../core' import { Stream } from '../../models' -import { program } from 'commander' +import { program, OptionValues } from 'commander' import { eachLimit } from 'async-es' import chalk from 'chalk' import os from 'node:os' @@ -31,10 +31,10 @@ program .option('-x, --proxy ', 'Use the specified proxy') .parse(process.argv) -const options = program.opts() +const options: OptionValues = program.opts() const logger = new Logger() -const tester = new StreamTester() +const tester = new StreamTester({ options }) async function main() { if (await isOffline()) { @@ -95,7 +95,7 @@ async function runTest(stream: Stream) { const result = await tester.test(stream) let status = '' - const errorStatusCodes = ['ENOTFOUND'] + const errorStatusCodes = ['HTTP_404_NOT_FOUND'] if (result.status.ok) status = chalk.green('OK') else if (errorStatusCodes.includes(result.status.code)) { status = chalk.red(result.status.code) diff --git a/scripts/core/index.ts b/scripts/core/index.ts index d322373100..2e24771bf0 100644 --- a/scripts/core/index.ts +++ b/scripts/core/index.ts @@ -10,4 +10,5 @@ export * from './logParser' export * from './markdown' export * from './numberParser' export * from './playlistParser' +export * from './proxyParser' export * from './streamTester' diff --git a/scripts/core/proxyParser.ts b/scripts/core/proxyParser.ts new file mode 100644 index 0000000000..9cede1afc5 --- /dev/null +++ b/scripts/core/proxyParser.ts @@ -0,0 +1,31 @@ +import { URL } from 'node:url' + +interface ProxyParserResult { + protocol: string | null + auth?: { + username?: string + password?: string + } + host: string + port: number | null +} + +export class ProxyParser { + parse(_url: string): ProxyParserResult { + const parsed = new URL(_url) + + const result: ProxyParserResult = { + protocol: parsed.protocol.replace(':', '') || null, + host: parsed.hostname, + port: parsed.port ? parseInt(parsed.port) : null + } + + if (parsed.username || parsed.password) { + result.auth = {} + if (parsed.username) result.auth.username = parsed.username + if (parsed.password) result.auth.password = parsed.password + } + + return result + } +} diff --git a/scripts/core/streamTester.ts b/scripts/core/streamTester.ts index 7f618dff54..bbe2b0638f 100644 --- a/scripts/core/streamTester.ts +++ b/scripts/core/streamTester.ts @@ -1,9 +1,41 @@ import { Stream } from '../models' import { TESTING } from '../constants' import mediaInfoFactory from 'mediainfo.js' +import axios, { AxiosInstance, AxiosProxyConfig, AxiosRequestConfig } from 'axios' +import { ProxyParser } from './proxyParser.js' +import { OptionValues } from 'commander' +import { SocksProxyAgent } from 'socks-proxy-agent' + +type StreamTesterProps = { + options: OptionValues +} export class StreamTester { - constructor() {} + client: AxiosInstance + + constructor({ options }: StreamTesterProps) { + const proxyParser = new ProxyParser() + let request: AxiosRequestConfig = { + responseType: 'arraybuffer' + } + + if (options.proxy !== undefined) { + 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 } } + } + } + + this.client = axios.create(request) + } async test(stream: Stream) { if (TESTING) { @@ -12,31 +44,18 @@ export class StreamTester { return results[stream.url as keyof typeof results] } else { try { - const controller = new AbortController() const timeout = 10000 - const timeoutId = setTimeout(() => controller.abort(), timeout) - const res = await fetch(stream.url, { - signal: controller.signal, + const res = await this.client(stream.url, { + signal: AbortSignal.timeout(timeout), headers: { 'User-Agent': stream.getUserAgent() || 'Mozilla/5.0', Referer: stream.getReferrer() } }) - clearTimeout(timeoutId) - - if (!res.ok) { - return { - status: { - ok: false, - code: `HTTP_${res.status}` - } - } - } - const mediainfo = await mediaInfoFactory({ format: 'object' }) - const buffer = await res.arrayBuffer() + const buffer = await res.data const result = await mediainfo.analyzeData( () => buffer.byteLength, (size: any, offset: number | undefined) => @@ -60,8 +79,16 @@ export class StreamTester { } } catch (error: any) { let code = 'UNKNOWN_ERROR' - if (error.name === 'AbortError') { + if (error.name === 'CanceledError') { code = 'TIMEOUT' + } else if (error.name === 'AxiosError') { + if (error.response) { + const status = error.response?.status + const statusText = error.response?.statusText.toUpperCase().replace(/\s+/, '_') + code = `HTTP_${status}_${statusText}` + } else { + code = `AXIOS_${error.code}` + } } else if (error.cause) { const cause = error.cause as Error & { code?: string } if (cause.code) {