diff --git a/.github/workflows/build.yml b/.github/workflows/release.yml similarity index 66% rename from .github/workflows/build.yml rename to .github/workflows/release.yml index d1793b5b..9a8db299 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,12 @@ jobs: output_name: nodelink-win-x64.exe node_arch: x64 - # Windows x86 (32-bit) + # Windows x86 (32-bit) - Cross-build via download to avoid runner arch conflicts - os: windows-latest output_name: nodelink-win-x86.exe - node_arch: x86 + node_dl_url: https://nodejs.org/dist/v22.12.0/node-v22.12.0-win-x86.zip + node_dl_path: node-v22.12.0-win-x86/node.exe + is_cross: true # Linux x64 (Standard) - os: ubuntu-latest @@ -40,8 +42,8 @@ jobs: output_name: nodelink-macos-arm64 node_arch: arm64 - # macOS x64 (Intel) - - os: macos-13 + # macOS x64 (Intel) - Updated to macos-15 to avoid deprecation + - os: macos-15 output_name: nodelink-macos-x64 node_arch: x64 @@ -55,8 +57,8 @@ jobs: with: node-version: 22 - # For standard builds (Windows x86/x64, macOS, Linux x64), we can use setup-node to get the target binary - # But for Linux ARM64 on x64 host, we must download it manually. + # For standard builds (Windows x64, macOS, Linux x64), we can use setup-node + # For cross-builds (Linux ARM64, Windows x86), we download manually. - name: Setup Target Node.js (Standard) if: matrix.is_cross != true @@ -65,22 +67,31 @@ jobs: node-version: 22 architecture: ${{ matrix.node_arch }} - - name: Download Target Node.js (Cross-Build) - if: matrix.is_cross == true + - name: Download Target Node.js (Cross-Build - Linux) + if: matrix.is_cross == true && runner.os == 'Linux' run: | wget ${{ matrix.node_dl_url }} tar -xzf $(basename ${{ matrix.node_dl_url }}) shell: bash + - name: Download Target Node.js (Cross-Build - Windows) + if: matrix.is_cross == true && runner.os == 'Windows' + run: | + curl -L -o node_x86.zip ${{ matrix.node_dl_url }} + unzip node_x86.zip + shell: bash + - name: Install Dependencies - run: npm ci + run: npm install - name: Install Build Tools run: npm install --no-save esbuild postject rcedit - name: Set Target Binary Path (Standard Windows) if: runner.os == 'Windows' && matrix.is_cross != true - run: echo "TARGET_NODE_EXE=$(where node)" >> $GITHUB_ENV + run: | + NODE_PATH=$(where node | head -n 1) + echo "TARGET_NODE_EXE=$(cygpath -m "$NODE_PATH")" >> $GITHUB_ENV shell: bash - name: Set Target Binary Path (Standard Unix) @@ -88,11 +99,16 @@ jobs: run: echo "TARGET_NODE_EXE=$(which node)" >> $GITHUB_ENV shell: bash - - name: Set Target Binary Path (Cross-Build) - if: matrix.is_cross == true + - name: Set Target Binary Path (Cross-Build - Linux) + if: matrix.is_cross == true && runner.os == 'Linux' run: echo "TARGET_NODE_EXE=$(pwd)/${{ matrix.node_dl_path }}" >> $GITHUB_ENV shell: bash + - name: Set Target Binary Path (Cross-Build - Windows) + if: matrix.is_cross == true && runner.os == 'Windows' + run: echo "TARGET_NODE_EXE=$(pwd -W)/${{ matrix.node_dl_path }}" >> $GITHUB_ENV + shell: bash + - name: Build Executable run: node scripts/build.js @@ -111,4 +127,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ matrix.output_name }} - path: dist/${{ matrix.output_name }} + path: dist/${{ matrix.output_name }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 12d5eb3f..e8b9340b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ !.github !.github/** +!plugins/** +!plugins !docs !docs/** !src @@ -20,4 +22,6 @@ !commitlint.config.mjs !.gitmessage !docker-compose.yml -!ytmusic_videodata_1765292622656.json \ No newline at end of file +!scripts +!scripts/** +!sea-config.json \ No newline at end of file diff --git a/README.md b/README.md index e574d99d..1709097b 100644 --- a/README.md +++ b/README.md @@ -98,21 +98,22 @@ However, some clients may not work properly, since NodeLink changes certain beha | [DisGoLink](https://github.com/disgoorg/disgolink) | Go | unknown | No | v1 and v2 | | [Lavalink.py](https://github.com/devoxin/lavalink.py) | Python | unknown | No | v1 and v2 | | [Mafic](https://github.com/ooliver1/mafic) | Python | unknown | No | v1 and v2 | -| [Wavelink](https://github.com/PythonistaGuild/Wavelink) | Python | unknown | No | v1 and v2 | +| [Wavelink](https://github.com/PythonistaGuild/Wavelink) | Python | Yes | No | v1, v2, v3 | | [Pomice](https://github.com/cloudwithax/pomice) | Python | unknown | No | v1 and v2 | +| [lava-lyra](https://github.com/ParrotXray/lava-lyra) | Python | Yes | No | v3 | | [Hikari-ongaku](https://github.com/MPlatypus/hikari-ongaku) | Python | unknown | No | v1 and v2 | -| [Moonlink.js](https://github.com/1Lucas1apk/moonlink.js) | TypeScript | unknown | No | v1 and v2 | +| [Moonlink.js](https://github.com/1Lucas1apk/moonlink.js) | TypeScript | Yes | No | v1, v2, v3 | | [Magmastream](https://github.com/Blackfort-Hosting/magmastream) | TypeScript | unknown | No | v1 | | [Lavacord](https://github.com/lavacord/Lavacord) | TypeScript | unknown | No | v1 and v2 | -| [Shoukaku](https://github.com/Deivu/Shoukaku) | TypeScript | unknown | No | v1 and v2 | +| [Shoukaku](https://github.com/Deivu/Shoukaku) | TypeScript | Yes | No | v1, v2, v3 | | [Lavalink-Client](https://github.com/tomato6966/Lavalink-Client) | TypeScript | Yes | No | v1 and v3 | | [Rainlink](https://github.com/RainyXeon/Rainlink) | TypeScript | unknown | No | v1 and v2 | | [Poru](https://github.com/parasop/Poru) | TypeScript | unknown | No | v1 and v2 | | [Blue.ts](https://github.com/ftrapture/blue.ts) | TypeScript | unknown | No | v1 and v2 | -| [FastLink](https://github.com/PerformanC/FastLink) | Node.js | unknown | No | v1 and v2 | -| [Riffy](https://github.com/riffy-team/riffy) | Node.js | unknown | No | v1 and v2 | +| [FastLink](https://github.com/PerformanC/FastLink) | Node.js | Yes | No | v1, v2, v3 | +| [Riffy](https://github.com/riffy-team/riffy) | Node.js | Yes | No | v1, v2, v3 | | [TsumiLink](https://github.com/Fyphen1223/TsumiLink) | Node.js | unknown | No | v1 and v2 | -| [AquaLink](https://github.com/ToddyTheNoobDud/AquaLink) | JavaScript | unknown | No | v1 and v2 | +| [AquaLink](https://github.com/ToddyTheNoobDud/AquaLink) | JavaScript | Yes | No | v1, v2, v3 | | [DisCatSharp](https://github.com/Aiko-IT-Systems/DisCatSharp) | .NET | unknown | No | v1 and v2 | | [Lavalink4NET](https://github.com/angelobreuer/Lavalink4NET) | .NET | unknown | No | v1 and v2 | | [Nomia](https://github.com/DHCPCD9/Nomia) | .NET | unknown | No | v1 and v2 | diff --git a/config.default.js b/config.default.js index 42bdc69f..5c09d46a 100644 --- a/config.default.js +++ b/config.default.js @@ -100,6 +100,10 @@ export default { http: { enabled: true }, + vimeo: { + // Note: not 100% of the songs are currently working (but most should.), because i need to code a different extractor for every year (2010, 2011, etc. not all are done) + enabled: true, + }, flowery: { enabled: true, voice: 'Salli', @@ -131,7 +135,7 @@ export default { resolve: ['AndroidVR', 'TV', 'TVEmbedded', 'IOS', 'Web'], // Clients used for resolving detailed track information (channel, external links, etc.) settings: { TV: { - refreshToken: '' + refreshToken: [""] // You can use a string "token" or an array ["token1", "token2"] for rotation/fallback } } }, @@ -172,7 +176,7 @@ export default { }, tidal: { enabled: true, - token: '', //get from tidal web player devtools; using login google account + token: 'token_here', //manually | or "token_here" to get a token automatically, get from tidal web player devtools; using login google account countryCode: 'US', playlistLoadLimit: 2, // 0 = no limit, 1 = 50 tracks, 2 = 100 tracks, etc. playlistPageLoadConcurrency: 5 // How many pages to load simultaneously @@ -241,7 +245,12 @@ export default { maxRequests: 20, timeWindowMs: 5000 // 5 seconds }, - ignorePaths: [] + ignorePaths: [], + ignore: { + userIds: [], + guildIds: [], + ips: [] + } }, dosProtection: { enabled: true, @@ -252,6 +261,11 @@ export default { mitigation: { delayMs: 500, blockDurationMs: 300000 // 5 minutes + }, + ignore: { + userIds: [], + guildIds: [], + ips: [] } }, metrics: { @@ -266,5 +280,12 @@ export default { defaultVolume: 0.8, maxLayersMix: 5, autoCleanup: true - } + }, + plugins: [ +/* { + name: 'nodelink-sample-plugin', + source: 'local' + } */ + ], + pluginConfig: {} } diff --git a/package.json b/package.json index c44892d1..a9b2a860 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "nodelink", - "version": "3.3.0", + "version": "3.4.0", "scripts": { - "prebuild": "node scripts/prebuild.js", + "build": "node scripts/build.js", "start": "node --dns-result-order=ipv4first --openssl-legacy-provider src/index.js", "start:bun": "bun run --dns-result-order=ipv4first src/index.js", "prepare": "husky" @@ -15,15 +15,15 @@ "@ecliptia/seekable-stream": "github:1Lucas1apk/seekable-stream", "@performanc/pwsl-server": "github:performanc/internals#PWSL-server", "@performanc/voice": "github:PerformanC/voice", - "@toddynnn/symphonia-decoder": "1.0.3", + "@toddynnn/symphonia-decoder": "1.0.6", "mp4box": "^2.3.0", "myzod": "^1.12.1", "toddy-mediaplex": "^2.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.3.8", - "@commitlint/cli": "20.1.0", - "@commitlint/config-conventional": "20.0.0", + "@biomejs/biome": "^2.3.10", + "@commitlint/cli": "20.2.0", + "@commitlint/config-conventional": "20.2.0", "dotenv": "^17.2.3", "husky": "9.1.7" }, diff --git a/plugins/@1lucas1apk/tunnel-cloudflared/index.js b/plugins/@1lucas1apk/tunnel-cloudflared/index.js new file mode 100644 index 00000000..6d206429 --- /dev/null +++ b/plugins/@1lucas1apk/tunnel-cloudflared/index.js @@ -0,0 +1,47 @@ +import fs from "node:fs" +import { spawn } from "node:child_process" + +export default async function(nodelink, config, context) { + if (context.type !== 'master') return + + const logger = (msg, level = 'info') => nodelink.logger(level, 'Cloudflared', msg) + + const token = config.token || process.env.CF_TUNNEL_TOKEN + const port = nodelink.options.server.port || 3000 + + if (!token) { + logger('CF_TUNNEL_TOKEN not found. Plugin disabled.', 'warn') + return + } + + let cloudflared + try { + cloudflared = await import("cloudflared") + } catch (e) { + logger('Package "cloudflared" not found. Please install it in the plugin folder.', 'error') + return + } + + const { bin, install } = cloudflared + + if (!fs.existsSync(bin)) { + logger('Installing cloudflared binary...') + await install(bin) + } + + const tunnel = spawn( + bin, + ["tunnel", "run", "--token", token, "--url", `http://127.0.0.1:${port}`], + { stdio: "inherit", env: process.env } + ) + + const stopAll = () => { + try { tunnel.kill("SIGTERM") } catch {} + } + + process.on("SIGINT", stopAll) + process.on("SIGTERM", stopAll) + process.on("beforeExit", stopAll) + + logger(`Tunnel started on port ${port}`) +} diff --git a/plugins/@1lucas1apk/tunnel-cloudflared/package.json b/plugins/@1lucas1apk/tunnel-cloudflared/package.json new file mode 100644 index 00000000..8adc913a --- /dev/null +++ b/plugins/@1lucas1apk/tunnel-cloudflared/package.json @@ -0,0 +1,12 @@ +{ + "name": "@1lucas1apk/tunnel-cloudflared", + "version": "1.0.0", + "description": "Cloudflare Tunnel integration for NodeLink", + "type": "module", + "main": "index.js", + "author": "1lucas1apk", + "license": "MIT", + "dependencies": { + "cloudflared": "^0.7.1" + } +} diff --git a/plugins/nodelink-sample-plugin/index.js b/plugins/nodelink-sample-plugin/index.js new file mode 100644 index 00000000..274a31b7 --- /dev/null +++ b/plugins/nodelink-sample-plugin/index.js @@ -0,0 +1,162 @@ +/** + * NodeLink Plugin Entry Point + * + * This file demonstrates the structure and capabilities of a NodeLink plugin. + * Plugins are loaded in both the Master process and Worker processes. + * + * @param {import('../../src/index').NodelinkServer} nodelink - The main server instance. + * @param {Object} config - The specific configuration for this plugin defined in 'pluginConfig' within config.js. + * @param {Object} context - Metadata about the execution environment. + */ +export default async function(nodelink, config, context) { + const logger = (msg, level = 'info') => nodelink.logger(level, `Plugin:${context.pluginName}`, msg); + + logger(`Initializing in ${context.type.toUpperCase()} mode.`); + + // ================================================================================= + // CONTEXT: MASTER + // Executed only once in the main process. + // ================================================================================= + if (context.type === 'master') { + logger('Running Master setup...'); + + // 1. Registering a Custom API Route + nodelink.registerRoute('GET', '/v4/sample/status', (nodelink, req, res, sendResponse) => { + sendResponse(req, res, { + status: 'ok', + message: 'Hello from NodeLink Sample Plugin!', + version: context.meta.version + }, 200); + }); + + // Test helper: create a test player in a worker (uses mock session inside worker) + nodelink.registerRoute('POST', '/v4/sample/create-test-player', async (nodelink, req, res, sendResponse) => { + try { + const body = req.body || {} + const sessionId = body.sessionId || 'test-session' + const guildId = body.guildId || '11111111111111111' + const voice = body.voice || null + const payload = { sessionId, guildId, userId: body.userId || 'test-user', voice } + const worker = nodelink.workerManager.getBestWorker() + if (!worker) return sendResponse(req, res, { error: 'No worker available' }, 500) + const result = await nodelink.workerManager.execute(worker, 'createPlayer', payload) + sendResponse(req, res, { result }, 200) + } catch (e) { + sendResponse(req, res, { error: e.message }, 500) + } + }) + + // Test helper: attempt to load a mysource track and play it on a test player + nodelink.registerRoute('POST', '/v4/sample/play-mysource', async (nodelink, req, res, sendResponse) => { + try { + const body = req.body || {} + const sessionId = body.sessionId || 'test-session' + const guildId = body.guildId || '11111111111111111' + const worker = nodelink.workerManager.getBestWorker() + if (!worker) return sendResponse(req, res, { error: 'No worker available' }, 500) + + // create mock player + await nodelink.workerManager.execute(worker, 'createPlayer', { sessionId, guildId, userId: body.userId || 'test-user' }) + + // try resolving via mysource + const load = await nodelink.workerManager.execute(worker, 'loadTracks', { identifier: 'mysource:dummy' }) + + // if empty, return load result (nothing to play) + if (!load || load.loadType === 'empty' || (load.data && (!load.data.tracks || load.data.tracks.length === 0))) { + return sendResponse(req, res, { load }, 200) + } + + const track = load.data.tracks[0] + + // send play command to worker player + const playRes = await nodelink.workerManager.execute(worker, 'playerCommand', { + sessionId, + guildId, + command: 'play', + args: [{ info: track }] + }) + + sendResponse(req, res, { load, play: playRes }, 200) + } catch (e) { + sendResponse(req, res, { error: e.message }, 500) + } + }) + + // 2. Intercepting Player Commands (Master Side) + // This allows you to block or modify play/stop/pause/seek/volume commands before they reach the worker. + nodelink.registerPlayerInterceptor(async (action, guildId, args) => { + // logger(`Intercepted player action '${action}' for guild ${guildId}`, 'debug'); + + if (action === 'play') { + const track = args[0]; + // Example: Block playing a specific track + if (track?.info?.title?.includes('Forbidden Song')) { + logger(`Blocked playback of forbidden song for guild ${guildId}`, 'warn'); + return { error: 'This song is forbidden by plugin.' }; // Returns this to the caller immediately + } + } + return null; // Continue execution + }); + } + + // ================================================================================= + // CONTEXT: WORKER + // Executed in every worker process (if cluster is enabled). + // ================================================================================= + if (context.type === 'worker') { + logger('Running Worker setup...'); + + // 1. Registering a Custom Audio Source + class MyCustomSource { + constructor(nodelink) { + this.nodelink = nodelink; + this.sourceName = 'mysource'; + this.searchTerms = ['mysource']; + } + async search(query) { return { loadType: 'empty', data: {} }; } + async resolve(url) { return { loadType: 'empty', data: {} }; } + async getTrackUrl(trackInfo) { return { exception: { message: 'Not implemented', severity: 'fault' } }; } + } + const mySrc = new MyCustomSource(nodelink); + nodelink.registerSource('mysource', mySrc); + // Ensure the source can be used via the `source:query` syntax by mapping a search term + try { + nodelink.sources.searchTermMap.set('mysource', 'mysource') + } catch (e) { + if (nodelink.sources && nodelink.sources.searchTermMap) { + nodelink.sources.searchTermMap.set('mysource', 'mysource') + } + } + + // 2. Registering a Custom Audio Filter + class SimpleGainFilter { + constructor() { this.gain = 1.0; } + update(config) { if (config.simpleGain) this.gain = config.simpleGain; } + process(chunk) { return chunk; } + } + nodelink.registerFilter('simpleGain', new SimpleGainFilter()); + + // 3. Registering an Audio Interceptor (Low Level) + const { Transform } = await import('node:stream'); + nodelink.registerAudioInterceptor(() => { + return new Transform({ + transform(chunk, encoding, callback) { + callback(null, chunk); + } + }); + }); + + // 4. Intercepting Worker Commands (Worker Side) + // This intercepts internal IPC commands sent from Master to Worker. + nodelink.registerWorkerInterceptor(async (type, payload) => { + // logger(`Worker received command: ${type}`, 'debug'); + + if (type === 'destroyPlayer') { + // Example: Log before destroying + // logger(`Destroying player for guild ${payload.guildId} in worker...`, 'debug'); + } + + return false; // Return true to block the command + }); + } +} diff --git a/plugins/nodelink-sample-plugin/package.json b/plugins/nodelink-sample-plugin/package.json new file mode 100644 index 00000000..b9031185 --- /dev/null +++ b/plugins/nodelink-sample-plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "nodelink-sample-plugin", + "version": "1.0.0", + "description": "A sample plugin demonstrating the NodeLink Plugin API capabilities", + "type": "module", + "main": "index.js", + "author": "NodeLink Team", + "license": "MIT" +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 00000000..657f3f21 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,199 @@ +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import esbuild from 'esbuild' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const distDir = path.join(rootDir, 'dist') + +if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }) +} +fs.mkdirSync(distDir) + +const pluginsSrc = path.join(rootDir, 'plugins') +const pluginsDest = path.join(distDir, 'plugins') +if (fs.existsSync(pluginsSrc)) { + fs.cpSync(pluginsSrc, pluginsDest, { recursive: true }) +} + +execSync('node scripts/generate-registry.js', { + cwd: rootDir, + stdio: 'inherit' +}) + +const gitInfo = (() => { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim() + const commit = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim() + const commitTime = Number.parseInt(execSync('git log -1 --format=%ct', { encoding: 'utf8' }).trim(), 10) * 1000 + return { branch, commit, commitTime } + } catch (e) { + return { branch: 'unknown', commit: 'unknown', commitTime: 0 } + } +})(); + +await esbuild.build({ + entryPoints: [path.join(rootDir, 'src/index.js')], + bundle: true, + platform: 'node', + target: 'node20', + outfile: path.join(distDir, 'main.mjs'), + external: ['bufferutil', 'utf-8-validate', '@toddynnn/symphonia-decoder', 'toddy-mediaplex'], + format: 'esm', + keepNames: true, + loader: { '.node': 'file' }, + define: { + '__BUILD_GIT_INFO__': JSON.stringify(gitInfo) + }, + banner: { + js: `import { createRequire as _createRequire } from 'module'; const require = _createRequire(import.meta.url);` + } +}) + +const modulesToCopy = [ + { src: path.join(rootDir, 'node_modules', '@toddynnn', 'symphonia-decoder'), dest: path.join(distDir, 'node_modules', '@toddynnn', 'symphonia-decoder') }, + { src: path.join(rootDir, 'node_modules', 'toddy-mediaplex'), dest: path.join(distDir, 'node_modules', 'toddy-mediaplex') } +] + +const toddyDir = path.join(rootDir, 'node_modules', '@toddynnn') +if (fs.existsSync(toddyDir)) { + const packages = fs.readdirSync(toddyDir) + for (const pkg of packages) { + if (pkg.startsWith('symphonia-decoder-')) { + modulesToCopy.push({ src: path.join(toddyDir, pkg), dest: path.join(distDir, 'node_modules', '@toddynnn', pkg) }) + } + } +} + +const rootModulesDir = path.join(rootDir, 'node_modules') +if (fs.existsSync(rootModulesDir)) { + const packages = fs.readdirSync(rootModulesDir) + for (const pkg of packages) { + if (pkg.startsWith('mediaplex-')) { + modulesToCopy.push({ src: path.join(rootModulesDir, pkg), dest: path.join(distDir, 'node_modules', pkg) }) + } + } +} + +for (const { src, dest } of modulesToCopy) { + if (fs.existsSync(src)) { + fs.mkdirSync(path.dirname(dest), { recursive: true }) + fs.cpSync(src, dest, { recursive: true }) + } +} + +const symphoniaDest = path.join(distDir, 'node_modules', '@toddynnn', 'symphonia-decoder') +const symphoniaPkgDir = path.join(distDir, 'node_modules', '@toddynnn', 'symphonia-decoder-win32-x64-msvc') +if (fs.existsSync(symphoniaPkgDir)) { + for (const binary of fs.readdirSync(symphoniaPkgDir).filter(f => f.endsWith('.node'))) { + fs.copyFileSync(path.join(symphoniaPkgDir, binary), path.join(symphoniaDest, binary)) + } +} + +const mediaplexDest = path.join(distDir, 'node_modules', 'toddy-mediaplex') +const mediaplexPkgDir = path.join(distDir, 'node_modules', 'mediaplex-win32-x64-msvc') +if (fs.existsSync(mediaplexPkgDir)) { + for (const binary of fs.readdirSync(mediaplexPkgDir).filter(f => f.endsWith('.node'))) { + fs.copyFileSync(path.join(mediaplexPkgDir, binary), path.join(mediaplexDest, binary)) + } +} + +const filesToEmbed = {} +function scanDir(dir, base = '') { + for (const file of fs.readdirSync(dir)) { + const fullPath = path.join(dir, file) + const relativePath = path.join(base, file) + if (file === 'nodelink.exe' || file === 'app.blob' || file === 'runner.js') continue + if (fs.statSync(fullPath).isDirectory()) { + scanDir(fullPath, relativePath) + } else { + filesToEmbed[relativePath.replace(/\\/g, '/')] = fs.readFileSync(fullPath).toString('base64') + } + } +} +scanDir(distDir) + +const configDefaultBase64 = Buffer.from(fs.readFileSync(path.join(rootDir, 'config.default.js'), 'utf-8')).toString('base64') +let configBase64 = null +if (fs.existsSync(path.join(rootDir, 'config.js'))) { + configBase64 = Buffer.from(fs.readFileSync(path.join(rootDir, 'config.js'), 'utf-8')).toString('base64') +} + +const runnerCode = ` +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { spawn } = require('child_process'); +const dns = require('dns'); + +try { + if (dns.setDefaultResultOrder) { + dns.setDefaultResultOrder('ipv4first'); + } +} catch (e) {} + +if (!process.env.NODELINK_RESTARTED) { + const child = spawn(process.execPath, ['--openssl-legacy-provider', ...process.argv.slice(1)], { + stdio: ['ignore', 'inherit', 'inherit'], + env: { ...process.env, NODELINK_RESTARTED: '1' } + }); + child.on('close', (code) => process.exit(code)); + return; +} + +const baseDir = path.dirname(process.execPath); +const internalDir = path.join(baseDir, 'internal'); +const mainPath = path.join(internalDir, 'main.mjs'); +const configDefaultPath = path.join(baseDir, 'config.default.js'); +const configPath = path.join(baseDir, 'config.js'); +const embeddedFiles = ${JSON.stringify(filesToEmbed)}; + +try { + if (!fs.existsSync(internalDir)) fs.mkdirSync(internalDir, { recursive: true }); + if (!fs.existsSync(path.join(baseDir, 'plugins'))) fs.mkdirSync(path.join(baseDir, 'plugins'), { recursive: true }); + if (!fs.existsSync(configDefaultPath)) { + fs.writeFileSync(configDefaultPath, Buffer.from("${configDefaultBase64}", 'base64')); + } + if (${configBase64 ? 'true' : 'false'} && !fs.existsSync(configPath)) { + fs.writeFileSync(configPath, Buffer.from("${configBase64 || ''}", 'base64')); + } + for (const [filename, contentBase64] of Object.entries(embeddedFiles)) { + const filePath = path.join(internalDir, filename); + if (!fs.existsSync(path.dirname(filePath))) fs.mkdirSync(path.dirname(filePath), { recursive: true }); + if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, Buffer.from(contentBase64, 'base64')); + } + import(pathToFileURL(mainPath).href).catch(err => { + console.error('[NodeLink] Failed to start application:', err); + console.log('Press any key to exit...'); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', () => process.exit(1)); + }); +} catch (err) { + console.error('[NodeLink] Bootstrap error:', err); + console.log('Press any key to exit...'); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', () => process.exit(1)); +} +` +fs.writeFileSync(path.join(distDir, 'runner.js'), runnerCode) + +fs.writeFileSync(path.join(rootDir, 'sea-config.json'), JSON.stringify({ + main: 'dist/runner.js', + output: 'dist/app.blob', + disableExperimentalSEAWarning: true +}, null, 2)) + +execSync('node --experimental-sea-config sea-config.json', { cwd: rootDir, stdio: 'inherit' }) + +const outputName = process.platform === 'win32' ? 'nodelink.exe' : 'nodelink' +const destExe = path.join(distDir, outputName) +fs.copyFileSync(process.execPath, destExe) + +const postjectPath = path.join(rootDir, 'node_modules', '.bin', process.platform === 'win32' ? 'postject.cmd' : 'postject') +execSync(`"${postjectPath}" "${destExe}" NODE_SEA_BLOB "dist/app.blob" --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2`, { stdio: 'inherit' }) +console.log('Build complete: ' + destExe) \ No newline at end of file diff --git a/scripts/generate-registry.js b/scripts/generate-registry.js new file mode 100644 index 00000000..9470b0e7 --- /dev/null +++ b/scripts/generate-registry.js @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const srcDir = path.resolve(__dirname, '../src') +const registryPath = path.join(srcDir, 'registry.js') + +function getFiles(dir) { + const fullPath = path.join(srcDir, dir) + if (!fs.existsSync(fullPath)) return [] + return fs + .readdirSync(fullPath) + .filter((f) => f.endsWith('.js') && f !== 'index.js') +} + +const apis = getFiles('api') +const sources = getFiles('sources') +const lyrics = getFiles('lyrics') + +const hasYouTube = fs.existsSync( + path.join(srcDir, 'sources/youtube/YouTube.js') +) + +let output = `// Auto-generated registry file +// Do not edit this file manually. It is regenerated during the build process. + +` + +apis.forEach((f, i) => (output += `import api_${i} from './api/${f}'\n`)) +sources.forEach( + (f, i) => (output += `import source_${i} from './sources/${f}'\n`) +) +if (hasYouTube) { + output += "import source_yt from './sources/youtube/YouTube.js'\n" +} +lyrics.forEach((f, i) => (output += `import lyric_${i} from './lyrics/${f}'\n`)) + +output += '\nexport const apiRegistry = {\n' +apis.forEach((f, i) => (output += ` '${f}': api_${i},\n`)) +output += '}\n\n' + +output += 'export const sourceRegistry = {\n' +sources.forEach((f, i) => { + const name = f.replace('.js', '') + output += ` '${name}': source_${i},\n` +}) +if (hasYouTube) { + output += " 'youtube': source_yt,\n" +} +output += '}\n\n' + +output += 'export const lyricRegistry = {\n' +lyrics.forEach((f, i) => { + const name = f.replace('.js', '') + output += ` '${name}': lyric_${i},\n` +}) +output += '}\n' + +fs.writeFileSync(registryPath, output) + +console.log('Registry generated at src/registry.js') \ No newline at end of file diff --git a/scripts/wrapper.cjs b/scripts/wrapper.cjs new file mode 100644 index 00000000..87dc9539 --- /dev/null +++ b/scripts/wrapper.cjs @@ -0,0 +1,3 @@ +(async () => { + await import('../dist/main.mjs'); +})(); diff --git a/sea-config.json b/sea-config.json new file mode 100644 index 00000000..6a1a06d4 --- /dev/null +++ b/sea-config.json @@ -0,0 +1,5 @@ +{ + "main": "dist/runner.js", + "output": "dist/app.blob", + "disableExperimentalSEAWarning": true +} \ No newline at end of file diff --git a/src/api/index.js b/src/api/index.js index 0cb1e51b..7c695653 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -9,46 +9,70 @@ import { sendErrorResponse } from '../utils.js' +let apiRegistry +try { + const mod = await import('../registry.js') + apiRegistry = mod.apiRegistry +} catch {} + const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) async function loadRoutes() { - const routeFiles = await fs.readdir(__dirname) const staticRoutes = new Map() const dynamicRoutes = [] + let routeModules = [] - for (const file of routeFiles) { - if (file !== 'index.js' && file.endsWith('.js')) { - const routeName = file.replace('.js', '').toLowerCase() - let pathname - - if (routeName === 'version') { - pathname = '/version' - } else if (routeName.includes('.')) { - const parts = routeName.split('.') - const basePattern = parts - .map((part) => (part === 'id' ? '(?:id|[A-Za-z0-9]+)' : part)) - .join('/') - pathname = new RegExp( - `^/${PATH_VERSION}/${basePattern}(?:/[A-Za-z0-9]+)?/?$` - ) - } else { - pathname = `/${PATH_VERSION}/${routeName}` - } + if (apiRegistry) { + routeModules = Object.entries(apiRegistry).map( + ([file, mod]) => ({ + file, + module: mod.default || mod + }) + ) + } - const filePath = join(__dirname, file) - const fileUrl = new URL(`file://${filePath.replace(/\\/g, '/')}`) - const routeModule = await import(fileUrl) - const routeData = { - handler: routeModule.default.handler, - methods: routeModule.default.methods || ['GET'] + if (routeModules.length === 0) { + try { + const routeFiles = await fs.readdir(__dirname) + for (const file of routeFiles) { + if (file !== 'index.js' && file.endsWith('.js')) { + const filePath = join(__dirname, file) + const fileUrl = new URL(`file://${filePath.replace(/\\/g, '/')}`) + const routeModule = await import(fileUrl) + routeModules.push({ file, module: routeModule.default }) + } } + } catch {} + } - if (pathname instanceof RegExp) { - dynamicRoutes.push([pathname, routeData]) - } else { - staticRoutes.set(pathname, routeData) - } + for (const { file, module } of routeModules) { + const routeName = file.replace('.js', '').toLowerCase() + let pathname + + if (routeName === 'version') { + pathname = '/version' + } else if (routeName.includes('.')) { + const parts = routeName.split('.') + const basePattern = parts + .map((part) => (part === 'id' ? '(?:id|[A-Za-z0-9]+)' : part)) + .join('/') + pathname = new RegExp( + `^/${PATH_VERSION}/${basePattern}(?:/[A-Za-z0-9]+)?/?$` + ) + } else { + pathname = `/${PATH_VERSION}/${routeName}` + } + + const routeData = { + handler: module.handler, + methods: module.methods || ['GET'] + } + + if (pathname instanceof RegExp) { + dynamicRoutes.push([pathname, routeData]) + } else { + staticRoutes.set(pathname, routeData) } } @@ -62,6 +86,15 @@ const routesPromise = loadRoutes() async function requestHandler(nodelink, req, res) { const startTime = Date.now() const parsedUrl = new URL(req.url, `http://${req.headers.host}`) + + const middlewares = nodelink.extensions?.middlewares + if (middlewares && Array.isArray(middlewares)) { + for (const middleware of middlewares) { + const result = await middleware(nodelink, req, res, parsedUrl) + if (result === true) return + } + } + nodelink.statsManager.incrementApiRequest(parsedUrl.pathname) const trace = parsedUrl.searchParams.get('trace') === 'true' const remoteAddress = req.socket.remoteAddress @@ -80,7 +113,6 @@ async function requestHandler(nodelink, req, res) { originalEnd.apply(res, args) } - // Handle metrics endpoint separately const isMetricsEndpoint = parsedUrl.pathname === `/${PATH_VERSION}/metrics` if (isMetricsEndpoint) { const metricsConfig = nodelink.options.metrics || {} @@ -95,12 +127,10 @@ async function requestHandler(nodelink, req, res) { return } - // Metrics authorization check const authConfig = metricsConfig.authorization || {} let authType = authConfig.type; if(!['Bearer', 'Basic'].includes(authType)) { logger('warn',`Config: metrics authorization.type SHOULD BE one of 'Bearer', 'Basic'.... Defaulting to 'Bearer'!`); - // Because prom doesn't support any Other Auth(except Bearer & Basic) or Custom Authorization Like the one Server uses by default. authType = 'Bearer'; } @@ -122,8 +152,6 @@ async function requestHandler(nodelink, req, res) { res.end('Unauthorized') return } - - // Metrics endpoint is authorized, continue to handler } const dosCheck = nodelink.dosProtectionManager.check(req) @@ -174,7 +202,6 @@ async function requestHandler(nodelink, req, res) { return } - // Skip general authorization check for metrics endpoint (already checked above) if (!isMetricsEndpoint) { if ( !req.headers || @@ -259,6 +286,30 @@ async function requestHandler(nodelink, req, res) { return } + const customRoutes = nodelink.extensions?.routes + if (customRoutes && Array.isArray(customRoutes)) { + const customRoute = customRoutes.find( + (r) => r.path === parsedUrl.pathname + ) + + if (customRoute) { + if ( + !verifyMethod( + parsedUrl, + req, + res, + customRoute.method ? [customRoute.method] : ['GET'], + clientAddress, + trace + ) + ) + return + + customRoute.handler(nodelink, req, res, sendResponse, parsedUrl) + return + } + } + for (const [regex, route] of dynamicRoutes) { if (regex.test(parsedUrl.pathname)) { if ( diff --git a/src/api/info.js b/src/api/info.js index 753de223..748d7f84 100644 --- a/src/api/info.js +++ b/src/api/info.js @@ -27,7 +27,14 @@ async function handler(nodelink, req, res, sendResponse) { ? Array.from(nodelink.sources.sources.keys()) : [], filters, - plugins: [] + plugins: nodelink.pluginManager + ? Array.from(nodelink.pluginManager.loadedPlugins.values()).map((p) => ({ + name: p.name, + version: p.meta?.version || '0.0.0', + author: p.meta?.author || null, + path: p.path || null + })) + : [] } sendResponse(req, res, response, 200) } diff --git a/src/api/sessions.id.players.js b/src/api/sessions.id.players.js index 50f84322..6ad14739 100644 --- a/src/api/sessions.id.players.js +++ b/src/api/sessions.id.players.js @@ -254,7 +254,8 @@ async function handler(nodelink, req, res, sendResponse, parsedUrl) { } trackToPlay = { encoded: trackPayload.encoded, - info: decodedTrack.info + info: decodedTrack.info, + audioTrackId: trackPayload.language || trackPayload.audioTrackId || null } } } else if (trackPayload.identifier) { @@ -285,7 +286,8 @@ async function handler(nodelink, req, res, sendResponse, parsedUrl) { if (loadResult.loadType === 'track') { trackToPlay = { encoded: loadResult.data.encoded, - info: loadResult.data.info + info: loadResult.data.info, + audioTrackId: trackPayload.language || trackPayload.audioTrackId || null } } else { const message = diff --git a/src/index.js b/src/index.js index 41c0b203..281f8abd 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,8 @@ import cluster from 'node:cluster' import { EventEmitter } from 'node:events' import http from 'node:http' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import WebSocketServer from '@performanc/pwsl-server' import requestHandler from './api/index.js' @@ -30,15 +32,25 @@ import { GatewayEvents } from './constants.js' import DosProtectionManager from './managers/dosProtectionManager.js' import PlayerManager from './managers/playerManager.js' import RateLimitManager from './managers/rateLimitManager.js' +import PluginManager from './managers/pluginManager.js' let config +const isSEA = process.embedder === 'nodejs' +const executableDir = path.dirname(process.execPath) + try { - config = (await import('../config.js')).default + const configPath = isSEA + ? pathToFileURL(path.join(executableDir, 'config.js')).href + : '../config.js' + config = (await import(configPath)).default } catch (e) { - if (e.code === 'ERR_MODULE_NOT_FOUND') { + if (e.code === 'ERR_MODULE_NOT_FOUND' || e.code === 'ENOENT') { try { - config = (await import('../config.default.js')).default + const defaultConfigPath = isSEA + ? pathToFileURL(path.join(executableDir, 'config.default.js')).href + : '../config.default.js' + config = (await import(defaultConfigPath)).default console.log( '[WARN] Config: config.js not found, using config.default.js. It is recommended to create a config.js file for your own configuration.' ) @@ -114,11 +126,19 @@ class BunSocketWrapper extends EventEmitter { } } +let registry = null +if (process.embedder === 'nodejs') { + try { + registry = await import('./registry.js') + } catch (e) {} +} + class NodelinkServer { constructor(options, PlayerManagerClass, isClusterPrimary = false) { if (!options || Object.keys(options).length === 0) throw new Error('Configuration file not found or empty') this.options = options + this.logger = logger this.server = null this.socket = null this.sessions = new sessionManager(this, PlayerManagerClass) @@ -134,12 +154,26 @@ class NodelinkServer { this.statsManager = new statsManager(this) this.rateLimitManager = new RateLimitManager(this) this.dosProtectionManager = new DosProtectionManager(this) + this.pluginManager = new PluginManager(this) + this.registry = registry this.version = getVersion() this.gitInfo = getGitInfo() this.statistics = { players: 0, playingPlayers: 0 } + + this.extensions = { + sources: new Map(), + filters: new Map(), + routes: [], + middlewares: [], + trackModifiers: [], + wsInterceptors: [], + audioInterceptors: [], + playerInterceptors: [] + } + this._globalUpdater = null this.supportedSourcesCache = null @@ -172,17 +206,182 @@ class NodelinkServer { _validateConfig() { validateProperty( - this.options, - (options) => - options.server.port && typeof options.server.port === 'number', - 'Port must be a number' + this.options.server.port, + 'server.port', + 'integer between 1 and 65535', + (value) => + Number.isInteger(value) && + value >= 1 && + value <= 65535 + ) + + validateProperty( + this.options.server.host, + 'server.host', + 'string', + (value) => typeof value === 'string' + ) + + validateProperty( + this.options.playerUpdateInterval, + 'playerUpdateInterval', + 'integer between 250 and 60000 (milliseconds)', + (value) => + Number.isInteger(value) && + value >= 250 && + value <= 60000 + ) + + validateProperty( + this.options.maxSearchResults, + 'maxSearchResults', + 'integer between 1 and 100', + (value) => + Number.isInteger(value) && + value >= 1 && + value <= 100 + ) + + validateProperty( + this.options.maxAlbumPlaylistLength, + 'maxAlbumPlaylistLength', + 'integer between 1 and 500', + (value) => + Number.isInteger(value) && + value >= 1 && + value <= 500 + ) + + validateProperty( + this.options.trackStuckThresholdMs, + 'trackStuckThresholdMs', + 'integer >= 1000 (milliseconds)', + (value) => + Number.isInteger(value) && + value >= 1000 + ) + + validateProperty( + this.options.zombieThresholdMs, + 'zombieThresholdMs', + `integer > trackStuckThresholdMs (${this.options.trackStuckThresholdMs})`, + (value) => + Number.isInteger(value) && + value > this.options.trackStuckThresholdMs + ) + + validateProperty( + this.options.cluster.workers, + 'cluster.workers', + 'integer >= 0', + (value) => + Number.isInteger(value) && + value >= 0 ) + validateProperty( - this.options, - (options) => - options.server.host && typeof options.server.host === 'string', - 'Host must be a string' + this.options.cluster.minWorkers, + 'cluster.minWorkers', + this.options.cluster.workers === 0 + ? 'integer >= 0 (workers auto-scaled)' + : `integer between 0 and ${this.options.cluster.workers}`, + (value) => + Number.isInteger(value) && + value >= 0 && + (this.options.cluster.workers === 0 || + value <= this.options.cluster.workers) ) + + validateProperty( + this.options.defaultSearchSource, + 'defaultSearchSource', + 'key of an enabled source in config.sources', + (v) => + typeof v === 'string' && + Boolean(this.options.sources?.[v]?.enabled) + ) + + validateProperty( + this.options.audio.quality, + 'audio.quality', + "one of ['high', 'medium', 'low', 'lowest']", + (v) => ['high', 'medium', 'low', 'lowest'].includes(v) + ) + + validateProperty( + this.options.audio.resamplingQuality, + 'audio.resamplingQuality', + "one of ['best', 'medium', 'fastest', 'zero', 'linear']", + (v) => ['best', 'medium', 'fastest', 'zero', 'linear'].includes(v) + ) + + validateProperty( + this.options.routePlanner?.strategy, + 'routePlanner.strategy', + "one of ['RotateOnBan', 'RoundRobin', 'LoadBalance']", + (v) => + typeof v === 'string' && + ['RotateOnBan', 'RoundRobin', 'LoadBalance'].includes(v) + ) + + validateProperty( + this.options.routePlanner?.bannedIpCooldown, + 'routePlanner.bannedIpCooldown', + 'integer > 0 (milliseconds)', + (v) => Number.isInteger(v) && v > 0 + ) + + + const rateLimitSections = [ + 'global', + 'perIp', + 'perUserId', + 'perGuildId' + ] + + if (this.options.rateLimit?.enabled !== false) { + for (let i = 0; i < rateLimitSections.length; i++) { + const section = rateLimitSections[i] + const config = this.options.rateLimit?.[section] + + if (!config) continue + + validateProperty( + config.maxRequests, + `rateLimit.${section}.maxRequests`, + 'integer > 0', + (value) => + Number.isInteger(value) && + value > 0 + ) + + validateProperty( + config.timeWindowMs, + `rateLimit.${section}.timeWindowMs`, + 'integer > 0 (milliseconds)', + (value) => + Number.isInteger(value) && + value > 0 + ) + + if (i === 0) continue + + const parentSection = rateLimitSections[i - 1] + const parentConfig = this.options.rateLimit?.[parentSection] + + if (!parentConfig) continue + + validateProperty( + config.maxRequests, + `rateLimit.${section}.maxRequests`, + `integer <= rateLimit.${parentSection}.maxRequests (${parentConfig.maxRequests})`, + (value) => + Number.isInteger(value) && + value > 0 && + value <= parentConfig.maxRequests + ) + } + } } _setupSocketEvents() { @@ -193,6 +392,30 @@ class NodelinkServer { this.socket.on( '/v4/websocket', (socket, request, clientInfo, oldSessionId) => { + const originalOn = socket.on.bind(socket) + socket.on = (event, listener) => { + if (event === 'message') { + return originalOn(event, async (data) => { + const interceptors = this.extensions?.wsInterceptors + if (interceptors && Array.isArray(interceptors)) { + let parsedData + try { + parsedData = JSON.parse(data.toString()) + } catch { + parsedData = data + } + + for (const interceptor of interceptors) { + const handled = await interceptor(this, socket, parsedData, clientInfo) + if (handled === true) return + } + } + listener(data) + }) + } + return originalOn(event, listener) + } + logger( 'debug', 'Resume', @@ -205,7 +428,7 @@ class NodelinkServer { logger( 'info', 'Server', - `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${clientInfo.version}\x1b[0m resumed session with ID: ${oldSessionId}` + `\x1b[36m${clientInfo.name}\x1b[0m${clientInfo.version ? `/\x1b[32mv${clientInfo.version}\x1b[0m` : ''} resumed session with ID: ${oldSessionId}` ) this.statsManager.incrementSessionResume(clientInfo.name, true) socket.send( @@ -255,7 +478,7 @@ class NodelinkServer { logger( 'info', 'Server', - `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${clientInfo.version}\x1b[0m disconnected with code ${code} and reason: ${ + `\x1b[36m${clientInfo.name}\x1b[0m${clientInfo.version ? `/\x1b[32mv${clientInfo.version}\x1b[0m` : ''} disconnected with code ${code} and reason: ${ reason || 'without reason' }` ) @@ -329,7 +552,7 @@ class NodelinkServer { 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid Password` ) - return new Response(null, { + return new Response('Invalid password provided.', { status: 401, statusText: 'Unauthorized' }) @@ -341,7 +564,7 @@ class NodelinkServer { 'Server', `Missing client-name from ${clientAddress}` ) - return new Response(null, { + return new Response('Invalid or missing Client-Name header.', { status: 400, statusText: 'Bad Request' }) @@ -349,7 +572,7 @@ class NodelinkServer { if (!userId || !verifyDiscordID(userId)) { logger('warn', 'Server', `Invalid user ID from ${clientAddress}`) - return new Response(null, { + return new Response('Invalid or missing User-Id header.', { status: 400, statusText: 'Bad Request' }) @@ -447,7 +670,7 @@ class NodelinkServer { logger( 'info', 'Server', - `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${clientInfo.version}\x1b[0m connected from [External] (${ws.data.remoteAddress}) | \x1b[33mURL:\x1b[0m ${ws.data.url}` + `\x1b[36m${clientInfo.name}\x1b[0m${clientInfo.version ? `/\x1b[32mv${clientInfo.version}\x1b[0m` : ''} connected from [External] (${ws.data.remoteAddress}) | \x1b[33mURL:\x1b[0m ${ws.data.url}` ) self.socket.emit( @@ -495,6 +718,11 @@ class NodelinkServer { /^::ffff:127\.0\.0\.1/.test(remoteAddress) const clientAddress = `${isInternal ? '[Internal]' : '[External]'} (${remoteAddress}:${remotePort})` + const rejectUpgrade = (status, statusText, body) => { + socket.write(`HTTP/1.1 ${status} ${statusText}\r\nContent-Type: text/plain\r\nContent-Length: ${body.length}\r\n\r\n${body}`) + socket.destroy() + } + const originalHeaders = request.headers const headers = {} for (const key in originalHeaders) { @@ -513,8 +741,7 @@ class NodelinkServer { 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid password provided` ) - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') - return socket.destroy() + return rejectUpgrade(401, 'Unauthorized', 'Invalid password provided.') } const clientInfo = parseClient(headers['client-name']) if (!clientInfo) { @@ -523,8 +750,7 @@ class NodelinkServer { 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid client-name provided` ) - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n') - return socket.destroy() + return rejectUpgrade(400, 'Bad Request', 'Invalid or missing Client-Name header.') } let sessionId = headers['session-id'] @@ -549,8 +775,7 @@ class NodelinkServer { 'Server', `Unauthorized connection attempt from ${clientAddress} - Missing user ID` ) - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n') - return socket.destroy() + return rejectUpgrade(400, 'Bad Request', 'User-Id header is missing.') } if (!verifyDiscordID(headers['user-id'])) { logger( @@ -558,15 +783,14 @@ class NodelinkServer { 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid user ID provided` ) - socket.write('HTTP/1.1 400 Bad Request\r\n\r\n') - return socket.destroy() + return rejectUpgrade(400, 'Bad Request', 'Invalid User-Id header.') } request.headers = headers logger( 'info', 'Server', - `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${clientInfo.version}\x1b[0m connected from ${clientAddress} | \x1b[33mURL:\x1b[0m ${request.url}` + `\x1b[36m${clientInfo.name}\x1b[0m${clientInfo.version ? `/\x1b[32mv${clientInfo.version}\x1b[0m` : ''} connected from ${clientAddress} | \x1b[33mURL:\x1b[0m ${request.url}` ) this.socket.handleUpgrade(request, socket, head, {}, (ws) => @@ -578,8 +802,7 @@ class NodelinkServer { 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid path provided` ) - socket.write('HTTP/1.1 404 Not Found\r\n\r\n') - return socket.destroy() + return rejectUpgrade(404, 'Not Found', 'Invalid path for WebSocket upgrade.') } }) } @@ -779,6 +1002,11 @@ class NodelinkServer { this._validateConfig() await this.statsManager.initialize() + await this.pluginManager.load('master') + + if (!startOptions.isClusterPrimary) { + await this.pluginManager.load('worker') + } if (this.options.sources.youtube?.getOAuthToken) { logger( @@ -864,10 +1092,62 @@ class NodelinkServer { const workerMetrics = this.workerManager ? this.workerManager.getWorkerMetrics() : null this.statsManager.updateStatsMetrics(stats, workerMetrics) + const statsPayload = JSON.stringify({ op: 'stats', ...stats }) + for (const session of this.sessions.values()) { + if (session.socket) { + session.socket.send(statsPayload) + } + } + const sessionCount = this.sessions.activeSessions?.size || 0 this.statsManager.setWebsocketConnections(sessionCount) }, updateInterval) } + + registerSource(name, source) { + if (!this.sources) { + logger('warn', 'Server', 'Cannot register source in this context (sources manager not available).') + return + } + this.sources.sources.set(name, source) + logger('info', 'Server', `Registered custom source: ${name}`) + } + + registerFilter(name, filter) { + this.extensions.filters.set(name, filter) + logger('info', 'Server', `Registered custom filter: ${name}`) + } + + registerRoute(method, path, handler) { + this.extensions.routes.push({ method, path, handler }) + logger('info', 'Server', `Registered custom route: ${method} ${path}`) + } + + registerMiddleware(fn) { + this.extensions.middlewares.push(fn) + logger('info', 'Server', 'Registered custom REST interceptor (middleware)') + } + + registerTrackModifier(fn) { + this.extensions.trackModifiers.push(fn) + logger('info', 'Server', 'Registered custom track info modifier') + } + + registerWebSocketInterceptor(fn) { + this.extensions.wsInterceptors.push(fn) + logger('info', 'Server', 'Registered custom WebSocket interceptor') + } + + registerAudioInterceptor(interceptor) { + if (!this.extensions.audioInterceptors) this.extensions.audioInterceptors = [] + this.extensions.audioInterceptors.push(interceptor) + logger('info', 'Server', 'Registered custom audio interceptor') + } + + registerPlayerInterceptor(interceptor) { + this.extensions.playerInterceptors.push(interceptor) + logger('info', 'Server', 'Registered custom player interceptor') + } } import WorkerManager from './managers/workerManager.js' diff --git a/src/lyrics/youtube.js b/src/lyrics/youtube.js index e04f97ee..99e311d9 100644 --- a/src/lyrics/youtube.js +++ b/src/lyrics/youtube.js @@ -27,16 +27,14 @@ export default class YouTubeLyrics { return { loadType: 'empty', data: {} } } - const captionTracks = - resolvedTrack.data.pluginInfo.captions.playerCaptionsTracklistRenderer - .captionTracks - if (!captionTracks || captionTracks.length === 0) { + const captionTracks = resolvedTrack.data.pluginInfo.captions + if (!Array.isArray(captionTracks) || captionTracks.length === 0) { return { loadType: 'empty', data: {} } } const langs = captionTracks.map((c) => ({ code: c.languageCode, - name: c.name.simpleText, + name: c.name, isTranslatable: c.isTranslatable })) @@ -56,9 +54,7 @@ export default class YouTubeLyrics { ...defaultTrack, languageCode: language, baseUrl: `${defaultTrack.baseUrl}&tlang=${language}`, - name: { - simpleText: `${defaultTrack.name.simpleText} (Translated to ${language})` - } + name: `${defaultTrack.name} (Translated to ${language})` } } } @@ -71,20 +67,33 @@ export default class YouTubeLyrics { captionTracks[0] } + let url = trackLang.baseUrl + if (url.includes('fmt=')) { + url = url.replace(/fmt=[^&]+/, 'fmt=json3') + } else { + url += '&fmt=json3' + } + const { body: lyrics, error, statusCode - } = await makeRequest( - trackLang.baseUrl.replace('&fmt=srv3', '&fmt=json3'), - { method: 'GET' } - ) + } = await makeRequest(url, { method: 'GET' }) if (error || statusCode !== 200) { logger( 'error', 'Lyrics', - `Failed to fetch lyrics content from ${trackLang.baseUrl}: ${error?.message || statusCode}` + `Failed to fetch lyrics content from ${url}: ${error?.message || statusCode}` + ) + return { loadType: 'empty', data: {} } + } + + if (!lyrics || !lyrics.events) { + logger( + 'warn', + 'Lyrics', + `Invalid lyrics format received for ${trackInfo.title}` ) return { loadType: 'empty', data: {} } } @@ -106,7 +115,7 @@ export default class YouTubeLyrics { return { loadType: 'lyrics', data: { - name: trackLang.name.simpleText, + name: trackLang.name, synced: true, lang: trackLang.languageCode, lines, diff --git a/src/managers/dosProtectionManager.js b/src/managers/dosProtectionManager.js index 55282907..968e184c 100644 --- a/src/managers/dosProtectionManager.js +++ b/src/managers/dosProtectionManager.js @@ -34,6 +34,17 @@ export default class DosProtectionManager { const remoteAddress = req.socket.remoteAddress const now = Date.now() + if (this.config.ignore) { + if (this.config.ignore.ips?.includes(remoteAddress)) return { allowed: true } + + const userId = req.headers['user-id'] + if (userId && this.config.ignore.userIds?.includes(userId)) return { allowed: true } + + const guildIdMatch = req.url?.match(/\/players\/(\d+)/) + const guildId = guildIdMatch ? guildIdMatch[1] : null + if (guildId && this.config.ignore.guildIds?.includes(guildId)) return { allowed: true } + } + if (!this.ipRequestCounts.has(remoteAddress)) { this.ipRequestCounts.set(remoteAddress, { count: 0, diff --git a/src/managers/lyricsManager.js b/src/managers/lyricsManager.js index 3b35a4f4..4b341115 100644 --- a/src/managers/lyricsManager.js +++ b/src/managers/lyricsManager.js @@ -3,6 +3,12 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { logger } from '../utils.js' +let lyricRegistry +try { + const mod = await import('../registry.js') + lyricRegistry = mod.lyricRegistry +} catch {} + export default class LyricsManager { constructor(nodelink) { this.nodelink = nodelink @@ -14,46 +20,68 @@ export default class LyricsManager { const __dirname = path.dirname(__filename) const lyricsDir = path.join(__dirname, '../lyrics') + this.lyricsSources.clear() + + if (lyricRegistry && Object.keys(lyricRegistry).length > 0) { + await Promise.all( + Object.entries(lyricRegistry).map(async ([name, mod]) => { + if (!this.nodelink.options.lyrics?.[name]?.enabled) return + + const Mod = mod.default || mod + const instance = new Mod(this.nodelink) + + if (await instance.setup()) { + this.lyricsSources.set(name, instance) + logger('info', 'Lyrics', `Loaded lyrics source: ${name}`) + } else { + logger( + 'error', + 'Lyrics', + `Failed setup for lyrics source: ${name}; source not available.` + ) + } + }) + ) + return + } + try { await fs.access(lyricsDir) + const files = await fs.readdir(lyricsDir) + const jsFiles = files.filter((f) => f.endsWith('.js')) + const toLoad = jsFiles.filter((f) => { + const name = path.basename(f, '.js') + return !!this.nodelink.options.lyrics?.[name]?.enabled + }) + + await Promise.all( + toLoad.map(async (file) => { + const name = path.basename(file, '.js') + const filePath = path.join(lyricsDir, file) + const fileUrl = new URL(`file://${filePath}`) + const Mod = (await import(fileUrl)).default + + const instance = new Mod(this.nodelink) + if (await instance.setup()) { + this.lyricsSources.set(name, instance) + logger('info', 'Lyrics', `Loaded lyrics source: ${name}`) + } else { + logger( + 'error', + 'Lyrics', + `Failed setup for lyrics source: ${name}; source not available.` + ) + } + }) + ) } catch { logger( 'info', 'Lyrics', `Lyrics directory not found, creating at: ${lyricsDir}` ) - await fs.mkdir(lyricsDir) + await fs.mkdir(lyricsDir, { recursive: true }) } - - const files = await fs.readdir(lyricsDir) - const jsFiles = files.filter((f) => f.endsWith('.js')) - const toLoad = jsFiles.filter((f) => { - const name = path.basename(f, '.js') - return !!this.nodelink.options.lyrics?.[name]?.enabled - }) - - this.lyricsSources.clear() - - await Promise.all( - toLoad.map(async (file) => { - const name = path.basename(file, '.js') - const filePath = path.join(lyricsDir, file) - const fileUrl = new URL(`file://${filePath}`) - const Mod = (await import(fileUrl)).default - - const instance = new Mod(this.nodelink) - if (await instance.setup()) { - this.lyricsSources.set(name, instance) - logger('info', 'Lyrics', `Loaded lyrics source: ${name}`) - } else { - logger( - 'error', - 'Lyrics', - `Failed setup for lyrics source: ${name}; source not available.` - ) - } - }) - ) } async loadLyrics(decodedTrack, language) { diff --git a/src/managers/playerBackupManager.js b/src/managers/playerBackupManager.js deleted file mode 100644 index fc3caa15..00000000 --- a/src/managers/playerBackupManager.js +++ /dev/null @@ -1,160 +0,0 @@ -import { gzipSync, gunzipSync } from 'node:zlib' -import { logger } from '../utils.js' - -export default class PlayerBackupManager { - constructor() { - this.snapshots = new Map() - this.workerAssignments = new Map() - this.lastUpdate = new Map() - this.cleanupInterval = null - this.snapshotTTL = 300000 - - logger('info', 'PlayerBackup', 'Backup manager initialized') - this._startCleanup() - } - - storeSnapshot(playerKey, workerId, playerState) { - try { - const [guildId, userId] = playerKey.split(':') - const snapshot = { - guildId, - userId, - sessionId: playerState.sessionId, - track: playerState.track, - position: playerState.position || 0, - isPaused: playerState.isPaused || false, - volume: playerState.volume || 100, - filters: playerState.filters || {}, - voice: playerState.voice, - timestamp: Date.now() - } - - const json = JSON.stringify(snapshot) - const compressed = gzipSync(Buffer.from(json)) - - this.snapshots.set(playerKey, compressed) - this.workerAssignments.set(playerKey, workerId) - this.lastUpdate.set(playerKey, Date.now()) - } catch (error) { - logger( - 'error', - 'PlayerBackup', - `Failed to store snapshot for ${playerKey}: ${error.message}` - ) - } - } - - getSnapshot(playerKey) { - try { - const compressed = this.snapshots.get(playerKey) - if (!compressed) return null - - const decompressed = gunzipSync(compressed) - const snapshot = JSON.parse(decompressed.toString()) - - return snapshot - } catch (error) { - logger( - 'error', - 'PlayerBackup', - `Failed to get snapshot for ${playerKey}: ${error.message}` - ) - return null - } - } - - getWorkerSnapshots(workerId) { - const snapshots = [] - - for (const [ - playerKey, - assignedWorkerId - ] of this.workerAssignments.entries()) { - if (assignedWorkerId === workerId) { - const snapshot = this.getSnapshot(playerKey) - if (snapshot) { - snapshots.push(snapshot) - } - } - } - - logger( - 'info', - 'PlayerBackup', - `Retrieved ${snapshots.length} snapshots for worker ${workerId}` - ) - return snapshots - } - - removeSnapshot(playerKey) { - this.snapshots.delete(playerKey) - this.workerAssignments.delete(playerKey) - this.lastUpdate.delete(playerKey) - } - - clearWorkerSnapshots(workerId) { - let cleared = 0 - - for (const [ - playerKey, - assignedWorkerId - ] of this.workerAssignments.entries()) { - if (assignedWorkerId === workerId) { - this.removeSnapshot(playerKey) - cleared++ - } - } - - logger( - 'info', - 'PlayerBackup', - `Cleared ${cleared} snapshots for worker ${workerId}` - ) - return cleared - } - - _startCleanup() { - this.cleanupInterval = setInterval(() => { - const now = Date.now() - let cleaned = 0 - - for (const [playerKey, lastUpdate] of this.lastUpdate.entries()) { - if (now - lastUpdate > this.snapshotTTL) { - this.removeSnapshot(playerKey) - cleaned++ - } - } - - if (cleaned > 0) { - logger('info', 'PlayerBackup', `Cleaned ${cleaned} expired snapshots`) - } - }, 60000) - } - - getStats() { - let totalSize = 0 - for (const compressed of this.snapshots.values()) { - totalSize += compressed.length - } - - return { - totalSnapshots: this.snapshots.size, - totalSizeBytes: totalSize, - totalSizeKB: Math.round(totalSize / 1024), - workers: new Set(this.workerAssignments.values()).size - } - } - - destroy() { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - - this.snapshots.clear() - this.workerAssignments.clear() - this.lastUpdate.clear() - - logger('info', 'PlayerBackup', 'Backup manager destroyed') - } -} diff --git a/src/managers/playerManager.js b/src/managers/playerManager.js index a60f0e5b..adb9c1ac 100644 --- a/src/managers/playerManager.js +++ b/src/managers/playerManager.js @@ -8,6 +8,23 @@ export default class PlayerManager { this.isCluster = !!nodelink.workerManager } + async _runInterceptors(action, guildId, ...args) { + const interceptors = this.nodelink.extensions?.playerInterceptors + if (!interceptors || interceptors.length === 0) return null + + for (const interceptor of interceptors) { + try { + const result = await interceptor(action, guildId, args) + if (result !== null && result !== undefined && result !== false) { + return { handled: true, result } + } + } catch (e) { + logger('error', 'PlayerManager', `Interceptor error for ${action}: ${e.message}`) + } + } + return null + } + async create(guildId, voice) { const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -107,6 +124,9 @@ export default class PlayerManager { } async play(guildId, trackPayload) { + const interception = await this._runInterceptors('play', guildId, trackPayload) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -135,6 +155,9 @@ export default class PlayerManager { } async stop(guildId) { + const interception = await this._runInterceptors('stop', guildId) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -163,6 +186,9 @@ export default class PlayerManager { } async pause(guildId, shouldPause) { + const interception = await this._runInterceptors('pause', guildId, shouldPause) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -191,6 +217,9 @@ export default class PlayerManager { } async seek(guildId, position, endTime) { + const interception = await this._runInterceptors('seek', guildId, position, endTime) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -219,6 +248,9 @@ export default class PlayerManager { } async volume(guildId, level) { + const interception = await this._runInterceptors('volume', guildId, level) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -247,6 +279,9 @@ export default class PlayerManager { } async setFilters(guildId, filtersPayload) { + const interception = await this._runInterceptors('setFilters', guildId, filtersPayload) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` @@ -275,6 +310,9 @@ export default class PlayerManager { } async updateVoice(guildId, voicePayload) { + const interception = await this._runInterceptors('updateVoice', guildId, voicePayload) + if (interception?.handled) return interception.result + const session = this.nodelink.sessions.get(this.sessionId) const playerKey = `${this.sessionId}:${guildId}` diff --git a/src/managers/pluginManager.js b/src/managers/pluginManager.js new file mode 100644 index 00000000..fd17254e --- /dev/null +++ b/src/managers/pluginManager.js @@ -0,0 +1,166 @@ +import fs from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { logger } from '../utils.js' + +const require = createRequire(import.meta.url) + +export default class PluginManager { + constructor(nodelink) { + this.nodelink = nodelink + this.config = nodelink.options.plugins || [] + this.pluginConfigs = nodelink.options.pluginConfig || {} + this.pluginsDir = path.join(process.cwd(), 'plugins') + this.loadedPlugins = new Map() + } + + async load(contextType) { + logger('info', 'PluginManager', `Initializing plugins in ${contextType} context...`) + + try { + await fs.access(this.pluginsDir) + } catch { + await fs.mkdir(this.pluginsDir, { recursive: true }) + } + + if (Array.isArray(this.config)) { + for (const pluginDef of this.config) { + await this._loadPlugin(pluginDef, contextType) + } + } + + logger('info', 'PluginManager', `Plugins processed for ${contextType}.`) + } + + async _findPackageJson(startPath) { + let currentDir = path.dirname(startPath) + + while (currentDir !== path.parse(currentDir).root) { + const pkgPath = path.join(currentDir, 'package.json') + try { + await fs.access(pkgPath) + const data = await fs.readFile(pkgPath, 'utf-8') + return JSON.parse(data) + } catch { + if (path.basename(currentDir) === 'node_modules') break + currentDir = path.dirname(currentDir) + } + } + return null + } + + async _loadPlugin(def, contextType) { + const { name, source, path: localPath, package: packageName } = def + + if (!name) return + + if (this.loadedPlugins.has(name)) { + const cached = this.loadedPlugins.get(name) + await this._executePlugin(cached.module, name, contextType, cached.meta) + return + } + + try { + let entryPoint = null + let pluginMeta = { + name, + version: '0.0.0', + author: 'Unknown', + topic: null + } + + if (source === 'local') { + const resolvedPath = path.resolve(this.pluginsDir, localPath || name) + const stat = await fs.stat(resolvedPath) + + if (stat.isDirectory()) { + const pkgPath = path.join(resolvedPath, 'package.json') + try { + const pkgData = await fs.readFile(pkgPath, 'utf-8') + const pkg = JSON.parse(pkgData) + + if (pkg.version) pluginMeta.version = pkg.version + if (pkg.author) pluginMeta.author = typeof pkg.author === 'object' ? pkg.author.name : pkg.author + if (pkg.homepage || (pkg.repository && pkg.repository.url)) { + pluginMeta.topic = pkg.homepage || pkg.repository.url + } + + if (pkg.main) { + entryPoint = path.join(resolvedPath, pkg.main) + } else { + entryPoint = path.join(resolvedPath, 'index.js') + } + } catch { + entryPoint = path.join(resolvedPath, 'index.js') + } + } else { + entryPoint = resolvedPath + } + } else if (source === 'npm') { + try { + const pkgName = packageName || name + entryPoint = require.resolve(pkgName) + + const pkg = await this._findPackageJson(entryPoint) + if (pkg) { + if (pkg.version) pluginMeta.version = pkg.version + if (pkg.author) pluginMeta.author = typeof pkg.author === 'object' ? pkg.author.name : pkg.author + if (pkg.homepage || (pkg.repository && pkg.repository.url)) { + pluginMeta.topic = pkg.homepage || pkg.repository.url + } + } + } catch (e) { + logger('warn', 'PluginManager', `NPM package '${packageName || name}' not found.`) + return + } + } + + if (!entryPoint) return + + const fileUrl = pathToFileURL(entryPoint).href + const pluginModule = await import(fileUrl) + + if (typeof pluginModule.default !== 'function') { + throw new Error(`Plugin '${name}' entry point must export a default function.`) + } + + this.loadedPlugins.set(name, { + name, + path: entryPoint, + module: pluginModule, + meta: pluginMeta + }) + + await this._executePlugin(pluginModule, name, contextType, pluginMeta) + + const author = `\x1b[36m${pluginMeta.author}\x1b[0m` + const pluginName = `\x1b[1m\x1b[32m${name}\x1b[0m` + const version = `\x1b[33mv${pluginMeta.version}\x1b[0m` + const topic = pluginMeta.topic ? ` | \x1b[34mTopic:\x1b[0m ${pluginMeta.topic}` : '' + + const creditString = `[${author}] ${pluginName} ${version}${topic}` + + logger('info', 'PluginManager', `Loaded: ${creditString}`) + + } catch (error) { + logger('error', 'PluginManager', `Failed to load plugin '${name}': ${error.message}`) + } + } + + async _executePlugin(pluginModule, name, contextType, meta) { + const specificConfig = this.pluginConfigs[name] || {} + const context = { + type: contextType, + workerId: process.pid, + pluginName: name, + meta + } + + try { + await pluginModule.default(this.nodelink, specificConfig, context) + } catch (err) { + logger('error', 'PluginManager', `Error executing plugin '${name}' in '${contextType}' context: ${err.message}`) + } + } +} \ No newline at end of file diff --git a/src/managers/rateLimitManager.js b/src/managers/rateLimitManager.js index 3f48464e..abe240db 100644 --- a/src/managers/rateLimitManager.js +++ b/src/managers/rateLimitManager.js @@ -56,6 +56,12 @@ export default class RateLimitManager { ? parsedUrl.pathname.split('/')[5] : null + if (this.config.ignore) { + if (this.config.ignore.ips?.includes(remoteAddress)) return true + if (userId && this.config.ignore.userIds?.includes(userId)) return true + if (guildId && this.config.ignore.guildIds?.includes(guildId)) return true + } + if ( !this._checkAndIncrement( 'global', diff --git a/src/managers/sourceManager.js b/src/managers/sourceManager.js index 466a1f50..16cab5fa 100644 --- a/src/managers/sourceManager.js +++ b/src/managers/sourceManager.js @@ -3,6 +3,12 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { logger } from '../utils.js' +let sourceRegistry +try { + const mod = await import('../registry.js') + sourceRegistry = mod.sourceRegistry +} catch {} + export default class SourcesManager { constructor(nodelink) { this.nodelink = nodelink @@ -16,110 +22,155 @@ export default class SourcesManager { const __dirname = path.dirname(__filename) const sourcesDir = path.join(__dirname, '../sources') - try { - await fs.access(sourcesDir) - } catch { - throw new Error(`Sources directory not found: ${sourcesDir}`) - } - - const files = await fs.readdir(sourcesDir) - const jsFiles = files.filter((f) => f.endsWith('.js')) - const toLoad = jsFiles.filter((f) => { - const name = path.basename(f, '.js') - return ( - name !== 'youtube' && !!this.nodelink.options.sources[name]?.enabled - ) - }) - this.sources.clear() this.searchTermMap.clear() this.patternMap = [] - if (this.nodelink.options.sources.youtube?.enabled) { - const name = 'youtube' - const filePath = path.join(sourcesDir, 'youtube', 'YouTube.js') - const fileUrl = new URL(`file://${filePath.replace(/\\/g, '/')}`) - const Mod = (await import(fileUrl)).default + if (sourceRegistry && Object.keys(sourceRegistry).length > 0) { + await Promise.all( + Object.entries(sourceRegistry).map(async ([name, mod]) => { + const isYouTube = name === 'youtube' || name.includes('YouTube.js') + const enabled = isYouTube + ? this.nodelink.options.sources.youtube?.enabled + : !!this.nodelink.options.sources[name]?.enabled - const instance = new Mod(this.nodelink) - if (await instance.setup()) { - this.sources.set(name, instance) + if (!enabled) return - if (Array.isArray(instance.searchTerms)) { - for (const term of instance.searchTerms) { - this.searchTermMap.set(term, name) - } - } + const Mod = mod.default || mod + const instance = new Mod(this.nodelink) + + if (await instance.setup()) { + const sourceKey = isYouTube ? 'youtube' : name + this.sources.set(sourceKey, instance) + + if (isYouTube) this.sources.set('ytmusic', instance) + + if (Array.isArray(instance.searchTerms)) { + for (const term of instance.searchTerms) { + this.searchTermMap.set(term, sourceKey) + } + } - if (Array.isArray(instance.patterns)) { - for (const regex of instance.patterns) { - if (regex instanceof RegExp) { - this.patternMap.push({ - regex, - sourceName: name, - priority: instance.priority || 0 - }) + if (Array.isArray(instance.patterns)) { + for (const regex of instance.patterns) { + if (regex instanceof RegExp) { + this.patternMap.push({ + regex, + sourceName: sourceKey, + priority: instance.priority || 0 + }) + } + } } + logger('info', 'Sources', `Loaded source: ${sourceKey}`) } - } - logger( - 'info', - 'Sources', - `Loaded source: ${name} ${instance.searchTerms?.length ? `(terms: ${instance.searchTerms.join(', ')})` : ''}` - ) - } else { - logger( - 'error', - 'Sources', - `Failed setup source: ${name}; source not available for use` - ) - } + }) + ) + this.patternMap.sort((a, b) => b.priority - a.priority) + return } - await Promise.all( - toLoad.map(async (file) => { - const name = path.basename(file, '.js') - const filePath = path.join(sourcesDir, file) + try { + await fs.access(sourcesDir) + const files = await fs.readdir(sourcesDir) + const jsFiles = files.filter((f) => f.endsWith('.js')) + const toLoad = jsFiles.filter((f) => { + const name = path.basename(f, '.js') + return ( + name !== 'youtube' && !!this.nodelink.options.sources[name]?.enabled + ) + }) + + if (this.nodelink.options.sources.youtube?.enabled) { + const name = 'youtube' + const filePath = path.join(sourcesDir, 'youtube', 'YouTube.js') const fileUrl = new URL(`file://${filePath.replace(/\\/g, '/')}`) const Mod = (await import(fileUrl)).default const instance = new Mod(this.nodelink) if (await instance.setup()) { this.sources.set(name, instance) + + this.sources.set('ytmusic', instance) + + if (Array.isArray(instance.searchTerms)) { + for (const term of instance.searchTerms) { + this.searchTermMap.set(term, name) + } + } + + if (Array.isArray(instance.patterns)) { + for (const regex of instance.patterns) { + if (regex instanceof RegExp) { + this.patternMap.push({ + regex, + sourceName: name, + priority: instance.priority || 0 + }) + } + } + } + logger( + 'info', + 'Sources', + `Loaded source: ${name} ${instance.searchTerms?.length ? `(terms: ${instance.searchTerms.join(', ')})` : ''}` + ) } else { logger( 'error', 'Sources', `Failed setup source: ${name}; source not available for use` ) - return } + } - if (Array.isArray(instance.searchTerms)) { - for (const term of instance.searchTerms) { - this.searchTermMap.set(term, name) + await Promise.all( + toLoad.map(async (file) => { + const name = path.basename(file, '.js') + const filePath = path.join(sourcesDir, file) + const fileUrl = new URL(`file://${filePath.replace(/\\/g, '/')}`) + const Mod = (await import(fileUrl)).default + + const instance = new Mod(this.nodelink) + if (await instance.setup()) { + this.sources.set(name, instance) + } else { + logger( + 'error', + 'Sources', + `Failed setup source: ${name}; source not available for use` + ) + return } - } - if (Array.isArray(instance.patterns)) { - for (const regex of instance.patterns) { - if (regex instanceof RegExp) { - this.patternMap.push({ - regex, - sourceName: name, - priority: instance.priority || 0 - }) + if (Array.isArray(instance.searchTerms)) { + for (const term of instance.searchTerms) { + this.searchTermMap.set(term, name) } } - } - logger( - 'info', - 'Sources', - `Loaded source: ${name} ${instance.searchTerms?.length ? `(terms: ${instance.searchTerms.join(', ')})` : ''}` - ) - }) - ) - this.patternMap.sort((a, b) => b.priority - a.priority) // Ordenar por prioridade + + if (Array.isArray(instance.patterns)) { + for (const regex of instance.patterns) { + if (regex instanceof RegExp) { + this.patternMap.push({ + regex, + sourceName: name, + priority: instance.priority || 0 + }) + } + } + } + logger( + 'info', + 'Sources', + `Loaded source: ${name} ${instance.searchTerms?.length ? `(terms: ${instance.searchTerms.join(', ')})` : ''}` + ) + }) + ) + } catch (e) { + logger('error', 'Sources', `Sources directory not found or error loading sources: ${sourcesDir} - ${e.message}`) + } + this.patternMap.sort((a, b) => b.priority - a.priority) } async _instrumentedSourceCall(sourceName, method, ...args) { @@ -150,7 +201,7 @@ export default class SourcesManager { if (!sourceName) { throw new Error(`Source not found for term: ${sourceTerm}`) } - logger('debug', 'Sources', `Searching on ${sourceName} for: "${query}"`) + logger('debug', 'Sources', `Searching on ${sourceName} for: "${query}" `) return this._instrumentedSourceCall(sourceName, 'search', query, sourceTerm) } diff --git a/src/managers/statsManager.js b/src/managers/statsManager.js index e8adb53f..2dce9618 100644 --- a/src/managers/statsManager.js +++ b/src/managers/statsManager.js @@ -447,8 +447,13 @@ export default class StatsManager { incrementApiRequest(endpoint) { const sanitized = this._sanitizeEndpoint(endpoint) - this.stats.api.requests[sanitized] = - (this.stats.api.requests[sanitized] || 0) + 1 + + if (Object.keys(this.stats.api.requests).length > 500 && !this.stats.api.requests[sanitized]) { + this.stats.api.requests['others'] = (this.stats.api.requests['others'] || 0) + 1 + } else { + this.stats.api.requests[sanitized] = (this.stats.api.requests[sanitized] || 0) + 1 + } + if (this.promApiRequests) { this.promApiRequests.inc({ endpoint: sanitized }) } @@ -456,7 +461,13 @@ export default class StatsManager { incrementApiError(endpoint) { const sanitized = this._sanitizeEndpoint(endpoint) - this.stats.api.errors[sanitized] = (this.stats.api.errors[sanitized] || 0) + 1 + + if (Object.keys(this.stats.api.errors).length > 500 && !this.stats.api.errors[sanitized]) { + this.stats.api.errors['others'] = (this.stats.api.errors['others'] || 0) + 1 + } else { + this.stats.api.errors[sanitized] = (this.stats.api.errors[sanitized] || 0) + 1 + } + if (this.promApiErrors) { this.promApiErrors.inc({ endpoint: sanitized }) } diff --git a/src/managers/workerManager.js b/src/managers/workerManager.js index b7150f37..10debd5c 100644 --- a/src/managers/workerManager.js +++ b/src/managers/workerManager.js @@ -3,7 +3,6 @@ import crypto from 'node:crypto' import os from 'node:os' import { logger } from '../utils.js' -import PlayerBackupManager from './playerBackupManager.js' export default class WorkerManager { constructor(config) { @@ -35,7 +34,6 @@ export default class WorkerManager { this.commandTimeout = config.cluster?.commandTimeout || 45000 this.fastCommandTimeout = config.cluster?.fastCommandTimeout || 10000 this.maxRetries = config.cluster?.maxRetries || 2 - this.backupManager = new PlayerBackupManager() this.scalingConfig = { maxPlayersPerWorker: config.cluster.scaling?.maxPlayersPerWorker || 20, targetUtilization: config.cluster.scaling?.targetUtilization || 0.7, @@ -70,7 +68,6 @@ export default class WorkerManager { const affectedGuilds = Array.from( this.workerToGuilds.get(worker.id) || [] ) - const snapshots = this.backupManager.getWorkerSnapshots(worker.id) this._retryPendingRequestsForWorker(worker.id) this.removeWorker(worker.id) @@ -85,13 +82,10 @@ export default class WorkerManager { logger( 'info', 'Cluster', - `Respawning worker and restoring ${snapshots.length} players...` + 'Respawning worker...' ) setTimeout(() => { - const newWorker = this.forkWorker() - if (newWorker && snapshots.length > 0) { - this._restorePlayers(newWorker, snapshots) - } + this.forkWorker() if (global.nodelink?.statsManager) { global.nodelink.statsManager.incrementWorkerRestart(worker.id) } @@ -438,14 +432,6 @@ export default class WorkerManager { if (msg.error) callback.reject(new Error(String(msg.error))) else callback.resolve(msg.payload) } - } else if (msg.type === 'playerSnapshot') { - const { playerKey, playerState } = msg.payload - playerState.guildId = playerKey.split(':')[0] - this.backupManager.storeSnapshot(playerKey, worker.id, playerState) - } else if (msg.type === 'playerDestroyed') { - const { guildId, userId } = msg.payload - const playerKey = `${guildId}:${userId}` - this.backupManager.removeSnapshot(playerKey) } else if (msg.type === 'workerStats') { this.statsUpdateBatch.set(worker.id, msg.stats) @@ -609,39 +595,6 @@ export default class WorkerManager { } } - async _restorePlayers(worker, snapshots) { - logger( - 'info', - 'Cluster', - `Restoring ${snapshots.length} players to worker ${worker.id}` - ) - - for (const snapshot of snapshots) { - try { - const playerKey = `${snapshot.guildId}:${snapshot.userId}` - this.assignGuildToWorker(playerKey, worker) - - await this.execute(worker, 'restorePlayer', { - snapshot - }) - - if (global.nodelink?.statsManager) { - global.nodelink.statsManager.incrementPlayerRestoration(worker.id) - } - - logger('debug', 'Cluster', `Restored player for ${playerKey}`) - } catch (error) { - logger( - 'error', - 'Cluster', - `Failed to restore player for guild ${snapshot.guildId} (bot: ${snapshot.userId}): ${error.message}` - ) - } - } - - logger('info', 'Cluster', `Restoration complete for worker ${worker.id}`) - } - getWorkerMetrics() { const workerMetrics = {} const now = Date.now() @@ -679,10 +632,6 @@ export default class WorkerManager { this._flushStatsUpdates() } - if (this.backupManager) { - this.backupManager.destroy() - } - this.pendingRequests.clear() this.workerFailureHistory.clear() this.statsUpdateBatch.clear() @@ -847,4 +796,4 @@ export default class WorkerManager { } } } -} +} \ No newline at end of file diff --git a/src/playback/filtersManager.js b/src/playback/filtersManager.js index 722e7b21..0af4269e 100644 --- a/src/playback/filtersManager.js +++ b/src/playback/filtersManager.js @@ -41,6 +41,12 @@ export class FiltersManager extends Transform { spatial: new Spatial() } + if (this.nodelink.extensions?.filters) { + for (const [name, filter] of this.nodelink.extensions.filters) { + this.availableFilters[name] = filter + } + } + this.update(options) } diff --git a/src/playback/player.js b/src/playback/player.js index e5184a9e..c3b20d3e 100644 --- a/src/playback/player.js +++ b/src/playback/player.js @@ -100,8 +100,15 @@ export class Player { async _initAudioMixer() { const { AudioMixer } = await import('./AudioMixer.js') - this.audioMixer = new AudioMixer(this.nodelink.options?.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5, autoCleanup: true }) - + this.audioMixer = new AudioMixer( + this.nodelink.options?.mix ?? { + enabled: true, + defaultVolume: 0.8, + maxLayersMix: 5, + autoCleanup: true + } + ) + this.audioMixer.on('mixStarted', (data) => { this.emitEvent(GatewayEvents.MIX_STARTED, { mixId: data.id, @@ -109,16 +116,20 @@ export class Player { volume: data.volume }) }) - + this.audioMixer.on('mixEnded', (data) => { this.emitEvent(GatewayEvents.MIX_ENDED, { mixId: data.id, reason: data.reason }) }) - + this.audioMixer.on('mixError', (data) => { - logger('error', 'Player', `Mix error for ${data.id}: ${data.error.message}`) + logger( + 'error', + 'Player', + `Mix error for ${data.id}: ${data.error.message}` + ) }) } @@ -193,7 +204,11 @@ export class Player { byRemote: true }) } else if (state.status === 'destroyed') { - this.connection = null + logger( + 'warn', + 'Player', + `Voice connection destroyed for guild ${this.guildId}` + ) } this._sendUpdate() } @@ -280,7 +295,7 @@ export class Player { 'Player', `Voice connection reset for guild ${this.guildId}. Attempting to manually reconnect.` ) - this.updateVoice(this.voice) + this.updateVoice(this.voice, true) } severity = 'suspicious' @@ -359,7 +374,7 @@ export class Player { track: trackToEmit, reason: reason }) - + if (this.audioMixer && this.audioMixer.autoCleanup) { this.audioMixer.clearLayers('MAIN_ENDED') } @@ -564,7 +579,11 @@ export class Player { async _startPlayback(startTime = 0) { if (!this.track) return false - const urlData = await this.nodelink.sources.getTrackUrl(this.track.info) + const trackInfo = { + ...this.track.info, + audioTrackId: this.track.audioTrackId + } + const urlData = await this.nodelink.sources.getTrackUrl(trackInfo) this.streamInfo = { ...urlData, trackInfo: this.track.info } logger('debug', 'Player', `Got track URL for guild ${this.guildId}`, { urlData @@ -648,6 +667,7 @@ export class Player { encoded, info, userData, + audioTrackId, noReplace = false, startTime, endTime = 0 @@ -687,7 +707,7 @@ export class Player { this._emitTrackEnd(EndReasons.REPLACED) } - this.track = { encoded, info, endTime, userData } + this.track = { encoded, info, endTime, userData, audioTrackId } if (!this.voice.endpoint || !this.voice.token) { logger( @@ -724,7 +744,7 @@ export class Player { async seek(position, endTime) { if (this.destroying || !this.track) return false - if (!this.track.info.isSeekable && this.track.info.isStream) return false + if (!this.track.info.isSeekable && !this.track.info.isStream) return false const seekPosition = position ?? this._realPosition() @@ -765,9 +785,11 @@ export class Player { 'Still no stream info URL available for seek.' ) if (this.track) { - const urlData = await this.nodelink.sources.getTrackUrl( - this.track.info - ) + const trackInfo = { + ...this.track.info, + audioTrackId: this.track.audioTrackId + } + const urlData = await this.nodelink.sources.getTrackUrl(trackInfo) this.streamInfo = { ...urlData, trackInfo: this.track.info } logger( 'debug', @@ -893,7 +915,11 @@ export class Player { this.position = position this.track.endTime = endTime - const urlData = await this.nodelink.sources.getTrackUrl(this.track.info) + const trackInfo = { + ...this.track.info, + audioTrackId: this.track.audioTrackId + } + const urlData = await this.nodelink.sources.getTrackUrl(trackInfo) this.streamInfo = { ...urlData, trackInfo: this.track.info } if (urlData.exception) { @@ -1068,7 +1094,7 @@ export class Player { return true } - updateVoice(voicePayload = {}) { + updateVoice(voicePayload = {}, force = false) { if (this.destroying) return const { sessionId, token, endpoint } = voicePayload @@ -1088,7 +1114,7 @@ export class Player { } if (this.voice.sessionId && this.voice.token && this.voice.endpoint) { - if (!changed) { + if (!changed && !force) { logger( 'debug', 'Player', @@ -1180,19 +1206,23 @@ export class Player { throw new Error('AudioMixer not initialized') } - const mixConfig = this.nodelink?.options?.mix ?? { - enabled: true, - defaultVolume: 0.8, - maxLayersMix: 5 + const mixConfig = this.nodelink?.options?.mix ?? { + enabled: true, + defaultVolume: 0.8, + maxLayersMix: 5 } if (this.audioMixer.mixLayers.size >= mixConfig.maxLayersMix) { - throw new Error(`Maximum number of mix layers (${mixConfig.maxLayersMix}) reached`) + throw new Error( + `Maximum number of mix layers (${mixConfig.maxLayersMix}) reached` + ) } const mixVolume = volume ?? mixConfig.defaultVolume - const { createAudioResource: createResource } = await import('./streamProcessor.js') + const { createAudioResource: createResource } = await import( + './streamProcessor.js' + ) const urlData = await this.nodelink.sources.getTrackUrl(trackPayload.info) if (!urlData || !urlData.url) { diff --git a/src/playback/streamProcessor.js b/src/playback/streamProcessor.js index a26e4303..bf8cc1ea 100644 --- a/src/playback/streamProcessor.js +++ b/src/playback/streamProcessor.js @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer' -import { PassThrough, Readable, Transform } from 'node:stream' +import { PassThrough, Readable, Transform, pipeline } from 'node:stream' import LibSampleRate from '@alexanderolsen/libsamplerate-js' import FAAD2NodeDecoder from '@ecliptia/faad2-wasm/faad2_node_decoder.js' @@ -44,8 +44,8 @@ const DOWNMIX_COEFFICIENTS = Object.freeze({ }) const SAMPLE_RATES = Object.freeze([ - 96000, 88200, 64000, 48000, 44100, 32000, - 24000, 22050, 16000, 12000, 11025, 8000, 7350 + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, + 8000, 7350 ]) const EMPTY_BUFFER = Buffer.alloc(0) @@ -79,14 +79,21 @@ const _floatToInt16Buffer = (floatArray) => { return Buffer.from(output.buffer) } -const _createAdtsHeader = (sampleLength, profile, samplingIndex, channelCount) => { +const _createAdtsHeader = ( + sampleLength, + profile, + samplingIndex, + channelCount +) => { const frameLength = sampleLength + 7 const profileIndex = profile - 1 return Buffer.from([ 0xff, 0xf1, - ((profileIndex & 0x03) << 6) | ((samplingIndex & 0x0f) << 2) | ((channelCount & 0x04) >> 2), + ((profileIndex & 0x03) << 6) | + ((samplingIndex & 0x0f) << 2) | + ((channelCount & 0x04) >> 2), ((channelCount & 0x03) << 6) | ((frameLength & 0x1800) >> 11), (frameLength & 0x7f8) >> 3, ((frameLength & 0x7) << 5) | 0x1f, @@ -144,8 +151,7 @@ const _isFmp4Format = (type) => type.indexOf('mpegurl') !== -1 const _isMpegtsFormat = (type) => - type.indexOf('mpegts') !== -1 || - type.indexOf('video/mp2t') !== -1 + type.indexOf('mpegts') !== -1 || type.indexOf('video/mp2t') !== -1 const _isMp4Format = (type) => type.indexOf('mp4') !== -1 || @@ -153,8 +159,7 @@ const _isMp4Format = (type) => type.indexOf('m4v') !== -1 || type.indexOf('mov') !== -1 -const _isWebmFormat = (type) => - type.indexOf('webm') !== -1 +const _isWebmFormat = (type) => type.indexOf('webm') !== -1 class BaseAudioResource { constructor() { @@ -196,7 +201,9 @@ class BaseAudioResource { setVolume(volume) { if (!this.pipes) return - const volumeTransformer = this.pipes.find((p) => p instanceof VolumeTransformer) + const volumeTransformer = this.pipes.find( + (p) => p instanceof VolumeTransformer + ) if (volumeTransformer) { volumeTransformer.setVolume(volume) @@ -217,11 +224,21 @@ class BaseAudioResource { } } - emit(event, ...args) { this.stream?.emit(event, ...args) } - on(event, listener) { this.stream?.on(event, listener) } - off(event, listener) { this.stream?.off(event, listener) } - once(event, listener) { this.stream?.once(event, listener) } - removeListener(event, listener) { this.stream?.removeListener(event, listener) } + emit(event, ...args) { + this.stream?.emit(event, ...args) + } + on(event, listener) { + this.stream?.on(event, listener) + } + off(event, listener) { + this.stream?.off(event, listener) + } + once(event, listener) { + this.stream?.once(event, listener) + } + removeListener(event, listener) { + this.stream?.removeListener(event, listener) + } removeAllListeners() { if (!this.stream?.eventNames) return @@ -231,8 +248,12 @@ class BaseAudioResource { } } - read() { return this.stream?.read() } - resume() { this.stream?.resume() } + read() { + return this.stream?.read() + } + resume() { + this.stream?.resume() + } } class SymphoniaDecoderStream extends Transform { @@ -283,10 +304,7 @@ class SymphoniaDecoderStream extends Transform { } _transform(chunk, encoding, callback) { - if (this._aborted || !this.decoder) { - callback() - return - } + if (this._aborted || !this.decoder) return callback() this.decoder.push(chunk) this._scheduleDecode() @@ -300,28 +318,37 @@ class SymphoniaDecoderStream extends Transform { } _scheduleDecode() { - if (this._loopScheduled || this._isDecoding || !this._isDecoderValid()) return + if ( + this._loopScheduled || + this._isDecoding || + !this._isDecoderValid() || + this.readableFlowing === false || + this.readableLength >= this.readableHighWaterMark + ) + return this._loopScheduled = true this._timeoutId = setTimeout(() => { this._timeoutId = null this._loopScheduled = false - if (this._isDecoderValid()) { - this._decodeLoop() - } + if (this._isDecoderValid()) this._decodeLoop() }, AUDIO_CONSTANTS.decodeIntervalMs) } async _decodeLoop() { if (!this._isDecoderValid() || this.readableFlowing === false) return - this._isDecoding = true try { let hasMoreData = true - while (hasMoreData && this._isDecoderValid() && this.readableFlowing !== false) { + while ( + hasMoreData && + this._isDecoderValid() && + this.readableFlowing !== false && + this.readableLength < this.readableHighWaterMark + ) { hasMoreData = await this._processAudio() if (hasMoreData && this._isDecoderValid()) { @@ -340,13 +367,19 @@ class SymphoniaDecoderStream extends Transform { } const bufferedBytes = this.decoder?.bufferedBytes ?? 0 - if (bufferedBytes > 0 && this._isDecoderValid()) { + if ( + bufferedBytes > 0 && + this._isDecoderValid() && + this.readableFlowing !== false && + this.readableLength < this.readableHighWaterMark + ) { this._scheduleDecode() } } async _processAudio() { if (!this._isDecoderValid()) return false + if (this.readableLength >= this.readableHighWaterMark) return true if (!this.decoder.isProbed) { try { @@ -359,17 +392,20 @@ class SymphoniaDecoderStream extends Transform { let decodeCount = 0 let hasOutput = false - while (decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && this._isDecoderValid()) { - const bufferedBytes = this.decoder?.bufferedBytes ?? 0 + while ( + decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && + this._isDecoderValid() && + this.readableLength < this.readableHighWaterMark + ) { const result = this.decoder?.decode() - if (!result) break const { samples, sampleRate, channels } = result - const output = sampleRate !== AUDIO_CONFIG.sampleRate - ? await this._resample(samples, channels, sampleRate) - : samples + const output = + sampleRate !== AUDIO_CONFIG.sampleRate + ? await this._resample(samples, channels, sampleRate) + : samples if (this._aborted) break @@ -377,10 +413,13 @@ class SymphoniaDecoderStream extends Transform { hasOutput = true decodeCount++ - if (this.resumeInput && bufferedBytes < BUFFER_THRESHOLDS.minCompressed) { - const callback = this.resumeInput - this.resumeInput = null - callback() + if (this.resumeInput) { + const afterBytes = this.decoder?.bufferedBytes ?? 0 + if (afterBytes < BUFFER_THRESHOLDS.minCompressed) { + const cb = this.resumeInput + this.resumeInput = null + cb() + } } if (!canPush) break @@ -390,7 +429,7 @@ class SymphoniaDecoderStream extends Transform { return hasOutput || remainingBytes > 0 } - async _resample(pcmInt16, channels, inputRate) { + async _resample(pcmInt16Buf, channels, inputRate) { if (this._aborted) return EMPTY_BUFFER if (!this.resampler) { @@ -402,13 +441,16 @@ class SymphoniaDecoderStream extends Transform { ) } - const float32 = new Float32Array( - pcmInt16.buffer, - pcmInt16.byteOffset, - pcmInt16.length / 2 + const i16 = new Int16Array( + pcmInt16Buf.buffer, + pcmInt16Buf.byteOffset, + pcmInt16Buf.byteLength / 2 ) - return _floatToInt16Buffer(this.resampler.full(float32)) + const f32 = new Float32Array(i16.length) + for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768 + + return _floatToInt16Buffer(this.resampler.full(f32)) } _flush(callback) { @@ -424,9 +466,10 @@ class SymphoniaDecoderStream extends Transform { try { this.decoder.closeInput() - let result let count = 0 - while ((result = this.decoder?.decode()) !== null && result && count < 10) { + while (count < 1000) { + const result = this.decoder?.decode() + if (!result) break this.push(result.samples) count++ } @@ -501,16 +544,18 @@ class MPEGTSToAACStream extends Transform { } try { - const data = this.buffer.length > 0 - ? Buffer.concat([this.buffer, chunk]) - : chunk + const data = + this.buffer.length > 0 ? Buffer.concat([this.buffer, chunk]) : chunk this.buffer = EMPTY_BUFFER const dataLength = data.length let position = 0 - while (position <= dataLength - MPEGTS_CONFIG.packetSize && !this._aborted) { + while ( + position <= dataLength - MPEGTS_CONFIG.packetSize && + !this._aborted + ) { if (data[position] !== MPEGTS_CONFIG.syncByte) { const syncIndex = data.indexOf(MPEGTS_CONFIG.syncByte, position + 1) if (syncIndex === -1) { @@ -521,7 +566,10 @@ class MPEGTSToAACStream extends Transform { continue } - const packet = data.subarray(position, position + MPEGTS_CONFIG.packetSize) + const packet = data.subarray( + position, + position + MPEGTS_CONFIG.packetSize + ) const payloadUnitStartIndicator = !!(packet[1] & 0x40) const pid = ((packet[1] & 0x1f) << 8) + packet[2] @@ -569,16 +617,20 @@ class MPEGTSToAACStream extends Transform { _processPMT(packet, offset) { offset += packet[offset] + 1 - const sectionLength = ((packet[offset + 1] & 0x0f) << 8) | packet[offset + 2] + const sectionLength = + ((packet[offset + 1] & 0x0f) << 8) | packet[offset + 2] const tableEnd = offset + 3 + sectionLength - 4 - const programInfoLength = ((packet[offset + 10] & 0x0f) << 8) | packet[offset + 11] + const programInfoLength = + ((packet[offset + 10] & 0x0f) << 8) | packet[offset + 11] offset += 12 + programInfoLength while (offset < tableEnd && offset < MPEGTS_CONFIG.packetSize) { const streamType = packet[offset] - const elementaryPid = ((packet[offset + 1] & 0x1f) << 8) | packet[offset + 2] - const esInfoLength = ((packet[offset + 3] & 0x0f) << 8) | packet[offset + 4] + const elementaryPid = + ((packet[offset + 1] & 0x1f) << 8) | packet[offset + 2] + const esInfoLength = + ((packet[offset + 3] & 0x0f) << 8) | packet[offset + 4] if (streamType === MPEGTS_CONFIG.aacStreamType && !this.aacPidFound) { this.aacPid = elementaryPid @@ -909,11 +961,16 @@ class MP4ToAACStream extends Transform { } this.audioConfig = this._getAudioConfig(audioTrack) - this.mp4boxFile.setExtractionOptions(audioTrack.id, null, { nbSamples: 1 }) + this.mp4boxFile.setExtractionOptions(audioTrack.id, null, { + nbSamples: 1 + }) this.mp4boxFile.start() this.isReady = true } catch (err) { - this.emit('error', new Error(`MP4 initialization error: ${err.message}`)) + this.emit( + 'error', + new Error(`MP4 initialization error: ${err.message}`) + ) } } @@ -930,7 +987,10 @@ class MP4ToAACStream extends Transform { } } catch (err) { if (!this._aborted) { - this.emit('error', new Error(`MP4Box sample processing error: ${err.message}`)) + this.emit( + 'error', + new Error(`MP4Box sample processing error: ${err.message}`) + ) } } } @@ -945,11 +1005,19 @@ class MP4ToAACStream extends Transform { _emitSampleWithADTS(sample) { const { profile, samplingIndex, channelCount } = this.audioConfig - const sampleData = sample.data instanceof ArrayBuffer - ? Buffer.from(sample.data) - : Buffer.from(sample.data.buffer || sample.data) - - this.push(_createAdtsHeader(sampleData.byteLength, profile, samplingIndex, channelCount)) + const sampleData = + sample.data instanceof ArrayBuffer + ? Buffer.from(sample.data) + : Buffer.from(sample.data.buffer || sample.data) + + this.push( + _createAdtsHeader( + sampleData.byteLength, + profile, + samplingIndex, + channelCount + ) + ) this.push(sampleData) } @@ -969,7 +1037,9 @@ class MP4ToAACStream extends Transform { const objectType = Number.parseInt(codecParts[2], 10) if (objectType === 5) { - const coreSamplingIndex = SAMPLE_RATES.indexOf(track.audio.sample_rate / 2) + const coreSamplingIndex = SAMPLE_RATES.indexOf( + track.audio.sample_rate / 2 + ) if (coreSamplingIndex !== -1) { samplingIndex = coreSamplingIndex } @@ -993,9 +1063,13 @@ class MP4ToAACStream extends Transform { } try { - const arrayBuffer = chunk instanceof ArrayBuffer - ? chunk - : chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) + const arrayBuffer = + chunk instanceof ArrayBuffer + ? chunk + : chunk.buffer.slice( + chunk.byteOffset, + chunk.byteOffset + chunk.byteLength + ) arrayBuffer.fileStart = this.offset this.offset += arrayBuffer.byteLength @@ -1240,7 +1314,11 @@ class MixerTransform extends Transform { } _transform(mainChunk, encoding, callback) { - if (!this.audioMixer || !this.audioMixer.enabled || !this.audioMixer.hasActiveLayers()) { + if ( + !this.audioMixer || + !this.audioMixer.enabled || + !this.audioMixer.hasActiveLayers() + ) { return callback(null, mainChunk) } @@ -1255,12 +1333,21 @@ class MixerTransform extends Transform { } class StreamAudioResource extends BaseAudioResource { - constructor(stream, type, nodelink, initialFilters = {}, volume = 1.0, audioMixer = null, returnPCM = false) { + constructor( + stream, + type, + nodelink, + initialFilters = {}, + volume = 1.0, + audioMixer = null, + returnPCM = false + ) { super() this._validateInputStream(stream) - const resamplingQuality = nodelink.options.audio.resamplingQuality || 'fastest' + const resamplingQuality = + nodelink.options.audio.resamplingQuality || 'fastest' const normalizedType = normalizeFormat(type) this.pipes = [stream] @@ -1275,9 +1362,15 @@ class StreamAudioResource extends BaseAudioResource { if (returnPCM) { this._createPCMOutputPipeline(pcmStream, volume) } else { - this._createOutputPipeline(pcmStream, nodelink, initialFilters, volume, audioMixer) + this._createOutputPipeline( + pcmStream, + nodelink, + initialFilters, + volume, + audioMixer + ) } - + this._setupEventHandlers(stream) } @@ -1309,31 +1402,44 @@ class StreamAudioResource extends BaseAudioResource { _createAACPipeline(stream, type, resamplingQuality) { const lowerType = type.toLowerCase() let aacStream = stream + const streams = [stream] if (_isFmp4Format(lowerType)) { const demuxer = new FMP4ToAACStream() - aacStream = stream.pipe(demuxer) - this.pipes.push(demuxer) + streams.push(demuxer) } else if (_isMpegtsFormat(lowerType)) { const demuxer = new MPEGTSToAACStream() - aacStream = stream.pipe(demuxer) - this.pipes.push(demuxer) + streams.push(demuxer) } else if (_isMp4Format(lowerType)) { const demuxer = new MP4ToAACStream() - aacStream = stream.pipe(demuxer) - this.pipes.push(demuxer) + streams.push(demuxer) } const decoder = new AACDecoderStream({ resamplingQuality }) - this.pipes.push(decoder) + streams.push(decoder) - return aacStream.pipe(decoder) + this.pipes.push(...streams.slice(1)) + + pipeline(streams, (err) => { + if (err && !this._destroyed) { + this.stream?.emit('error', err) + } + }) + + return decoder } _createSymphoniaPipeline(stream, resamplingQuality) { const decoder = new SymphoniaDecoderStream({ resamplingQuality }) this.pipes.push(decoder) - return stream.pipe(decoder) + + pipeline(stream, decoder, (err) => { + if (err && !this._destroyed) { + this.stream?.emit('error', err) + } + }) + + return decoder } _createOpusPipeline(stream, type) { @@ -1343,17 +1449,33 @@ class StreamAudioResource extends BaseAudioResource { frameSize: AUDIO_CONFIG.frameSize }) + const streams = [stream] + if (_isWebmFormat(type.toLowerCase())) { const demuxer = new WebmOpusDemuxer() - this.pipes.push(demuxer, decoder) - return stream.pipe(demuxer).pipe(decoder) + streams.push(demuxer) + this.pipes.push(demuxer) } + streams.push(decoder) this.pipes.push(decoder) - return stream.pipe(decoder) + + pipeline(streams, (err) => { + if (err && !this._destroyed) { + this.stream?.emit('error', err) + } + }) + + return decoder } - _createOutputPipeline(pcmStream, nodelink, initialFilters, volume, audioMixer = null) { + _createOutputPipeline( + pcmStream, + nodelink, + initialFilters, + volume, + audioMixer = null + ) { const volumeTransformer = new VolumeTransformer({ type: 's16le', volume }) const filters = new FiltersManager(nodelink, initialFilters) const opusEncoder = new OpusEncoder({ @@ -1362,26 +1484,61 @@ class StreamAudioResource extends BaseAudioResource { frameSize: AUDIO_CONFIG.frameSize }) - let pipeline = pcmStream.pipe(volumeTransformer) + const streams = [pcmStream, volumeTransformer] + this.pipes.push(volumeTransformer) if (audioMixer && (nodelink.options?.mix?.enabled ?? true)) { const mixer = new MixerTransform(audioMixer) - pipeline = pipeline.pipe(mixer) + streams.push(mixer) this.pipes.push(mixer) } - pipeline.pipe(filters).pipe(opusEncoder) + streams.push(filters) + this.pipes.push(filters) + + // Inject Audio Interceptors (Low-level stream manipulation) + if (nodelink.extensions?.audioInterceptors) { + for (const interceptorFactory of nodelink.extensions.audioInterceptors) { + try { + const interceptorStream = interceptorFactory() + if ( + interceptorStream && + typeof interceptorStream.pipe === 'function' + ) { + streams.push(interceptorStream) + this.pipes.push(interceptorStream) + } + } catch (e) { + // Log error but don't break pipeline + console.error(`Audio interceptor error: ${e.message}`) + } + } + } + + streams.push(opusEncoder) + this.pipes.push(opusEncoder) + + pipeline(streams, (err) => { + if (err && !this._destroyed) { + opusEncoder.emit('error', err) + } + }) - this.pipes.push(volumeTransformer, filters, opusEncoder) this.stream = opusEncoder } _createPCMOutputPipeline(pcmStream, volume) { if (volume !== 1.0) { const volumeTransformer = new VolumeTransformer({ type: 's16le', volume }) - const outputStream = pcmStream.pipe(volumeTransformer) this.pipes.push(volumeTransformer) - this.stream = outputStream + + pipeline(pcmStream, volumeTransformer, (err) => { + if (err && !this._destroyed) { + this.stream?.emit('error', err) + } + }) + + this.stream = volumeTransformer } else { this.stream = pcmStream } @@ -1421,14 +1578,30 @@ class StreamAudioResource extends BaseAudioResource { return new Error( `Unsupported audio format: '${type}'.\n` + - 'Supported formats:\n' + - supportedFormats.map((f) => ` • ${f}`).join('\n') + 'Supported formats:\n' + + supportedFormats.map((f) => ` • ${f}`).join('\n') ) } } -export const createAudioResource = (stream, type, nodelink, initialFilters = {}, volume = 1.0, audioMixer = null, returnPCM = false) => - new StreamAudioResource(stream, type, nodelink, initialFilters, volume, audioMixer, returnPCM) +export const createAudioResource = ( + stream, + type, + nodelink, + initialFilters = {}, + volume = 1.0, + audioMixer = null, + returnPCM = false +) => + new StreamAudioResource( + stream, + type, + nodelink, + initialFilters, + volume, + audioMixer, + returnPCM + ) export const createSeekeableAudioResource = async ( url, @@ -1443,14 +1616,17 @@ export const createSeekeableAudioResource = async ( try { const { stream, meta } = await seekableStream(url, seekTime, endTime, {}) - const passthroughStream = new PassThrough({ highWaterMark: AUDIO_CONFIG.highWaterMark }) + const passthroughStream = new PassThrough({ + highWaterMark: AUDIO_CONFIG.highWaterMark + }) - stream.on('data', (chunk) => passthroughStream.write(chunk)) - stream.on('end', () => { - passthroughStream.end() + passthroughStream.once('finish', () => { passthroughStream.emit('finishBuffering') }) - stream.on('error', (err) => passthroughStream.emit('error', err)) + + pipeline(stream, passthroughStream, (err) => { + if (err) passthroughStream.emit('error', err) + }) const format = meta.codec?.container || player.streamInfo.format @@ -1469,29 +1645,77 @@ export const createSeekeableAudioResource = async ( } export const createPCMStream = (stream, type, nodelink, volume = 1.0) => { - const resamplingQuality = nodelink.options.audio.resamplingQuality || 'fastest' + const resamplingQuality = + nodelink.options.audio.resamplingQuality || 'fastest' const normalizedType = normalizeFormat(type) - - const resource = new StreamAudioResource.__proto__.constructor.call({ - pipes: [stream], - _createDecoderPipeline: StreamAudioResource.prototype._createDecoderPipeline, - _createSymphoniaPipeline: StreamAudioResource.prototype._createSymphoniaPipeline, - _createOpusPipeline: StreamAudioResource.prototype._createOpusPipeline, - _createAACPipeline: StreamAudioResource.prototype._createAACPipeline - }) - - const pcmStream = resource._createDecoderPipeline.call( - { pipes: [] }, - stream, - type, - normalizedType, - resamplingQuality - ) - + + let pcmStream + + switch (normalizedType) { + case SupportedFormats.AAC: { + const lowerType = type.toLowerCase() + const streams = [stream] + + if (_isFmp4Format(lowerType)) streams.push(new FMP4ToAACStream()) + else if (_isMpegtsFormat(lowerType)) streams.push(new MPEGTSToAACStream()) + else if (_isMp4Format(lowerType)) streams.push(new MP4ToAACStream()) + + const decoder = new AACDecoderStream({ resamplingQuality }) + streams.push(decoder) + + pipeline(streams, (err) => { + if (err) decoder.emit('error', err) + }) + + pcmStream = decoder + break + } + + case SupportedFormats.MPEG: + case SupportedFormats.FLAC: + case SupportedFormats.OGG_VORBIS: + case SupportedFormats.WAV: { + const decoder = new SymphoniaDecoderStream({ resamplingQuality }) + pipeline(stream, decoder, (err) => { + if (err) decoder.emit('error', err) + }) + pcmStream = decoder + break + } + + case SupportedFormats.OPUS: { + const decoder = new OpusDecoder({ + rate: AUDIO_CONFIG.sampleRate, + channels: AUDIO_CONFIG.channels, + frameSize: AUDIO_CONFIG.frameSize + }) + + if (_isWebmFormat(type.toLowerCase())) { + const demuxer = new WebmOpusDemuxer() + pipeline(stream, demuxer, decoder, (err) => { + if (err) decoder.emit('error', err) + }) + } else { + pipeline(stream, decoder, (err) => { + if (err) decoder.emit('error', err) + }) + } + + pcmStream = decoder + break + } + + default: + throw new Error(`Unsupported audio format: '${type}'`) + } + if (volume !== 1.0) { const volumeTransformer = new VolumeTransformer({ type: 's16le', volume }) - return pcmStream.pipe(volumeTransformer) + pipeline(pcmStream, volumeTransformer, (err) => { + if (err) volumeTransformer.emit('error', err) + }) + return volumeTransformer } - + return pcmStream } diff --git a/src/sources/bandcamp.js b/src/sources/bandcamp.js index 5008d78b..1114dac1 100644 --- a/src/sources/bandcamp.js +++ b/src/sources/bandcamp.js @@ -145,7 +145,7 @@ export default class BandCampSource { .filter((track) => track !== null) return { - loadType: 'album', + loadType: 'playlist', data: { info: { name: tralbumData.current.title, diff --git a/src/sources/flowery.js b/src/sources/flowery.js index 895ba35b..d8f83af1 100644 --- a/src/sources/flowery.js +++ b/src/sources/flowery.js @@ -9,13 +9,45 @@ export default class FlowerySource { this.searchTerms = ['ftts', 'flowery'] this.patterns = [/^ftts:\/\//] this.priority = 50 + this.voiceMap = new Map() // Stores voiceName -> voiceId mapping + this.defaultVoiceId = null // Stores the ID of the default voice } async setup() { logger('info', 'Sources', 'Loaded Flowery TTS source.') + await this._fetchVoices() return true } + async _fetchVoices() { + try { + const voicesEndpoint = 'https://api.flowery.pw/v1/tts/voices' + const { body, error, statusCode } = await makeRequest(voicesEndpoint, { method: 'GET' }) + + if (error || statusCode !== 200 || !body || !Array.isArray(body.voices)) { + logger('error', 'Flowery', `Failed to fetch voices from ${voicesEndpoint}: ${error?.message || `Status ${statusCode}`}`) + return + } + + this.voiceMap.clear() + for (const voice of body.voices) { + this.voiceMap.set(voice.name.toLowerCase(), voice.id) + } + + if (body.default?.id) { + this.defaultVoiceId = body.default.id + logger('info', 'Flowery', `Default voice set to: ${body.default.name} (${body.default.id})`) + } else if (body.voices.length > 0) { + this.defaultVoiceId = body.voices[0].id + logger('info', 'Flowery', `Using first available voice as default: ${body.voices[0].name} (${body.voices[0].id})`) + } + + logger('debug', 'Flowery', `Fetched ${this.voiceMap.size} voices.`) + } catch (e) { + logger('error', 'Flowery', `Exception fetching voices: ${e.message}`) + } + } + async search(query) { if (!query) { return { loadType: 'empty', data: {} } @@ -88,17 +120,24 @@ export default class FlowerySource { const config = this.config const enforceConfig = config.enforceConfig || false - let voice = config.voice || 'Salli' + let voiceName = config.voice || 'Salli' let translate = config.translate || false let silence = config.silence || 0 let speed = config.speed || 1.0 if (!enforceConfig) { - if (overrides.voice) voice = overrides.voice + if (overrides.voice) voiceName = overrides.voice if (overrides.translate !== undefined) translate = overrides.translate if (overrides.silence !== undefined) silence = overrides.silence if (overrides.speed !== undefined) speed = overrides.speed } + + let voiceId = this.voiceMap.get(voiceName.toLowerCase()) || this.defaultVoiceId + + if (!voiceId) { + logger('warn', 'Flowery', `Voice "${voiceName}" not found and no default voice available. Using a fallback empty voice ID.`) + voiceId = 'default' // Fallback to a generic 'default' if no ID is found + } let audioFormat = 'mp3' const quality = this.nodelink.options.audio?.quality || 'high' @@ -113,7 +152,7 @@ export default class FlowerySource { const baseUrl = 'https://api.flowery.pw/v1/tts' const queryParams = new URLSearchParams({ - voice, + voice: voiceId, text, translate: String(translate), silence: String(silence), diff --git a/src/sources/soundcloud.js b/src/sources/soundcloud.js index a2b64dd2..fb6a286d 100644 --- a/src/sources/soundcloud.js +++ b/src/sources/soundcloud.js @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, pipeline } from 'node:stream' import { encodeTrack, @@ -416,25 +416,16 @@ export default class SoundCloudSource { if (res.error) { stream.destroy(new Error(`Stream load failed: ${res.error.message}`)) - return } - const onError = (err) => { - logger('error', 'Sources', `Progressive error: ${err.message}`) - if (!stream.destroyed) stream.destroy(err) - } - - const onEnd = () => stream.emit('finishBuffering') - - res.stream.on('error', onError) - res.stream.on('end', onEnd) - res.stream.on('data', (chunk) => stream.write(chunk)) - - stream.on('close', () => { - res.stream.removeListener('error', onError) - res.stream.removeListener('end', onEnd) - if (!res.stream.destroyed) res.stream.destroy() + pipeline(res.stream, stream, (err) => { + if (err) { + logger('error', 'Sources', `Progressive pipeline error: ${err.message}`) + if (!stream.destroyed) stream.destroy(err) + } else { + stream.emit('finishBuffering') + } }) } catch (err) { this._logError('Progressive stream failed', err) diff --git a/src/sources/tidal.js b/src/sources/tidal.js index 2a25ada0..d56c551f 100644 --- a/src/sources/tidal.js +++ b/src/sources/tidal.js @@ -1,6 +1,22 @@ import { encodeTrack, http1makeRequest, logger } from '../utils.js' +import fs from 'node:fs/promises' +import path from 'node:path' const API_BASE = 'https://api.tidal.com/v1/' +const CACHE_VALIDITY_DAYS = 7 +const TIDAL_ASSET_URL = 'https://tidal.com/assets/index-CJ0DsMmf.js' + +const _functions = { + extractSecondClientId(text) { + const re = /clientId\s*[:=]\s*"([^"]+)"/g + let match, + count = 0 + while ((match = re.exec(text))) { + if (++count === 2) return match[1] + } + return null + } +} export default class TidalSource { constructor(nodelink) { @@ -16,16 +32,88 @@ export default class TidalSource { this.playlistLoadLimit = this.config?.playlistLoadLimit ?? 2 this.playlistPageLoadConcurrency = this.config?.playlistPageLoadConcurrency ?? 5 + this.tokenCachePath = path.join(process.cwd(), '.cache', 'tidal_token.json') } async setup() { - if (!this.token) { - logger('warn', 'Tidal', 'No token provided. Disabling source.') - return false + if (this.token && this.token !== 'token_here') return true + + const cachedToken = await this._loadTokenFromCache().catch(() => null) + if (cachedToken) { + this.token = cachedToken + logger('info', 'Tidal', 'Loaded valid token from cache.') + return true } + + try { + const res = await fetch(TIDAL_ASSET_URL) + if (!res.ok) throw new Error(`Status ${res.status}`) + + const token = _functions.extractSecondClientId(await res.text()) + + if (token) { + this.token = token + logger('info', 'Tidal', 'Fetched new token.') + await this._saveTokenToCache(token).catch((err) => + logger('warn', 'Tidal', `Cache save failed: ${err.message}`) + ) + } else { + logger('warn', 'Tidal', 'No clientId found in remote asset') + } + } catch (err) { + logger('warn', 'Tidal', `Token fetch failed: ${err.message}`) + } + return true } + async _loadTokenFromCache() { + try { + await fs.mkdir(path.dirname(this.tokenCachePath), { recursive: true }) + const data = await fs.readFile(this.tokenCachePath, 'utf-8') + const { token, timestamp } = JSON.parse(data) + + if (!token || !timestamp) return null + + const cacheAge = Date.now() - timestamp + const maxAge = CACHE_VALIDITY_DAYS * 24 * 60 * 60 * 1000 + + if (cacheAge > maxAge) { + logger('info', 'Tidal', 'Cached token has expired.') + return null + } + + return token + } catch (error) { + if (error.code !== 'ENOENT') { + logger('warn', 'Tidal', `Could not read token cache: ${error.message}`) + } + return null + } + } + + async _saveTokenToCache(token) { + try { + await fs.mkdir(path.dirname(this.tokenCachePath), { recursive: true }) + const dataToCache = { + token: token, + timestamp: Date.now() + } + await fs.writeFile( + this.tokenCachePath, + JSON.stringify(dataToCache), + 'utf-8' + ) + logger('info', 'Tidal', 'Saved new token to cache file.') + } catch (error) { + logger( + 'error', + 'Tidal', + `Failed to save token to cache: ${error.message}` + ) + } + } + async _getJson(endpoint, params = {}) { const url = new URL(`${API_BASE}${endpoint}`) params.countryCode = this.countryCode @@ -97,7 +185,7 @@ export default class TidalSource { const tracks = tracksData.items.map((item) => this._parseTrack(item)) return { - loadType: 'album', + loadType: 'playlist', data: { info: { name: albumData.title, selectedTrack: 0 }, tracks } } } diff --git a/src/sources/vimeo.js b/src/sources/vimeo.js new file mode 100644 index 00000000..388b4286 --- /dev/null +++ b/src/sources/vimeo.js @@ -0,0 +1,1444 @@ +import { PassThrough } from 'node:stream' +import { Buffer } from 'node:buffer' +import https from 'node:https' +import http from 'node:http' +import zlib from 'node:zlib' +import { spawn } from 'node:child_process' +import { encodeTrack, logger } from '../utils.js' + +const VIMEO_PATTERNS = [ + /^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)(?:|[/?#])/i, + /^https?:\/\/player\.vimeo\.com\/video\/(\d+)(?:|[/?#])/i, + /^https?:\/\/(?:www\.)?vimeo\.com\/channels\/[^/]+\/(\d+)(?:|[/?#])/i, + /^https?:\/\/(?:www\.)?vimeo\.com\/groups\/[^/]+\/videos\/(\d+)(?:|[/?#])/i, + /^https?:\/\/(?:www\.)?vimeo\.com\/album\/\d+\/video\/(\d+)(?:|[/?#])/i, + /^https?:\/\/(?:www\.)?vimeo\.com\/showcase\/\d+\/video\/(\d+)(?:|[/?#])/i +] + +const VIMEO_BASE = 'https://vimeo.com' +const VIMEO_PLAYER_BASE = 'https://player.vimeo.com' +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' + +const CDN_PRIORITY = ['akfire_interconnect_quic', 'fastly_skyfire'] +const REQUEST_TIMEOUT = 15000 +const MAX_REDIRECTS = 5 + +const SEGMENT_HIGH_WATER_MARK = 64 * 1024 +const PROGRESSIVE_HIGH_WATER_MARK = 16 * 1024 + +const HANDOFF_TTL = 15000 +const HANDOFF_MAX = 20 + +const HTTP_AGENT = new http.Agent({ + keepAlive: true, + maxSockets: 16, + maxFreeSockets: 4 +}) +const HTTPS_AGENT = new https.Agent({ + keepAlive: true, + maxSockets: 16, + maxFreeSockets: 4 +}) + +const _CONFIG_PATTERNS = [ + /window\.playerConfig\s*=\s*(\{[\s\S]*?\});\s*(?:window\.|<\/script>|if\s*\()/i, + /window\.playerConfig\s*=\s*(\{[\s\S]*?"video"[\s\S]*?\})\s*;/i, + /"config"\s*:\s*(\{[\s\S]*?"request"[\s\S]*?\})\s*[,}]/i +] + +const _functions = { + parseJson(data) { + try { + const str = Buffer.isBuffer(data) ? data.toString('utf8') : data + return JSON.parse(str) + } catch { + return null + } + }, + + unescapeString(text) { + if (!text) return '' + const s = String(text) + return s + .replaceAll('\\u002F', '/') + .replaceAll('\\/', '/') + .replaceAll('\\u0026', '&') + .replaceAll('\\u003C', '<') + .replaceAll('\\u003E', '>') + .replaceAll('\\"', '"') + .replaceAll('&', '&') + }, + + extractVideoId(url) { + if (!url) return null + for (const pattern of VIMEO_PATTERNS) { + const match = url.match(pattern) + if (match?.[1]) return match[1] + } + return null + }, + + extractHashParam(url) { + try { + return new URL(url).searchParams.get('h') + } catch { + return null + } + }, + + decompressBody(body, encoding) { + if (!encoding || !body) return body + const enc = Array.isArray(encoding) ? encoding[0] : encoding + try { + switch (enc) { + case 'gzip': + return zlib.gunzipSync(body) + case 'deflate': + return zlib.inflateSync(body) + case 'br': + return zlib.brotliDecompressSync(body) + default: + return body + } + } catch { + return body + } + }, + + sortTracksByQuality(tracks) { + return [...tracks].sort((a, b) => { + const aSampleRate = a.sample_rate || a.audio_sample_rate || 0 + const bSampleRate = b.sample_rate || b.audio_sample_rate || 0 + + const aIs48k = aSampleRate >= 44100 + const bIs48k = bSampleRate >= 44100 + if (aIs48k && !bIs48k) return -1 + if (bIs48k && !aIs48k) return 1 + + const aBitrate = a.avg_bitrate || a.bitrate || 0 + const bBitrate = b.avg_bitrate || b.bitrate || 0 + return bBitrate - aBitrate + }) + }, + + selectBestAudioTrack(tracks) { + if (!Array.isArray(tracks) || tracks.length === 0) return null + + const validTracks = tracks.filter((t) => t?.segments?.length > 0) + if (validTracks.length === 0) return null + + const mp42Aac = validTracks.filter((t) => { + const codecs = t.codecs || '' + const format = t.format || '' + return ( + codecs.includes('mp4a') && + (format === 'mp42' || format === 'iso5' || format === 'iso6') + ) + }) + + if (mp42Aac.length) return _functions.sortTracksByQuality(mp42Aac)[0] + + const aac = validTracks.filter((t) => (t.codecs || '').includes('mp4a')) + if (aac.length) return _functions.sortTracksByQuality(aac)[0] + + return validTracks.reduce((best, t) => { + const bw = t?.avg_bitrate || t?.bitrate || 0 + return bw > (best?.avg_bitrate || best?.bitrate || 0) ? t : best + }, validTracks[0]) + }, + + playlistDir(playlistUrl) { + const urlWithoutQuery = playlistUrl.split('?')[0] + return urlWithoutQuery.substring(0, urlWithoutQuery.lastIndexOf('/') + 1) + }, + + buildSegmentUrl(playlistDir, basePath, trackPath, segmentPath) { + try { + const relativePath = (basePath || '') + (trackPath || '') + segmentPath + return new URL(relativePath, playlistDir).href + } catch (err) { + logger( + 'error', + 'Sources', + `[vimeo] Failed to build segment URL: ${err.message}` + ) + return null + } + }, + + resolveRedirectUrl(currentUrl, location) { + if (!location) return null + if (location.startsWith('/')) { + const u = new URL(currentUrl) + return `${u.protocol}//${u.host}${location}` + } + return location + }, + + makeHeaders(extra) { + return { + 'User-Agent': USER_AGENT, + Accept: '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + Connection: 'keep-alive', + ...extra + } + }, + + async httpRequest(url, options = {}) { + let currentUrl = url + const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS + + for (let i = 0; i <= maxRedirects; i++) { + const urlObj = new URL(currentUrl) + const isHttps = urlObj.protocol === 'https:' + const httpLib = isHttps ? https : http + + const headers = _functions.makeHeaders({ + 'Accept-Encoding': 'gzip, deflate, br', + ...options.headers + }) + + const { timeout = REQUEST_TIMEOUT } = options + + const res = await new Promise((resolve, reject) => { + const req = httpLib.request( + { + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: options.method || 'GET', + headers, + timeout, + agent: isHttps ? HTTPS_AGENT : HTTP_AGENT + }, + resolve + ) + + req.once('error', reject) + req.once('timeout', () => req.destroy(new Error('Request timeout'))) + req.end() + }) + + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + res.resume() + const redirectUrl = _functions.resolveRedirectUrl( + currentUrl, + res.headers.location + ) + if (!redirectUrl) throw new Error('Redirect without location') + currentUrl = redirectUrl + continue + } + + const chunks = [] + let totalSize = 0 + const maxSize = options.maxSize || 10 * 1024 * 1024 + + const { statusCode, headers: resHeaders } = res + const body = await new Promise((resolve, reject) => { + res.on('data', (chunk) => { + totalSize += chunk.length + if (totalSize > maxSize) { + res.destroy(new Error('Response too large')) + return + } + chunks.push(chunk) + }) + + res.once('error', (err) => { + chunks.length = 0 + reject(err) + }) + + res.once('end', () => { + const raw = Buffer.concat(chunks, totalSize) + chunks.length = 0 + resolve( + _functions.decompressBody(raw, resHeaders['content-encoding']) + ) + }) + }) + + return { statusCode, headers: resHeaders, body } + } + + throw new Error('Too many redirects') + }, + + async pumpUrlToWritable(url, writable, options = {}) { + let currentUrl = url + const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS + const timeout = options.timeout ?? REQUEST_TIMEOUT + const maxSize = options.maxSize ?? 0 + + let req = null + let res = null + let done = false + + const cancel = (err) => { + if (done) return + done = true + if (res && !res.destroyed) res.destroy(err) + if (req && !req.destroyed) req.destroy(err) + } + + const onWritableClose = () => cancel(new Error('Destination closed')) + const onWritableError = (err) => cancel(err) + + writable.once('close', onWritableClose) + writable.once('error', onWritableError) + + try { + for (let i = 0; i <= maxRedirects; i++) { + if (writable.destroyed) throw new Error('Destination destroyed') + + const urlObj = new URL(currentUrl) + const isHttps = urlObj.protocol === 'https:' + const httpLib = isHttps ? https : http + + const headers = _functions.makeHeaders({ + 'Accept-Encoding': 'identity', + ...options.headers + }) + + res = await new Promise((resolve, reject) => { + req = httpLib.request( + { + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers, + timeout, + agent: isHttps ? HTTPS_AGENT : HTTP_AGENT + }, + resolve + ) + + req.once('error', reject) + req.once('timeout', () => req.destroy(new Error('Request timeout'))) + req.end() + }) + + const code = res.statusCode || 0 + + if (code >= 300 && code < 400 && res.headers.location) { + res.resume() + const redirectUrl = _functions.resolveRedirectUrl( + currentUrl, + res.headers.location + ) + if (!redirectUrl) throw new Error('Redirect without location') + currentUrl = redirectUrl + req = null + res = null + continue + } + + if (code >= 400) { + res.resume() + throw new Error(`HTTP ${code}`) + } + + const bytes = await new Promise((resolve, reject) => { + let total = 0 + let draining = false + + const cleanup = () => { + res.removeListener('data', onData) + res.removeListener('end', onEnd) + res.removeListener('error', onErr) + res.removeListener('aborted', onAborted) + res.removeListener('close', onClose) + writable.removeListener('drain', onDrain) + } + + const onDrain = () => { + draining = false + if (!res.destroyed && !writable.destroyed) res.resume() + } + + const onData = (chunk) => { + total += chunk.length + if (maxSize && total > maxSize) { + cleanup() + res.destroy(new Error('Response too large')) + return + } + + if (writable.destroyed) { + cleanup() + res.destroy(new Error('Destination destroyed')) + return + } + + if (!writable.write(chunk) && !draining) { + draining = true + res.pause() + writable.once('drain', onDrain) + } + } + + const onEnd = () => { + cleanup() + resolve(total) + } + + const onErr = (err) => { + cleanup() + reject(err) + } + + const onAborted = () => { + cleanup() + reject(new Error('Response aborted')) + } + + const onClose = () => { + if (done) return + cleanup() + reject(new Error('Response closed early')) + } + + res.on('data', onData) + res.once('end', onEnd) + res.once('error', onErr) + res.once('aborted', onAborted) + res.once('close', onClose) + }) + + return bytes + } + + throw new Error('Too many redirects') + } finally { + done = true + writable.removeListener('close', onWritableClose) + writable.removeListener('error', onWritableError) + req = null + res = null + } + } +} + +function curlRequest(url, options = {}) { + return new Promise((resolve, reject) => { + const args = [ + '-s', + '-L', + '-A', + USER_AGENT, + '-H', + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + '-H', + 'Accept-Language: en-US,en;q=0.9', + '-H', + 'Accept-Encoding: gzip, deflate, br', + '-H', + 'DNT: 1', + '-H', + 'Connection: keep-alive', + '-H', + 'Upgrade-Insecure-Requests: 1', + '-H', + 'Sec-Fetch-Dest: iframe', + '-H', + 'Sec-Fetch-Mode: navigate', + '-H', + 'Sec-Fetch-Site: cross-site', + '--compressed', + '-w', + '\n%{http_code}', + '-m', + String(Math.floor(REQUEST_TIMEOUT / 1000)) + ] + + if (options.referer) args.push('-H', `Referer: ${options.referer}`) + if (options.origin) args.push('-H', `Origin: ${options.origin}`) + args.push(url) + + const curlProcess = spawn('curl', args) + const outputChunks = [] + let completed = false + + const cleanup = () => { + outputChunks.length = 0 + curlProcess.stdout.removeAllListeners() + curlProcess.stderr.removeAllListeners() + curlProcess.removeAllListeners() + } + + const timeoutId = setTimeout(() => { + if (completed) return + completed = true + curlProcess.kill('SIGTERM') + cleanup() + reject(new Error('curl timeout')) + }, REQUEST_TIMEOUT) + timeoutId.unref?.() + + curlProcess.stdout.on('data', (chunk) => outputChunks.push(chunk)) + curlProcess.stderr.resume() + + curlProcess.once('error', (err) => { + clearTimeout(timeoutId) + if (completed) return + completed = true + cleanup() + reject(err) + }) + + curlProcess.once('close', (exitCode) => { + clearTimeout(timeoutId) + if (completed) return + completed = true + + if (exitCode !== 0) { + cleanup() + return reject(new Error(`curl exited with code ${exitCode}`)) + } + + const output = Buffer.concat(outputChunks).toString('utf8') + outputChunks.length = 0 + + const lastNewlineIndex = output.lastIndexOf('\n') + const statusCode = parseInt(output.slice(lastNewlineIndex + 1), 10) || 0 + const bodyText = output.slice(0, lastNewlineIndex) + + cleanup() + resolve({ + statusCode, + headers: {}, + body: Buffer.from(bodyText, 'utf8') + }) + }) + }) +} + +class SegmentStreamer { + constructor(playlistData, outputStream) { + this.playlistData = playlistData + this.outputStream = outputStream + this.aborted = false + this.segmentsFetched = 0 + this.bytesWritten = 0 + this._playlistDir = null + } + + abort() { + this.aborted = true + } + + async start() { + const { + playlistUrl, + basePath, + trackPath, + initSegment, + segments, + isDashFormat + } = this.playlistData || {} + + if (!playlistUrl || !Array.isArray(segments) || !this.outputStream) { + if (this.outputStream && !this.outputStream.destroyed) { + this.outputStream.destroy(new Error('Invalid Vimeo playlist data')) + } + return + } + + this._playlistDir = _functions.playlistDir(playlistUrl) + + const onClose = () => this.abort() + const onError = () => this.abort() + + this.outputStream.once('close', onClose) + this.outputStream.once('error', onError) + + try { + if (initSegment && !this.aborted) { + const initBuffer = Buffer.from(initSegment, 'base64') + logger( + 'debug', + 'Sources', + `[vimeo] Writing init segment: ${initBuffer.length} bytes (dash: ${isDashFormat})` + ) + + if (this.outputStream.destroyed || this.aborted) return + if (!this.outputStream.write(initBuffer)) { + await new Promise((resolve) => { + const onDrain = () => { + cleanup() + resolve() + } + const onClose2 = () => { + cleanup() + resolve() + } + const cleanup = () => { + this.outputStream.removeListener('drain', onDrain) + this.outputStream.removeListener('close', onClose2) + } + this.outputStream.once('drain', onDrain) + this.outputStream.once('close', onClose2) + }) + } + this.bytesWritten += initBuffer.length + } + + for (let i = 0; i < segments.length; i++) { + if (this.aborted || this.outputStream.destroyed) break + + const segmentPath = segments[i]?.url + if (!segmentPath) continue + + const segmentUrl = _functions.buildSegmentUrl( + this._playlistDir, + basePath, + trackPath, + segmentPath + ) + + if (!segmentUrl) { + logger( + 'warn', + 'Sources', + `[vimeo] Failed to build segment URL: ${segmentPath}` + ) + continue + } + + try { + const bytes = await _functions.pumpUrlToWritable( + segmentUrl, + this.outputStream, + { + headers: { + Accept: '*/*', + Origin: VIMEO_BASE, + Referer: `${VIMEO_BASE}/`, + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'cross-site' + }, + timeout: REQUEST_TIMEOUT, + maxSize: 5 * 1024 * 1024 + } + ) + + if (this.aborted || this.outputStream.destroyed) break + if (bytes > 0) { + this.segmentsFetched++ + this.bytesWritten += bytes + } else { + logger( + 'warn', + 'Sources', + `[vimeo] Empty segment ${i + 1}/${segments.length}` + ) + } + } catch (err) { + if (this.aborted || this.outputStream.destroyed) break + logger( + 'warn', + 'Sources', + `[vimeo] Segment fetch error (${i + 1}/${segments.length}): ${err.message}` + ) + } + } + + logger( + 'debug', + 'Sources', + `[vimeo] Streaming complete: ${this.segmentsFetched}/${segments.length} segments, ${this.bytesWritten} bytes` + ) + + if (!this.aborted && !this.outputStream.destroyed) { + this.outputStream.emit('finishBuffering') + this.outputStream.end() + } + } catch (error) { + logger( + 'error', + 'Sources', + `[vimeo] Segment streaming error: ${error.message}` + ) + if (this.outputStream && !this.outputStream.destroyed) { + this.outputStream.destroy(error) + } + } finally { + if (this.outputStream) { + this.outputStream.removeListener('close', onClose) + this.outputStream.removeListener('error', onError) + } + this.cleanup() + } + } + + cleanup() { + this.aborted = true + this.playlistData = null + this.outputStream = null + this._playlistDir = null + } +} + +export default class VimeoSource { + constructor(nodelink) { + this.nodelink = nodelink + this.config = nodelink.options + this.searchTerms = [] + this.patterns = VIMEO_PATTERNS + this.priority = 70 + + this._curlAvailable = null + this._activeStreams = new Set() + + this._handoff = new Map() + } + + _handoffGet(key) { + const entry = this._handoff.get(key) + if (!entry) return null + if (Date.now() >= entry.expiresAt) { + this._handoff.delete(key) + return null + } + return entry.value + } + + _handoffSet(key, value) { + const now = Date.now() + for (const [k, v] of this._handoff) { + if (now >= v.expiresAt) this._handoff.delete(k) + } + while (this._handoff.size >= HANDOFF_MAX) { + const firstKey = this._handoff.keys().next().value + this._handoff.delete(firstKey) + } + this._handoff.set(key, { value, expiresAt: now + HANDOFF_TTL }) + } + + _handoffTake(key) { + const value = this._handoffGet(key) + if (value) this._handoff.delete(key) + return value + } + + async _checkCurlAvailability() { + if (this._curlAvailable !== null) return this._curlAvailable + + return new Promise((resolve) => { + const curlProcess = spawn('curl', ['--version']) + + curlProcess.once('error', () => { + this._curlAvailable = false + resolve(false) + }) + + curlProcess.once('close', (code) => { + this._curlAvailable = code === 0 + resolve(this._curlAvailable) + }) + + curlProcess.stdout.resume() + curlProcess.stderr.resume() + }) + } + + async setup() { + await this._checkCurlAvailability() + return true + } + + match(url) { + return _functions.extractVideoId(url) !== null + } + + async search() { + return { loadType: 'empty', data: {} } + } + + async resolve(url) { + const videoId = _functions.extractVideoId(url) + const hashParam = _functions.extractHashParam(url) + if (!videoId) return { loadType: 'empty', data: {} } + + const metadata = await this._fetchVideoMetadata(videoId, hashParam) + if (!metadata?.title) return { loadType: 'empty', data: {} } + + const trackInfo = { + title: metadata.title, + author: metadata.author || 'Unknown', + length: metadata.durationMs || 0, + identifier: videoId, + isSeekable: true, + isStream: false, + uri: `https://vimeo.com/${videoId}${hashParam ? '?h=' + hashParam : ''}`, + artworkUrl: metadata.artworkUrl || null, + isrc: null, + sourceName: 'vimeo', + position: 0, + userData: hashParam ? { vimeo: { h: hashParam } } : undefined + } + + return { + loadType: 'track', + data: { encoded: encodeTrack(trackInfo), info: trackInfo, pluginInfo: {} } + } + } + + async getTrackUrl(decodedTrack) { + const videoId = decodedTrack?.identifier + const hashParam = decodedTrack?.userData?.vimeo?.h || null + + if (!videoId) { + return { + exception: { + message: 'Invalid Vimeo track identifier', + severity: 'fault' + } + } + } + + try { + const result = await this._extractFromEmbed(videoId, hashParam) + if (result?.playlistData) { + const key = `handoff:${videoId}:${hashParam || ''}` + this._handoffSet(key, result) + } + return result + } catch (err) { + logger( + 'warn', + 'Sources', + `[vimeo] Embed extraction failed for ${videoId}: ${err.message}` + ) + return { + exception: { + message: + 'Failed to extract Vimeo stream. Video may be private or require authentication.', + severity: 'fault', + cause: 'Upstream' + } + } + } + } + + async loadStream(decodedTrack, url, protocol) { + const isProgressive = protocol === 'https' || protocol === 'http' + const highWaterMark = isProgressive + ? PROGRESSIVE_HIGH_WATER_MARK + : SEGMENT_HIGH_WATER_MARK + + const stream = new PassThrough({ + highWaterMark, + emitClose: true, + autoDestroy: true + }) + + this._activeStreams.add(stream) + + const cleanup = () => { + this._activeStreams.delete(stream) + stream._segmentStreamer = null + } + + stream.once('close', cleanup) + stream.once('error', cleanup) + + if (isProgressive) { + setImmediate(() => { + _functions + .pumpUrlToWritable(url, stream, { timeout: REQUEST_TIMEOUT }) + .then(() => { + if (!stream.destroyed) { + stream.emit('finishBuffering') + stream.end() + } + }) + .catch((err) => { + if (!stream.destroyed) stream.destroy(err) + }) + }) + return { stream } + } + + if (protocol === 'segmented') { + const videoId = decodedTrack?.identifier + const hashParam = decodedTrack?.userData?.vimeo?.h || '' + const key = `handoff:${videoId}:${hashParam}` + + setImmediate(async () => { + try { + if (stream.destroyed) return + + let playlistResult = this._handoffTake(key) + if (!playlistResult?.playlistData) { + playlistResult = await this._fetchPlaylist( + url, + videoId || 'unknown' + ) + } + + if (!playlistResult?.playlistData) + throw new Error('Vimeo playlistData not found') + + const segmentStreamer = new SegmentStreamer( + playlistResult.playlistData, + stream + ) + stream._segmentStreamer = segmentStreamer + await segmentStreamer.start() + } catch (err) { + if (!stream.destroyed) stream.destroy(err) + } + }) + + return { stream } + } + + stream.destroy(new Error(`Unsupported protocol: ${protocol}`)) + return { stream } + } + + cleanupAllStreams() { + for (const stream of this._activeStreams) { + if (!stream.destroyed) stream.destroy() + } + this._activeStreams.clear() + this._handoff.clear() + } + + async _fetchVideoMetadata(videoId, hashParam) { + try { + const targetUrl = hashParam + ? `https://vimeo.com/${videoId}?h=${hashParam}` + : `https://vimeo.com/${videoId}` + + const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(targetUrl)}` + const response = await _functions.httpRequest(oembedUrl, { + headers: { Accept: 'application/json' }, + maxSize: 1024 * 1024 + }) + + if (response.statusCode >= 400) + return this._fetchMetadataFromApiV2(videoId) + + const data = _functions.parseJson(response.body) + if (!data?.title) return this._fetchMetadataFromApiV2(videoId) + + return { + title: data.title, + author: data.author_name || 'Unknown', + durationMs: (data.duration || 0) * 1000, + artworkUrl: data.thumbnail_url || null + } + } catch { + return this._fetchMetadataFromApiV2(videoId) + } + } + + async _fetchMetadataFromApiV2(videoId) { + try { + const response = await _functions.httpRequest( + `https://vimeo.com/api/v2/video/${videoId}.json`, + { + headers: { Accept: 'application/json' }, + maxSize: 1024 * 1024 + } + ) + + if (response.statusCode >= 400) return null + + const data = _functions.parseJson(response.body) + if (!Array.isArray(data) || !data[0]) return null + + const video = data[0] + return { + title: video.title || 'Unknown', + author: video.user_name || 'Unknown', + durationMs: (video.duration || 0) * 1000, + artworkUrl: video.thumbnail_large || video.thumbnail_medium || null + } + } catch { + return null + } + } + + async _extractFromEmbed(videoId, hashParam) { + const playerUrl = hashParam + ? `${VIMEO_PLAYER_BASE}/video/${videoId}?h=${hashParam}&app_id=122963` + : `${VIMEO_PLAYER_BASE}/video/${videoId}?app_id=122963` + + let response + + if (this._curlAvailable) { + try { + response = await curlRequest(playerUrl, { + referer: `${VIMEO_BASE}/${videoId}`, + origin: VIMEO_BASE + }) + } catch { + response = await _functions.httpRequest(playerUrl, { + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Sec-Fetch-Dest': 'iframe', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'cross-site', + Referer: `${VIMEO_BASE}/${videoId}`, + Origin: VIMEO_BASE + }, + maxSize: 5 * 1024 * 1024 + }) + } + } else { + response = await _functions.httpRequest(playerUrl, { + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Sec-Fetch-Dest': 'iframe', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'cross-site', + Referer: `${VIMEO_BASE}/${videoId}`, + Origin: VIMEO_BASE + }, + maxSize: 5 * 1024 * 1024 + }) + } + + if (response.statusCode >= 400) + throw new Error(`HTTP ${response.statusCode}`) + + const html = response.body.toString('utf8') + if (html.includes('Just a moment') || html.includes('challenge-platform')) { + throw new Error('Cloudflare challenge detected') + } + + return this._parsePageForConfig(html, playerUrl, videoId) + } + + async _parsePageForConfig(html, refererUrl, videoId) { + const cfgKeyIdx = html.indexOf('"config_url"') + if (cfgKeyIdx !== -1) { + const q1 = html.indexOf('"', html.indexOf(':', cfgKeyIdx) + 1) + const q2 = q1 !== -1 ? html.indexOf('"', q1 + 1) : -1 + if (q1 !== -1 && q2 !== -1) { + const raw = html.slice(q1 + 1, q2) + try { + const result = await this._fetchConfigFromUrl( + _functions.unescapeString(raw), + refererUrl, + videoId + ) + if (result) return result + } catch {} + } + } + + const cfgKeyIdx2 = html.indexOf('data-config-url="') + if (cfgKeyIdx2 !== -1) { + const start = cfgKeyIdx2 + 'data-config-url="'.length + const end = html.indexOf('"', start) + if (end !== -1) { + try { + const result = await this._fetchConfigFromUrl( + _functions.unescapeString(html.slice(start, end)), + refererUrl, + videoId + ) + if (result) return result + } catch {} + } + } + + for (const pattern of _CONFIG_PATTERNS) { + const match = html.match(pattern) + if (!match) continue + const config = this._parseJsonConfig(match[1]) + if (!config) continue + const result = await this._extractPlaylistFromConfig( + config, + refererUrl, + videoId + ) + if (result) return result + } + + const cdnMatch = html.match( + /(https?:\/\/[^"'\s\\]*vimeocdn\.com[^"'\s\\]*playlist\.json[^"'\s\\]*)/i + ) + if (cdnMatch?.[1]) { + try { + const result = await this._fetchPlaylist( + _functions.unescapeString(cdnMatch[1]), + videoId + ) + if (result) return result + } catch {} + } + + const progressiveMatch = html.match(/"progressive"\s*:\s*\[([\s\S]*?)\]/i) + if (progressiveMatch?.[1]) { + const result = this._handleProgressiveUrls(progressiveMatch[1], videoId) + if (result) return result + } + + throw new Error('No config found in embed page') + } + + _handleProgressiveUrls(progressiveJson, videoId) { + try { + const parsed = _functions.parseJson(`[${progressiveJson}]`) + if (Array.isArray(parsed) && parsed.length) { + const sorted = [...parsed].sort( + (a, b) => (a?.height || 0) - (b?.height || 0) + ) + const best = + sorted.find((p) => (p?.height || 0) >= 360) || + sorted[sorted.length - 1] + if (best?.url) { + logger( + 'warn', + 'Sources', + `[vimeo] Using progressive stream for ${videoId} (${best.height || 0}p)` + ) + return { + url: _functions.unescapeString(best.url), + protocol: 'https', + format: 'mp4', + additionalData: { + source: 'vimeo.progressive', + quality: best.quality, + height: best.height || 0 + } + } + } + } + } catch {} + + try { + const urls = [] + let pos = 0 + + while (true) { + const urlStart = progressiveJson.indexOf('"url"', pos) + if (urlStart === -1) break + + const valueStart = progressiveJson.indexOf('"', urlStart + 5) + if (valueStart === -1) break + + const valueEnd = progressiveJson.indexOf('"', valueStart + 1) + if (valueEnd === -1) break + + const url = _functions.unescapeString( + progressiveJson.substring(valueStart + 1, valueEnd) + ) + + let height = 0 + const before = progressiveJson.substring(pos, urlStart) + const heightKey = before.lastIndexOf('"height"') + if (heightKey !== -1) { + const colon = before.indexOf(':', heightKey) + if (colon !== -1) { + const num = before + .slice(colon + 1) + .trim() + .split(',')[0] + height = parseInt(num, 10) || 0 + } + } + + urls.push({ url, height }) + pos = valueEnd + 1 + } + + urls.sort((a, b) => a.height - b.height) + const best = urls.find((p) => p.height >= 360) || urls[urls.length - 1] + + if (best?.url) { + logger( + 'warn', + 'Sources', + `[vimeo] Using progressive stream for ${videoId} (${best.height}p)` + ) + return { + url: best.url, + protocol: 'https', + format: 'mp4', + additionalData: { source: 'vimeo.progressive', height: best.height } + } + } + } catch {} + + return null + } + + _parseJsonConfig(configString) { + try { + let braceDepth = 0 + let endIndex = 0 + + for (let i = 0; i < configString.length; i++) { + const c = configString[i] + if (c === '{') braceDepth++ + else if (c === '}') braceDepth-- + if (braceDepth === 0 && i > 0) { + endIndex = i + 1 + break + } + } + + return _functions.parseJson( + configString.substring(0, endIndex || configString.length) + ) + } catch { + return null + } + } + + async _fetchConfigFromUrl(configUrl, refererUrl, videoId) { + if (configUrl.startsWith('/')) { + const refUrl = new URL(refererUrl) + configUrl = `${refUrl.protocol}//${refUrl.host}${configUrl}` + } + + const response = await _functions.httpRequest(configUrl, { + headers: { + Accept: 'application/json', + Referer: refererUrl, + Origin: VIMEO_BASE + }, + maxSize: 2 * 1024 * 1024 + }) + + if (response.statusCode >= 400) + throw new Error(`HTTP ${response.statusCode}`) + + const config = _functions.parseJson(response.body) + if (!config) throw new Error('Invalid config JSON') + + return this._extractPlaylistFromConfig(config, configUrl, videoId) + } + + async _extractPlaylistFromConfig(config, refererUrl, videoId) { + let files = config?.request?.files + if (!files) + files = config?.video?.files || config?.files || config?.clip?.files + if (!files) { + const nested = + config?.config || config?.player?.config || config?.data?.config + if (nested) + return this._extractPlaylistFromConfig(nested, refererUrl, videoId) + } + if (!files) throw new Error('No files in config') + + const pickCdn = (cdns, def) => { + for (const name of CDN_PRIORITY) if (cdns?.[name]) return cdns[name] + return cdns?.[def] || (cdns ? Object.values(cdns)[0] : null) + } + + const dash = files.dash + if (dash?.cdns) { + const selected = pickCdn(dash.cdns, dash.default_cdn) + if (selected) { + let playlistUrl = _functions.unescapeString( + selected.avc_url || selected.url + ) + if (playlistUrl) { + if ( + !playlistUrl.includes('playlist.json') && + !playlistUrl.includes('master.json') + ) { + playlistUrl = playlistUrl.replace( + /\/[^/?]+(\?|$)/, + '/playlist.json$1' + ) + } + if (!playlistUrl.includes('omit=')) { + playlistUrl += + (playlistUrl.includes('?') ? '&' : '?') + 'omit=av1-hevc' + } + try { + return await this._fetchPlaylist(playlistUrl, videoId) + } catch {} + } + } + } + + const hls = files.hls + if (hls?.cdns) { + const selected = pickCdn(hls.cdns, hls.default_cdn) + if (selected?.url) { + return { + url: _functions.unescapeString(selected.url), + protocol: 'hls', + format: 'hls', + additionalData: { source: 'vimeo.hls' } + } + } + } + + const progressive = files.progressive + if (Array.isArray(progressive) && progressive.length) { + const sorted = [...progressive].sort( + (a, b) => (a.height || 0) - (b.height || 0) + ) + const best = + sorted.find((p) => (p.height || 0) >= 360) || sorted[sorted.length - 1] + + if (best?.url) { + logger( + 'warn', + 'Sources', + `[vimeo] Using progressive stream for ${videoId}` + ) + return { + url: best.url, + protocol: 'https', + format: 'mp4', + additionalData: { + source: 'vimeo.progressive', + quality: best.quality, + height: best.height + } + } + } + } + + throw new Error('No playable streams in config') + } + + async _fetchPlaylist(playlistUrl, videoId) { + const response = await _functions.httpRequest(playlistUrl, { + headers: { + Accept: '*/*', + Origin: VIMEO_BASE, + Referer: `${VIMEO_BASE}/`, + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'cross-site' + }, + maxSize: 2 * 1024 * 1024 + }) + + if (response.statusCode >= 400) + throw new Error(`HTTP ${response.statusCode}`) + + const playlist = _functions.parseJson(response.body) + if (!playlist) throw new Error('Invalid playlist JSON') + + if (playlist.audio?.length) { + const audioTrack = _functions.selectBestAudioTrack(playlist.audio) + if (audioTrack) { + const segments = audioTrack.segments.map((seg) => ({ + url: seg.url, + start: seg.start, + end: seg.end, + size: seg.size + })) + + const sampleRate = + audioTrack.sample_rate || audioTrack.audio_sample_rate || 48000 + const isDashFormat = audioTrack.format === 'dash' + + let basePath = playlist.base_url || '' + let trackPath = audioTrack.base_url || '' + + if (!basePath && !trackPath) basePath = '../../../../../' + else if (basePath && !basePath.endsWith('/')) basePath += '/' + + if (trackPath && !trackPath.endsWith('/')) trackPath += '/' + + const playlistData = { + playlistUrl, + basePath, + trackPath, + initSegment: audioTrack.init_segment || null, + segments, + duration: audioTrack.duration, + bitrate: audioTrack.avg_bitrate || audioTrack.bitrate, + codecs: audioTrack.codecs, + sampleRate, + clipId: playlist.clip_id, + isDashFormat + } + + logger( + 'debug', + 'Sources', + `[vimeo] Using audio: ${audioTrack.codecs} @ ${playlistData.bitrate}bps, ${sampleRate}Hz, ${segments.length} segments, format: ${audioTrack.format}` + ) + + return { + url: playlistUrl, + protocol: 'segmented', + format: 'mp4', + playlistData, + additionalData: { + source: 'vimeo.adaptive', + bitrate: playlistData.bitrate, + codecs: playlistData.codecs, + segments: segments.length, + sampleRate, + format: audioTrack.format + } + } + } + } + + if (playlist.video?.length) { + logger( + 'warn', + 'Sources', + `[vimeo] No compatible audio tracks, falling back to video track` + ) + + const video = playlist.video.reduce((best, v) => { + const bw = v.avg_bitrate || v.bitrate || 0 + return bw > (best?.avg_bitrate || best?.bitrate || 0) ? v : best + }, null) + + if (video?.segments?.length) { + const segments = video.segments.map((seg) => ({ + url: seg.url, + start: seg.start, + end: seg.end, + size: seg.size + })) + + const playlistData = { + playlistUrl, + basePath: playlist.base_url || '', + trackPath: video.base_url || '', + initSegment: video.init_segment || null, + segments, + duration: video.duration, + bitrate: video.avg_bitrate || video.bitrate, + codecs: video.codecs, + clipId: playlist.clip_id, + isDashFormat: video.format === 'dash' + } + + return { + url: playlistUrl, + protocol: 'segmented', + format: 'mp4', + playlistData, + additionalData: { + source: 'vimeo.video-only', + segments: segments.length + } + } + } + } + + throw new Error('No compatible audio tracks in playlist') + } +} diff --git a/src/sources/youtube/CipherManager.js b/src/sources/youtube/CipherManager.js index 3312aefb..f6017dc3 100644 --- a/src/sources/youtube/CipherManager.js +++ b/src/sources/youtube/CipherManager.js @@ -22,6 +22,12 @@ export default class CipherManager { this.cipherLoadLock = false this.explicitPlayerScriptUrl = null this.userAgent = `nodelink/${VERSION} (https://github.com/PerformanC/NodeLink)` + this.stsCache = new Map() + + setInterval(() => { + this.stsCache.clear() + logger('debug', 'YouTube-Cipher', 'Cleared STS cache (12h interval)') + }, 12 * 60 * 60 * 1000).unref() } setPlayerScriptUrl(url) { @@ -96,6 +102,10 @@ export default class CipherManager { } async getTimestamp(playerUrl) { + if (this.stsCache.has(playerUrl)) { + return this.stsCache.get(playerUrl) + } + if (!this.config.url) { const { body: scriptContent, @@ -134,6 +144,7 @@ export default class CipherManager { `Extracted timestamp from player script: ${sts}` ) + this.stsCache.set(playerUrl, sts) return sts } @@ -172,6 +183,8 @@ export default class CipherManager { } logger('debug', 'YouTube-Cipher', `Received STS: ${body.sts}`) + + this.stsCache.set(playerUrl, body.sts) return body.sts } diff --git a/src/sources/youtube/OAuth.js b/src/sources/youtube/OAuth.js index a24604d7..d542ca13 100644 --- a/src/sources/youtube/OAuth.js +++ b/src/sources/youtube/OAuth.js @@ -22,13 +22,14 @@ export default class OAuth { } } - this.refreshToken = foundToken + this.refreshToken = foundToken ? (Array.isArray(foundToken) ? foundToken : [foundToken]) : [] + this.currentTokenIndex = 0 this.accessToken = null this.tokenExpiry = 0 } async getAccessToken() { - if (!this.refreshToken) { + if (!this.refreshToken.length) { logger( 'debug', 'YouTube-OAuth', @@ -43,36 +44,53 @@ export default class OAuth { logger('info', 'YouTube-OAuth', 'Refreshing access token...') - const { body, error, statusCode } = await makeRequest( - 'https://www.youtube.com/o/oauth2/token', - { - method: 'POST', - body: { - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - refresh_token: this.refreshToken, - grant_type: 'refresh_token' + const maxTokenAttempts = this.refreshToken.length + let tokensTried = 0 + + while (tokensTried < maxTokenAttempts) { + const currentToken = this.refreshToken[this.currentTokenIndex] + let attempts = 0 + + while (attempts < 3) { + attempts++ + try { + const { body, error, statusCode } = await makeRequest( + 'https://www.youtube.com/o/oauth2/token', + { + method: 'POST', + body: { + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + refresh_token: currentToken, + grant_type: 'refresh_token' + } + } + ) + + if (!error && statusCode === 200 && body.access_token) { + this.accessToken = body.access_token + this.tokenExpiry = Date.now() + body.expires_in * 1000 - 30000 + logger('info', 'YouTube-OAuth', `Successfully refreshed access token using token index ${this.currentTokenIndex}.`) + return this.accessToken + } + + logger('warn', 'YouTube-OAuth', `Token refresh failed (Attempt ${attempts}/3, Token Index ${this.currentTokenIndex}): ${error?.message || body?.error_description || statusCode}`) + } catch (e) { + logger('warn', 'YouTube-OAuth', `Token refresh exception (Attempt ${attempts}/3, Token Index ${this.currentTokenIndex}): ${e.message}`) } + + await new Promise(r => setTimeout(r, 2000)) } - ) - if (error || statusCode !== 200 || !body.access_token) { - logger( - 'error', - 'YouTube-OAuth', - `Failed to refresh access token: ${error?.message || body.error_description || 'Invalid response'}` - ) - this.accessToken = null - this.tokenExpiry = 0 - return null + logger('warn', 'YouTube-OAuth', `Failed to refresh access token with token index ${this.currentTokenIndex}. Trying next token if available.`) + this.currentTokenIndex = (this.currentTokenIndex + 1) % this.refreshToken.length + tokensTried++ } - this.accessToken = body.access_token - this.tokenExpiry = Date.now() + body.expires_in * 1000 - 30000 - - logger('info', 'YouTube-OAuth', 'Successfully refreshed access token.') - - return this.accessToken + logger('error', 'YouTube-OAuth', 'All refresh tokens failed.') + this.accessToken = null + this.tokenExpiry = 0 + return null } async getAuthHeaders() { diff --git a/src/sources/youtube/YouTube.js b/src/sources/youtube/YouTube.js index 2c305018..ab4f69c9 100644 --- a/src/sources/youtube/YouTube.js +++ b/src/sources/youtube/YouTube.js @@ -11,7 +11,7 @@ import Web from './clients/Web.js' import { checkURLType, YOUTUBE_CONSTANTS } from './common.js' import OAuth from './OAuth.js' -const CHUNK_SIZE = 512 * 1024 +const CHUNK_SIZE = 64 * 1024 const MAX_RETRIES = 3 const MAX_URL_REFRESH = 10 const VISITOR_DATA_INTERVAL = 3600000 @@ -26,13 +26,39 @@ async function _manageYoutubeHlsStream( ) { const segmentQueue = [] const processedSegments = new Set() + const processedOrder = [] + let cleanedUp = false + let playlistEnded = false + const MAX_LIVE_QUEUE_SIZE = 15 + const MAX_PROCESSED_TRACK = 100 + + const rememberSegment = (url) => { + if (processedSegments.has(url)) return false + processedSegments.add(url) + processedOrder.push(url) + + if (processedOrder.length > MAX_PROCESSED_TRACK) { + const old = processedOrder.shift() + if (old) processedSegments.delete(old) + } + return true + } const cleanup = () => { + if (cleanedUp) return + cleanedUp = true cancelSignal.aborted = true outputStream.stopHls = null - if (streamKey && source) { + outputStream.removeListener('close', cleanup) + outputStream.removeListener('error', cleanup) + + if (source?.activeStreams && streamKey) { source.activeStreams.delete(streamKey) } + + segmentQueue.length = 0 + processedSegments.clear() + processedOrder.length = 0 } outputStream.once('close', cleanup) @@ -41,11 +67,12 @@ async function _manageYoutubeHlsStream( const fetchWithUserAgent = (url) => http1makeRequest(url, { method: 'GET' }) - const playlistFetcher = async (playlistUrl) => { + const playlistFetcher = async (playlistUrl, isLive = false) => { let isFirstFetch = true + let lastMediaSequence = -1 - while (!cancelSignal.aborted) { - try { + try { + while (!cancelSignal.aborted) { const { body: playlistContent, error, @@ -62,12 +89,31 @@ async function _manageYoutubeHlsStream( } const lines = playlistContent.split('\n').map((l) => l.trim()) + let targetDuration = 2 + let mediaSequence = 0 + const targetDurationLine = lines.find((l) => l.startsWith('#EXT-X-TARGETDURATION:') ) - if (targetDurationLine) - targetDuration = Number.parseInt(targetDurationLine.split(':')[1], 10) + if (targetDurationLine) { + const parts = targetDurationLine.split(':') + if (parts[1]) { + const parsed = Number.parseInt(parts[1], 10) + if (!Number.isNaN(parsed)) targetDuration = parsed + } + } + + const mediaSequenceLine = lines.find((l) => + l.startsWith('#EXT-X-MEDIA-SEQUENCE:') + ) + if (mediaSequenceLine) { + const parts = mediaSequenceLine.split(':') + if (parts[1]) { + const parsed = Number.parseInt(parts[1], 10) + if (!Number.isNaN(parsed)) mediaSequence = parsed + } + } const currentSegments = [] for (let i = 0; i < lines.length; i++) { @@ -79,46 +125,83 @@ async function _manageYoutubeHlsStream( } } - if (isFirstFetch) { - const startIdx = Math.max( - 0, - currentSegments.length - PLAYLIST_FALLBACK_SEGMENTS + if (!isFirstFetch && isLive && mediaSequence > lastMediaSequence + 30) { + logger( + 'warn', + 'YouTube-HLS-Fetcher', + `Fell behind live edge (gap: ${mediaSequence - lastMediaSequence}), resetting buffer` ) - for (let i = 0; i < currentSegments.length; i++) { + segmentQueue.length = 0 + processedSegments.clear() + processedOrder.length = 0 + isFirstFetch = true + } + + lastMediaSequence = mediaSequence + + if (isFirstFetch) { + const segmentsToTake = isLive ? 3 : PLAYLIST_FALLBACK_SEGMENTS + const startIdx = Math.max(0, currentSegments.length - segmentsToTake) + for (let i = startIdx; i < currentSegments.length; i++) { const url = currentSegments[i] - processedSegments.add(url) - if (i >= startIdx) segmentQueue.push(url) + if (rememberSegment(url)) { + segmentQueue.push(url) + } } isFirstFetch = false } else { for (const url of currentSegments) { if (!processedSegments.has(url)) { - processedSegments.add(url) - segmentQueue.push(url) + if (isLive && segmentQueue.length >= MAX_LIVE_QUEUE_SIZE) { + const oldUrl = segmentQueue.shift() + if (oldUrl) { + processedSegments.delete(oldUrl) + } + } + + if (rememberSegment(url)) { + segmentQueue.push(url) + } } } } - if (playlistContent.includes('#EXT-X-ENDLIST')) return + if (playlistContent.includes('#EXT-X-ENDLIST')) { + playlistEnded = true + return + } - await new Promise((resolve) => - setTimeout(resolve, Math.max(1, targetDuration) * 1000) - ) - } catch (e) { - logger('error', 'YouTube-HLS-Fetcher', `Error: ${e.message}`) - return + await new Promise((resolve) => { + const timeout = setTimeout( + resolve, + Math.max(1, targetDuration) * 1000 + ) + if (typeof timeout.unref === 'function') timeout.unref() + }) } + } finally { + playlistEnded = true } } const segmentDownloader = async () => { - while (!cancelSignal.aborted || segmentQueue.length > 0) { + while (true) { + if (cancelSignal.aborted || (playlistEnded && segmentQueue.length === 0)) + break + if (segmentQueue.length === 0) { - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 50) + if (typeof timeout.unref === 'function') timeout.unref() + }) continue } const segmentUrl = segmentQueue.shift() + if (processedSegments.has(segmentUrl)) { + processedSegments.delete(segmentUrl) + } + if (cancelSignal.aborted) break try { @@ -135,7 +218,7 @@ async function _manageYoutubeHlsStream( } if (outputStream.destroyed || cancelSignal.aborted) { - res.stream.destroy() + if (res.stream && !res.stream.destroyed) res.stream.destroy() break } @@ -143,9 +226,11 @@ async function _manageYoutubeHlsStream( res.stream.pipe(outputStream, { end: false }) res.stream.on('end', resolve) res.stream.on('error', (err) => { - if (err.message === 'aborted' || err.code === 'ECONNRESET') + if (err.message === 'aborted' || err.code === 'ECONNRESET') { resolve() - else reject(err) + } else { + reject(err) + } }) }) } catch (e) { @@ -159,7 +244,7 @@ async function _manageYoutubeHlsStream( } } - if (!outputStream.destroyed) { + if (!outputStream.destroyed && !outputStream.writableEnded) { outputStream.emit('finishBuffering') outputStream.end() } @@ -183,6 +268,17 @@ async function _manageYoutubeHlsStream( let bestAudioOnlyUrl = null let bestBandwidth = 0 let bestAudioOnlyBandwidth = 0 + let isLive = + masterPlaylistContent.includes('yt_live_broadcast') || + masterPlaylistContent.includes('live/1') + + if (isLive) { + logger( + 'debug', + 'YouTube-HLS', + 'Live stream detected, remember that this is still experimental (for performance reasons)' + ) + } for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF:')) { @@ -218,10 +314,15 @@ async function _manageYoutubeHlsStream( logger('debug', 'YouTube-HLS', `Selected stream: ${selectedPlaylistUrl}`) - Promise.all([playlistFetcher(selectedPlaylistUrl), segmentDownloader()]) + await Promise.all([ + playlistFetcher(selectedPlaylistUrl, isLive), + segmentDownloader() + ]) } catch (e) { logger('error', 'YouTube-HLS', `Error managing HLS stream: ${e.message}`) if (!outputStream.destroyed) outputStream.destroy(e) + } finally { + cleanup() } } @@ -229,9 +330,9 @@ export default class YouTubeSource { constructor(nodelink) { this.nodelink = nodelink this.config = nodelink.options.sources.youtube - this.searchTerms = ['youtube', 'ytsearch', 'ytmsearch'] + this.searchTerms = ['youtube', 'ytsearch', 'ytmsearch', 'ytmusic'] this.patterns = [ - /^https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+)|youtu\.be\/[\w-]+)/, + /^https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)/, /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[\w-]+/, /^https?:\/\/music\.youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+)/ ] @@ -292,6 +393,9 @@ export default class YouTubeSource { () => this._fetchVisitorData(), VISITOR_DATA_INTERVAL ) + if (typeof this.visitorDataInterval.unref === 'function') { + this.visitorDataInterval.unref() + } logger('info', 'YouTube', 'YouTube source setup complete.') return true @@ -300,7 +404,7 @@ export default class YouTubeSource { cleanup() { logger('info', 'YouTube', 'Cleaning up YouTube source...') - for (const [guildId, cancelSignal] of this.activeStreams.entries()) { + for (const [, cancelSignal] of this.activeStreams.entries()) { cancelSignal.aborted = true } this.activeStreams.clear() @@ -439,11 +543,19 @@ export default class YouTubeSource { } async resolve(url, type) { + const liveMatch = url.match( + /^https?:\/\/(?:www\.)?youtube\.com\/live\/([\w-]+)/ + ) + if (liveMatch) { + const videoId = liveMatch[1] + url = `https://www.youtube.com/watch?v=${videoId}` + logger('debug', 'YouTube', `Normalized live URL to: ${url}`) + } const isMusicUrl = url.includes('music.youtube.com') const sourceType = isMusicUrl ? 'ytmusic' : 'youtube' - let processUrl = url - + const processUrl = url + const clientList = this.config.clients.resolve || this.config.clients.playback logger( @@ -456,94 +568,130 @@ export default class YouTubeSource { const urlType = checkURLType(processUrl, sourceType) if (isMusicUrl) { - const musicClient = this.clients.Music; - if (musicClient) { - try { - logger('debug', 'YouTube', 'Attempting to resolve YouTube Music URL with Music client.'); - const result = await musicClient.resolve( - processUrl, - sourceType, - this.ytContext, - this.cipherManager - ); - if (result && (result.loadType === 'track' || result.loadType === 'playlist')) { - logger('debug', 'YouTube', 'Successfully resolved YouTube Music URL with Music client.'); - return result; - } + const musicClient = this.clients.Music + if (musicClient) { + try { + logger( + 'debug', + 'YouTube', + 'Attempting to resolve YouTube Music URL with Music client.' + ) + const result = await musicClient.resolve( + processUrl, + sourceType, + this.ytContext, + this.cipherManager + ) + if ( + result && + (result.loadType === 'track' || result.loadType === 'playlist') + ) { + logger( + 'debug', + 'YouTube', + 'Successfully resolved YouTube Music URL with Music client.' + ) + return result + } - const listIdMatch = url.match(/[?&]list=([\w-]+)/); - const videoIdMatch = url.match(/[?&]v=([\w-]+)/); - const listId = listIdMatch ? listIdMatch[1] : null; - const videoId = videoIdMatch ? videoIdMatch[1] : null; - const fallbackId = listId || videoId; - - if (fallbackId) { - logger('warn', 'YouTube', `Music client failed for ${fallbackId}. Attempting fallback to standard YouTube client.`); - let fallbackUrl; - if (listId) { - fallbackUrl = `https://www.youtube.com/playlist?list=${listId}`; - if (videoId) { - fallbackUrl += `&v=${videoId}`; - } - } else { - fallbackUrl = `https://www.youtube.com/watch?v=${videoId}`; - } - const fallbackResult = await this.resolve(fallbackUrl, 'youtube'); - - if (fallbackResult && (fallbackResult.loadType === 'track' || fallbackResult.loadType === 'playlist')) { - if (fallbackResult.loadType === 'track' && fallbackResult.data?.info) { - fallbackResult.data.info.sourceName = 'ytmusic'; - fallbackResult.data.info.uri = url; - } else if (fallbackResult.loadType === 'playlist' && fallbackResult.data?.tracks) { - for (const track of fallbackResult.data.tracks) { - if (track.info) { - track.info.sourceName = 'ytmusic'; - const trackVideoId = track.info.identifier; - track.info.uri = `https://music.youtube.com/watch?v=${trackVideoId}`; - } - } - } - return fallbackResult; - } - } - clientErrors.push({ client: 'Music', message: 'Music client failed or fallback unsuccessful for direct Music URL.' }); - logger('error', 'YouTube', 'Music client failed for direct Music URL and no fallback yielded a track.'); - return { - exception: { - message: 'Music client failed for direct Music URL and no fallback yielded a track.', - severity: 'fault', - cause: 'MusicClientFailure', - errors: clientErrors - } - }; - } catch (e) { - clientErrors.push({ client: 'Music', message: e.message }); - logger('warn', 'YouTube', `Music client threw an exception during direct Music URL resolve: ${e.message}`); - return { - exception: { - message: `Music client failed for direct Music URL: ${e.message}`, - severity: 'fault', - cause: 'MusicClientException', - errors: clientErrors - } - }; + const listIdMatch = url.match(/[?&]list=([\w-]+)/) + const videoIdMatch = url.match(/[?&]v=([\w-]+)/) + const listId = listIdMatch ? listIdMatch[1] : null + const videoId = videoIdMatch ? videoIdMatch[1] : null + const fallbackId = listId || videoId + + if (fallbackId) { + logger( + 'warn', + 'YouTube', + `Music client failed for ${fallbackId}. Attempting fallback to standard YouTube client.` + ) + let fallbackUrl + if (listId) { + fallbackUrl = `https://www.youtube.com/playlist?list=${listId}` + if (videoId) { + fallbackUrl += `&v=${videoId}` + } + } else { + fallbackUrl = `https://www.youtube.com/watch?v=${videoId}` } - } else { - const msg = 'Music client not available for direct Music URL.'; - clientErrors.push({ client: 'Music', message: msg }); - logger('error', 'YouTube', msg); - return { - exception: { - message: msg, - severity: 'fault', - cause: 'MusicClientNotAvailable', - errors: clientErrors + const fallbackResult = await this.resolve(fallbackUrl, 'youtube') + + if ( + fallbackResult && + (fallbackResult.loadType === 'track' || + fallbackResult.loadType === 'playlist') + ) { + if ( + fallbackResult.loadType === 'track' && + fallbackResult.data?.info + ) { + fallbackResult.data.info.sourceName = 'ytmusic' + fallbackResult.data.info.uri = url + } else if ( + fallbackResult.loadType === 'playlist' && + fallbackResult.data?.tracks + ) { + for (const track of fallbackResult.data.tracks) { + if (track.info) { + track.info.sourceName = 'ytmusic' + const trackVideoId = track.info.identifier + track.info.uri = `https://music.youtube.com/watch?v=${trackVideoId}` + } } - }; + } + return fallbackResult + } + } + clientErrors.push({ + client: 'Music', + message: + 'Music client failed or fallback unsuccessful for direct Music URL.' + }) + logger( + 'error', + 'YouTube', + 'Music client failed for direct Music URL and no fallback yielded a track.' + ) + return { + exception: { + message: + 'Music client failed for direct Music URL and no fallback yielded a track.', + severity: 'fault', + cause: 'MusicClientFailure', + errors: clientErrors + } + } + } catch (e) { + clientErrors.push({ client: 'Music', message: e.message }) + logger( + 'warn', + 'YouTube', + `Music client threw an exception during direct Music URL resolve: ${e.message}` + ) + return { + exception: { + message: `Music client failed for direct Music URL: ${e.message}`, + severity: 'fault', + cause: 'MusicClientException', + errors: clientErrors + } + } + } + } + const msg = 'Music client not available for direct Music URL.' + clientErrors.push({ client: 'Music', message: msg }) + logger('error', 'YouTube', msg) + return { + exception: { + message: msg, + severity: 'fault', + cause: 'MusicClientNotAvailable', + errors: clientErrors } + } } - if (urlType === YOUTUBE_CONSTANTS.PLAYLIST) { const androidClient = this.clients.Android if (androidClient) { @@ -562,7 +710,9 @@ export default class YouTubeSource { if ( result && - (result.loadType === 'track' || result.loadType === 'playlist' || result.loadType === 'empty') + (result.loadType === 'track' || + result.loadType === 'playlist' || + result.loadType === 'empty') ) { logger( 'debug', @@ -605,12 +755,15 @@ export default class YouTubeSource { const client = this.clients[clientName] if (!client) continue - if (!isMusicUrl && clientName === 'Music') continue + if (!isMusicUrl && clientName === 'Music') continue if (isMusicUrl && clientName !== 'Music' && type !== 'youtube-fallback') { - continue; + continue } - if (type === 'youtube-fallback' && !['Android', 'Web'].includes(clientName)) { - continue; + if ( + type === 'youtube-fallback' && + !['Android', 'Web'].includes(clientName) + ) { + continue } try { @@ -626,54 +779,76 @@ export default class YouTubeSource { this.cipherManager ) - if (isMusicUrl && clientName === 'Music' && result?.loadType === 'error' && result.data?.cause === 'UpstreamPlayability') { - const listIdMatch = url.match(/[?&]list=([\w-]+)/); - const videoIdMatch = url.match(/[?&]v=([\w-]+)/); - const listId = listIdMatch ? listIdMatch[1] : null; - const videoId = videoIdMatch ? videoIdMatch[1] : null; - const fallbackId = listId || videoId; - - if (fallbackId) { - logger('warn', 'YouTube', `Music client returned Playability Error for ${fallbackId}. Attempting fallback to standard YouTube client.`); - let fallbackUrl; - if (listId) { - fallbackUrl = `https://www.youtube.com/playlist?list=${listId}`; - if (videoId) { - fallbackUrl += `&v=${videoId}`; - } - } else { - fallbackUrl = `https://www.youtube.com/watch?v=${videoId}`; - } - const fallbackResult = await this.resolve(fallbackUrl, 'youtube'); - - if (fallbackResult && (fallbackResult.loadType === 'track' || fallbackResult.loadType === 'playlist' || fallbackResult.loadType === 'empty')) { - if (fallbackResult.loadType === 'track' && fallbackResult.data?.info) { - fallbackResult.data.info.sourceName = 'ytmusic'; - fallbackResult.data.info.uri = url; - } else if (fallbackResult.loadType === 'playlist' && fallbackResult.data?.tracks) { - for (const track of fallbackResult.data.tracks) { - if (track.info) { - track.info.sourceName = 'ytmusic'; - const trackVideoId = track.info.identifier; - track.info.uri = `https://music.youtube.com/watch?v=${trackVideoId}`; - } - } - } - return fallbackResult; + if ( + isMusicUrl && + clientName === 'Music' && + result?.loadType === 'error' && + result.data?.cause === 'UpstreamPlayability' + ) { + const listIdMatch = url.match(/[?&]list=([\w-]+)/) + const videoIdMatch = url.match(/[?&]v=([\w-]+)/) + const listId = listIdMatch ? listIdMatch[1] : null + const videoId = videoIdMatch ? videoIdMatch[1] : null + const fallbackId = listId || videoId + + if (fallbackId) { + logger( + 'warn', + 'YouTube', + `Music client returned Playability Error for ${fallbackId}. Attempting fallback to standard YouTube client.` + ) + let fallbackUrl + if (listId) { + fallbackUrl = `https://www.youtube.com/playlist?list=${listId}` + if (videoId) { + fallbackUrl += `&v=${videoId}` + } + } else { + fallbackUrl = `https://www.youtube.com/watch?v=${videoId}` + } + const fallbackResult = await this.resolve(fallbackUrl, 'youtube') + + if ( + fallbackResult && + (fallbackResult.loadType === 'track' || + fallbackResult.loadType === 'playlist' || + fallbackResult.loadType === 'empty') + ) { + if ( + fallbackResult.loadType === 'track' && + fallbackResult.data?.info + ) { + fallbackResult.data.info.sourceName = 'ytmusic' + fallbackResult.data.info.uri = url + } else if ( + fallbackResult.loadType === 'playlist' && + fallbackResult.data?.tracks + ) { + for (const track of fallbackResult.data.tracks) { + if (track.info) { + track.info.sourceName = 'ytmusic' + const trackVideoId = track.info.identifier + track.info.uri = `https://music.youtube.com/watch?v=${trackVideoId}` + } } + } + return fallbackResult } + } } - + if ( result && - (result.loadType === 'track' || result.loadType === 'playlist' || result.loadType === 'empty') + (result.loadType === 'track' || + result.loadType === 'playlist' || + result.loadType === 'empty') ) { logger( 'debug', 'YouTube', `Successfully resolved URL with client: ${clientName}` ) - return result; + return result } const errorMessage = @@ -787,7 +962,7 @@ export default class YouTubeSource { if (urlData.url) { const check = await http1makeRequest(urlData.url, { method: 'GET', - headers: { Range: 'bytes=0-' }, + headers: { Range: 'bytes=0-0' }, streamOnly: true }) @@ -798,14 +973,15 @@ export default class YouTubeSource { (check.statusCode === 200 || check.statusCode === 206) ) { let contentLength = null - if (check.headers?.['content-length']) { + if (check.headers?.['content-range']) { + const match = check.headers['content-range'].match(/\/(\d+)/) + if (match) contentLength = Number.parseInt(match[1], 10) + } + if (!contentLength && check.headers?.['content-length']) { contentLength = Number.parseInt( check.headers['content-length'], 10 ) - } else if (check.headers?.['content-range']) { - const match = check.headers['content-range'].match(/\/(\d+)/) - if (match) contentLength = Number.parseInt(match[1], 10) } logger( @@ -888,6 +1064,19 @@ export default class YouTubeSource { } } + if (decodedTrack.audioTrackId) { + logger( + 'warn', + 'YouTube', + `Requested audio track "${decodedTrack.audioTrackId}" not found on any client. Falling back to default audio.` + ) + + const fallbackTrack = { ...decodedTrack } + delete fallbackTrack.audioTrackId + + return this.getTrackUrl(fallbackTrack, itag) + } + logger( 'error', 'YouTube', @@ -911,7 +1100,7 @@ export default class YouTubeSource { ) const cancelSignal = { aborted: false } - const streamKey = additionalData || Symbol('streamKey') + const streamKey = additionalData?.streamKey || Symbol('streamKey') this.activeStreams.set(streamKey, cancelSignal) try { @@ -943,8 +1132,9 @@ export default class YouTubeSource { ) } - if (testResponse.statusCode === 403) + if (testResponse.statusCode === 403) { throw new Error('URL returned 403 Forbidden') + } if (!contentLength) { const rangeResponse = await http1makeRequest(url, { @@ -956,7 +1146,8 @@ export default class YouTubeSource { if (rangeResponse.stream) rangeResponse.stream.destroy() if (rangeResponse.headers?.['content-range']) { - const match = rangeResponse.headers['content-range'].match(/\/(\d+)/) + const match = + rangeResponse.headers['content-range'].match(/\/(\d+)/) if (match) contentLength = Number.parseInt(match[1], 10) } } @@ -968,14 +1159,13 @@ export default class YouTubeSource { 'YouTube', `Using range buffering for ${decodedTrack.title} (${Math.round(contentLength / 1024 / 1024)}MB)` ) - const result = this._streamWithRangeRequests( + return this._streamWithRangeRequests( url, contentLength, decodedTrack, cancelSignal, streamKey ) - return result } const response = await http1makeRequest(url, { @@ -983,25 +1173,42 @@ export default class YouTubeSource { streamOnly: true }) - if (response.statusCode !== 200 && response.statusCode !== 206) + if (response.statusCode !== 200 && response.statusCode !== 206) { throw new Error(`HTTP status ${response.statusCode}`) + } const stream = new PassThrough() stream.responseStream = response.stream + let cleanedUp = false const cleanup = () => { + if (cleanedUp) return + cleanedUp = true cancelSignal.aborted = true response.stream.removeAllListeners() - if (response.stream && !response.stream.destroyed) - response.stream.destroy() + if (!response.stream.destroyed) response.stream.destroy() this.activeStreams.delete(streamKey) + stream.removeListener('close', cleanup) } - response.stream.on('data', (chunk) => stream.write(chunk)) + response.stream.on('data', (chunk) => { + if (!stream.write(chunk)) { + response.stream.pause() + } + }) + + stream.on('drain', () => { + if (!response.stream.destroyed) response.stream.resume() + }) + response.stream.on('end', () => { cleanup() - stream.emit('finishBuffering') + if (!stream.writableEnded) { + stream.emit('finishBuffering') + stream.end() + } }) + response.stream.on('error', (error) => { cleanup() @@ -1024,8 +1231,6 @@ export default class YouTubeSource { originalDestroy(err) } - stream.once('end', cleanup) - stream.once('error', cleanup) stream.once('close', cleanup) return { stream } @@ -1058,21 +1263,20 @@ export default class YouTubeSource { let fetching = false let activeRequest = null let recoverTimeout = null - let drainListener = null const cleanup = () => { if (destroyed) return destroyed = true cancelSignal.aborted = true - if (drainListener) { - stream.removeListener('drain', drainListener) - drainListener = null - } + stream.removeListener('drain', onDrain) + stream.removeListener('close', cleanup) + stream.removeListener('end', cleanup) + stream.removeListener('error', cleanup) if (activeRequest) { activeRequest.removeAllListeners() - activeRequest.destroy() + if (!activeRequest.destroyed) activeRequest.destroy() activeRequest = null } @@ -1080,15 +1284,21 @@ export default class YouTubeSource { clearTimeout(recoverTimeout) recoverTimeout = null } + this.activeStreams.delete(streamKey) } - drainListener = () => { + const onDrain = () => { if (destroyed || cancelSignal.aborted) return - fetchNext() + if (activeRequest && !activeRequest.destroyed) { + activeRequest.resume() + } + if (!fetching && position < contentLength) { + fetchNext() + } } - stream.on('drain', drainListener) + stream.on('drain', onDrain) stream.once('close', cleanup) stream.once('end', cleanup) stream.once('error', cleanup) @@ -1126,7 +1336,10 @@ export default class YouTubeSource { const { error, statusCode } = result if (destroyed || cancelSignal.aborted) { - responseStream?.destroy() + if (responseStream && !responseStream.destroyed) { + responseStream.destroy() + } + fetching = false return } @@ -1151,17 +1364,20 @@ export default class YouTubeSource { responseStream.destroy() return } + if (refreshes > 0) refreshes = 0 position += chunk.length - const ok = stream.write(chunk) - if (!ok) responseStream.pause() + if (!stream.write(chunk)) { + responseStream.pause() + } } const onEnd = () => { cleanupRequestListeners() activeRequest = null fetching = false - if (!destroyed && position < contentLength) fetchNext() - else if (!stream.writableEnded && position >= contentLength) { + if (!destroyed && position < contentLength) { + setImmediate(fetchNext) + } else if (!stream.writableEnded && position >= contentLength) { stream.emit('finishBuffering') stream.end() cleanup() @@ -1173,12 +1389,16 @@ export default class YouTubeSource { activeRequest = null fetching = false if (!destroyed) { - if (++errors >= MAX_RETRIES) recover() - else - setTimeout( + logger('warn', 'YouTube', `Range request error at pos ${position}: ${err.message}`) + if (++errors >= MAX_RETRIES) { + recover(err) + } else { + const timeout = setTimeout( fetchNext, Math.min(1000 * Math.pow(2, errors - 1), 5000) ) + if (typeof timeout.unref === 'function') timeout.unref() + } } } @@ -1191,29 +1411,43 @@ export default class YouTubeSource { responseStream.on('data', onData) responseStream.on('end', onEnd) responseStream.on('error', onError) - - if (!stream.writableEnded) stream.resume?.() } catch (err) { activeRequest = null fetching = false if (!destroyed) { - if (++errors >= MAX_RETRIES) recover() - else - setTimeout( + logger('warn', 'YouTube', `Range request exception at pos ${position}: ${err.message}`) + if (++errors >= MAX_RETRIES) { + recover(err) + } else { + const timeout = setTimeout( fetchNext, Math.min(1000 * Math.pow(2, errors - 1), 5000) ) + if (typeof timeout.unref === 'function') timeout.unref() + } } } } - const recover = async () => { + const recover = async (causeError) => { if (destroyed || cancelSignal.aborted) return + const isForbidden = causeError?.message?.includes('403') || causeError?.statusCode === 403 + + if (!isForbidden && refreshes === 0) { + logger('debug', 'YouTube', `Retrying same URL for recovery first (cause: ${causeError?.message})...`) + errors = 0 + fetching = false + fetchNext() + refreshes++ + return + } + if (++refreshes > MAX_URL_REFRESH) { logger('error', 'YouTube', 'Max URL refresh attempts reached') - if (!stream.destroyed) + if (!stream.destroyed) { stream.destroy(new Error('Failed to recover stream')) + } return } @@ -1236,9 +1470,8 @@ export default class YouTubeSource { logger( 'debug', 'YouTube', - `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, resettings attempts for 0)` + `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, cause: ${causeError?.message})` ) - refreshes = 0 fetching = false fetchNext() } catch (error) { @@ -1248,7 +1481,10 @@ export default class YouTubeSource { `Recovery failed (attempt ${refreshes}): ${error.message}` ) if (!destroyed && !cancelSignal.aborted) { - recoverTimeout = setTimeout(recover, 4000 + refreshes * 1000) + recoverTimeout = setTimeout(() => recover(causeError), 4000 + refreshes * 1000) + if (typeof recoverTimeout.unref === 'function') { + recoverTimeout.unref() + } } } } @@ -1282,4 +1518,4 @@ export default class YouTubeSource { return [] } } -} +} \ No newline at end of file diff --git a/src/sources/youtube/common.js b/src/sources/youtube/common.js index 219bfe26..bda3ffce 100644 --- a/src/sources/youtube/common.js +++ b/src/sources/youtube/common.js @@ -1,9 +1,4 @@ -import { - encodeTrack, - generateRandomLetters, - logger, - makeRequest -} from '../../utils.js' +import { encodeTrack, logger, makeRequest } from '../../utils.js' export const YOUTUBE_CONSTANTS = { VIDEO: 0, @@ -12,6 +7,40 @@ export const YOUTUBE_CONSTANTS = { UNKNOWN: -1 } +const FALLBACK_TITLE = 'Unknown Title' +const FALLBACK_AUTHOR = 'Unknown Artist' +const FALLBACK_CHANNEL = 'Unknown Channel' + +const URL_PATTERNS = { + video: /^https?:\/\/(?:music\.)?(?:www\.)?youtube\.com\/watch\?v=[\w-]+/, + musicVideo: /^https?:\/\/music\.youtube\.com\/watch\?v=[\w-]+/, + playlist: + /^https?:\/\/(?:music\.)?(?:www\.)?youtube\.com\/playlist\?list=[\w-]+/, + shortUrl: /^https?:\/\/youtu\.be\/[\w-]+/, + shorts: /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[\w-]+/, + listParam: /[?&]list=/, + videoId: /(?:v=|shorts\/|youtu\.be\/)([^&?]+)/, + playlistId: /[?&]list=([\w-]+)/, + videoIdParam: /[?&]v=([\w-]+)/ +} + +const TIME_UNIT_MULTIPLIERS = { + year: 365.25 * 24 * 60 * 60 * 1000, + month: 30.44 * 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + hour: 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000 +} + +const TIME_UNIT_REGEX = /(\d+)\s*(year|month|week|day|hour|minute|second)/gi + +function safeString(value, fallback = '') { + if (value === null || value === undefined) return fallback + return String(value) +} + function formatDuration(ms) { if (!ms || ms === 0) return { ms: 0, formatted: '🔴 LIVE', hms: '🔴 LIVE' } const seconds = Math.floor(ms / 1000) @@ -28,6 +57,7 @@ function formatDuration(ms) { } function formatNumber(num) { + if (!num || isNaN(num)) return '0' if (num >= 1000000000) return `${(num / 1000000000).toFixed(1)}B` if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M` if (num >= 1000) return `${(num / 1000).toFixed(1)}K` @@ -38,118 +68,211 @@ function parsePublishedAt(publishedText) { if (!publishedText) return null const date = new Date(publishedText) + if (!isNaN(date.getTime())) { - const timestamp = date.getTime() - const now = Date.now() - const diffMs = now - timestamp + return _buildPublishedAtFromTimestamp(date.getTime(), publishedText) + } - const years = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000)) - const months = Math.floor( - (diffMs % (365.25 * 24 * 60 * 60 * 1000)) / (30.44 * 24 * 60 * 60 * 1000) - ) - const weeks = Math.floor( - (diffMs % (30.44 * 24 * 60 * 60 * 1000)) / (7 * 24 * 60 * 60 * 1000) - ) - const days = Math.floor( - (diffMs % (7 * 24 * 60 * 60 * 1000)) / (24 * 60 * 60 * 1000) - ) - const hours = Math.floor( - (diffMs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000) - ) - const minutes = Math.floor((diffMs % (60 * 60 * 1000)) / (60 * 1000)) - const seconds = Math.floor((diffMs % (60 * 1000)) / 1000) + const text = publishedText.toLowerCase() + const units = { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0 + } - const parts = [] - if (years > 0) parts.push(`${years} year${years > 1 ? 's' : ''}`) - if (months > 0) parts.push(`${months} month${months > 1 ? 's' : ''}`) - if (weeks > 0) parts.push(`${weeks} week${weeks > 1 ? 's' : ''}`) - if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`) - if (hours > 0) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`) - if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`) - if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`) + let match + const regex = new RegExp(TIME_UNIT_REGEX.source, TIME_UNIT_REGEX.flags) - const readable = parts.length > 0 ? parts.join(' ') + ' ago' : 'just now' - const compact = `${years}y ${months}mo ${weeks}w ${days}d ${hours}h ${minutes}m ${seconds}s` + while ((match = regex.exec(text)) !== null) { + const value = parseInt(match[1], 10) + const unit = match[2].toLowerCase() - return { - original: publishedText, - timestamp: Math.floor(timestamp), - date: date.toISOString(), - readable, - compact - } + if (unit.startsWith('year')) units.years = value + else if (unit.startsWith('month')) units.months = value + else if (unit.startsWith('week')) units.weeks = value + else if (unit.startsWith('day')) units.days = value + else if (unit.startsWith('hour')) units.hours = value + else if (unit.startsWith('minute')) units.minutes = value + else if (unit.startsWith('second')) units.seconds = value } - const text = publishedText.toLowerCase() - - const yearMatch = text.match(/(\d+)\s*year/) - const monthMatch = text.match(/(\d+)\s*month/) - const weekMatch = text.match(/(\d+)\s*week/) - const dayMatch = text.match(/(\d+)\s*day/) - const hourMatch = text.match(/(\d+)\s*hour/) - const minuteMatch = text.match(/(\d+)\s*minute/) - const secondMatch = text.match(/(\d+)\s*second/) - - const years = yearMatch ? Number.parseInt(yearMatch[1], 10) : 0 - const months = monthMatch ? Number.parseInt(monthMatch[1], 10) : 0 - const weeks = weekMatch ? Number.parseInt(weekMatch[1], 10) : 0 - const days = dayMatch ? Number.parseInt(dayMatch[1], 10) : 0 - const hours = hourMatch ? Number.parseInt(hourMatch[1], 10) : 0 - const minutes = minuteMatch ? Number.parseInt(minuteMatch[1], 10) : 0 - const seconds = secondMatch ? Number.parseInt(secondMatch[1], 10) : 0 - - const now = Date.now() const msAgo = - years * 365.25 * 24 * 60 * 60 * 1000 + - months * 30.44 * 24 * 60 * 60 * 1000 + - weeks * 7 * 24 * 60 * 60 * 1000 + - days * 24 * 60 * 60 * 1000 + - hours * 60 * 60 * 1000 + - minutes * 60 * 1000 + - seconds * 1000 - const timestamp = now - msAgo - - const parts = [] - if (years > 0) parts.push(`${years} year${years > 1 ? 's' : ''}`) - if (months > 0) parts.push(`${months} month${months > 1 ? 's' : ''}`) - if (weeks > 0) parts.push(`${weeks} week${weeks > 1 ? 's' : ''}`) - if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`) - if (hours > 0) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`) - if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`) - if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`) - - const readable = parts.length > 0 ? parts.join(' ') + ' ago' : 'just now' - - const compact = `${years}y ${months}mo ${weeks}w ${days}d ${hours}h ${minutes}m ${seconds}s` + units.years * TIME_UNIT_MULTIPLIERS.year + + units.months * TIME_UNIT_MULTIPLIERS.month + + units.weeks * TIME_UNIT_MULTIPLIERS.week + + units.days * TIME_UNIT_MULTIPLIERS.day + + units.hours * TIME_UNIT_MULTIPLIERS.hour + + units.minutes * TIME_UNIT_MULTIPLIERS.minute + + units.seconds * TIME_UNIT_MULTIPLIERS.second + + const timestamp = Date.now() - msAgo return { original: publishedText, timestamp: Math.floor(timestamp), date: new Date(timestamp).toISOString(), - readable, - compact, - ago: { - years, - months, - weeks, - days, - hours, - minutes, - seconds - } + readable: _buildReadableTime(units), + compact: `${units.years}y ${units.months}mo ${units.weeks}w ${units.days}d ${units.hours}h ${units.minutes}m ${units.seconds}s`, + ago: units } } -async function fetchChannelInfo(channelId, makeRequest, context) { - if (!channelId) return null +function getItemValue(obj, paths, defaultValue = null) { + if (!obj) return defaultValue + for (const path of paths) { + const value = path.split('.').reduce((o, k) => o?.[k], obj) + if (value !== undefined && value !== null) return value + } + return defaultValue +} + +function getRunsText(runsArray) { + if (Array.isArray(runsArray) && runsArray.length > 0) { + return runsArray.map((run) => run.text || '').join('') + } + return null +} +function extractMetadataFromResponse(fullApiResponse) { + const metadata = { title: null, author: null } + + const vd = fullApiResponse?.videoDetails + if (vd) { + if (vd.title && vd.title !== 'undefined') metadata.title = vd.title + if (vd.author && vd.author !== 'undefined') metadata.author = vd.author + } + if (metadata.title && metadata.author) return metadata + + const mf = fullApiResponse?.microformat?.playerMicroformatRenderer + if (mf) { + if (mf.title && !metadata.title) metadata.title = mf.title + if (mf.ownerChannelName && !metadata.author) + metadata.author = mf.ownerChannelName + } + + return metadata +} + +async function fetchOEmbedMetadata(videoId, makeRequest) { try { + const { body, statusCode } = await makeRequest( + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`, + { + method: 'GET', + timeout: 5000 + } + ) + + if (statusCode === 200 && body) { + return { + title: body.title || null, + author: body.author_name || null, + thumbnail_url: body.thumbnail_url || null + } + } + } catch (e) { logger( 'debug', - 'fetchChannelInfo', - `Fetching info for channel: ${channelId}` + 'fetchOEmbedMetadata', + `Failed to fetch oEmbed data: ${e.message}` ) + } + return null +} + +function extractTitle( + renderer, + fullApiResponse, + videoId, + makeRequestFn = null +) { + const metadata = extractMetadataFromResponse(fullApiResponse) + if (metadata.title) { + return metadata.title + } + + if (typeof renderer?.title === 'string' && renderer.title !== 'undefined') { + return renderer.title + } + + const title = + getRunsText(renderer?.title?.runs) || + getItemValue(fullApiResponse, [ + 'videoDetails.endscreen.endscreenRenderer.elements.1.endscreenElementRenderer.title.simpleText' + ]) || + getItemValue(renderer, ['title.simpleText']) + + if (title && title !== 'undefined') { + return title + } + + if (videoId && makeRequestFn) { + return null + } + + return FALLBACK_TITLE +} + +function extractAuthor( + renderer, + fullApiResponse, + videoId, + makeRequestFn = null +) { + const metadata = extractMetadataFromResponse(fullApiResponse) + if (metadata.author) { + return metadata.author + } + + if (renderer?.author && renderer.author !== 'undefined') { + return renderer.author + } + + const author = + getRunsText( + getItemValue(renderer, [ + 'longBylineText.runs', + 'shortBylineText.runs', + 'ownerText.runs' + ]) + ) || getItemValue(fullApiResponse, ['videoDetails.author']) + + if (author && author !== 'undefined') { + return author + } + + if (videoId && makeRequestFn) { + return null + } + return FALLBACK_CHANNEL +} + +function extractThumbnail(renderer, videoId) { + const thumbnails = + renderer?.thumbnail?.thumbnails || + renderer?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails + + if (Array.isArray(thumbnails) && thumbnails.length > 0) { + const url = thumbnails[thumbnails.length - 1]?.url + return url?.split('?')[0] || null + } + + if (videoId) { + return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` + } + + return null +} + +async function fetchChannelInfo(channelId, makeRequest, context) { + if (!channelId) return null + + try { const { body: channelResponse, statusCode } = await makeRequest( 'https://www.youtube.com/youtubei/v1/browse', { @@ -161,7 +284,10 @@ async function fetchChannelInfo(channelId, makeRequest, context) { context: { client: { clientName: 'WEB', - clientVersion: '2.20241106.01.00', + clientVersion: '2.20251030.01.00', + platform: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36,gzip(gfe)', hl: context?.client?.hl || 'en', gl: context?.client?.gl || 'US' } @@ -340,11 +466,6 @@ async function fetchChannelInfo(channelId, makeRequest, context) { } } - logger( - 'debug', - 'fetchChannelInfo', - `Channel info: icon=${channelInfo.icon ? 'yes' : 'no'}, subscribers=${channelInfo.subscribers}, verified=${channelInfo.verified}` - ) return channelInfo } catch (e) { logger( @@ -386,7 +507,7 @@ async function resolveExternalLinks(externalLinks, makeRequest) { } } } - } catch (e) {} + } catch (e) { } } if ( @@ -403,7 +524,7 @@ async function resolveExternalLinks(externalLinks, makeRequest) { if (response.finalUrl && response.finalUrl.includes('music.apple.com')) { resolved.appleMusic = response.finalUrl } - } catch (e) {} + } catch (e) { } } return resolved @@ -571,6 +692,278 @@ function extractAudioFormats(streamingData) { return Array.from(qualityMap.values()).sort((a, b) => b.bitrate - a.bitrate) } +function extractAudioTracks(streamingData) { + if (!streamingData) return [] + + const allFormats = [ + ...(streamingData.formats || []), + ...(streamingData.adaptiveFormats || []) + ] + + const tracksMap = new Map() + + for (const format of allFormats) { + if (format.audioTrack) { + const id = format.audioTrack.id + if (!tracksMap.has(id)) { + tracksMap.set(id, { + id: format.audioTrack.id, + name: format.audioTrack.displayName, + isDefault: !!format.audioTrack.audioIsDefault, + isAutoDubbed: !!format.audioTrack.isAutoDubbed + }) + } + } + } + + return Array.from(tracksMap.values()) +} + +function extractCaptions(captionsData) { + if (!captionsData?.playerCaptionsTracklistRenderer?.captionTracks) return [] + + return captionsData.playerCaptionsTracklistRenderer.captionTracks.map((c) => ({ + languageCode: c.languageCode, + name: c.name?.simpleText, + isTranslatable: c.isTranslatable, + baseUrl: c.baseUrl, + kind: c.kind + })) +} + +function parseLengthAndStream(lengthText, lengthSeconds, isLive) { + if (isLive) { + return { lengthMs: -1, isStream: true } + } + + let lengthMs = 0 + let isStream = true + + if (lengthText && /[:\d]+/.test(lengthText)) { + const parts = lengthText.split(':').map(Number) + lengthMs = parts.reduce((acc, val) => acc * 60 + val, 0) * 1000 + isStream = !Number.isFinite(lengthMs) || lengthMs <= 0 + } else if (lengthSeconds) { + lengthMs = Number.parseInt(lengthSeconds, 10) * 1000 + isStream = false + } + return { lengthMs, isStream } +} + +function getRendererFromItemData(itemData, itemType) { + if (!itemData) return null + + if (itemType === 'ytmusic') { + return getItemValue(itemData, [ + 'musicResponsiveListItemRenderer', + 'playlistPanelVideoRenderer', + 'musicTwoColumnItemRenderer' + ]) + } + + return ( + getItemValue(itemData, [ + 'videoRenderer', + 'compactVideoRenderer', + 'playlistPanelVideoRenderer', + 'gridVideoRenderer' + ]) || (itemData.videoId ? itemData : null) + ) +} + +export async function buildTrack( + itemData, + itemType, + sourceNameOverride = null, + fullApiResponse = null, + enableHolo = false, + config = {} +) { + if (!itemData) { + logger('warn', 'buildTrack', 'itemData is null or undefined') + return null + } + + const renderer = getRendererFromItemData(itemData, itemType) + + const videoId = + getItemValue(renderer, [ + 'playlistItemData.videoId', + 'navigationEndpoint.watchEndpoint.videoId', + 'videoId' + ]) || + itemData.videoId || + renderer?.videoId + + if (!videoId) { + logger('warn', 'buildTrack', 'Could not extract videoId from item data') + return null + } + + let title = FALLBACK_TITLE + let author = FALLBACK_AUTHOR + let lengthMs = 0 + let isStream = true + let artworkUrl = null + let uri = '' + + if (itemType === 'ytmusic') { + title = safeString( + getRunsText(getItemValue(renderer, ['title.runs'])), + FALLBACK_TITLE + ) + + const subtitleRuns = getItemValue(renderer, ['subtitle.runs']) + if (Array.isArray(subtitleRuns) && subtitleRuns.length > 0) { + author = safeString(subtitleRuns[0]?.text, FALLBACK_AUTHOR) + } + + let lengthText = null + if (Array.isArray(subtitleRuns)) { + const lengthRun = subtitleRuns.find( + (run) => run.text && /^\d{1,2}:\d{2}(:\d{2})?$/.test(run.text) + ) + lengthText = lengthRun?.text + } + + const parsed = parseLengthAndStream( + lengthText, + itemData.lengthSeconds, + itemData.isLive + ) + lengthMs = parsed.lengthMs + isStream = parsed.isStream + + artworkUrl = extractThumbnail(renderer, videoId) + uri = `https://music.youtube.com/watch?v=${videoId}` + } else { + const extractedTitle = extractTitle( + renderer, + fullApiResponse, + videoId, + makeRequest + ) + const extractedAuthor = extractAuthor( + renderer, + fullApiResponse, + videoId, + makeRequest + ) + + if (extractedTitle === null || extractedAuthor === null) { + try { + const oEmbedData = await fetchOEmbedMetadata(videoId, makeRequest) + if (oEmbedData) { + title = safeString(oEmbedData.title, FALLBACK_TITLE) + author = safeString(oEmbedData.author, FALLBACK_AUTHOR) + logger( + 'debug', + 'buildTrack', + `Got metadata from oEmbed: title="${title}", author="${author}"` + ) + + if (oEmbedData.thumbnail_url && !artworkUrl) { + artworkUrl = oEmbedData.thumbnail_url + } + } else { + title = FALLBACK_TITLE + author = FALLBACK_AUTHOR + } + } catch (e) { + logger( + 'warn', + 'buildTrack', + `Failed to fetch oEmbed metadata: ${e.message}` + ) + title = FALLBACK_TITLE + author = FALLBACK_AUTHOR + } + } else { + title = safeString(extractedTitle, FALLBACK_TITLE) + author = safeString(extractedAuthor, FALLBACK_AUTHOR) + } + + const lengthText = + getItemValue(renderer, ['lengthText.simpleText']) || + getRunsText(renderer?.lengthText?.runs) + + const parsed = parseLengthAndStream( + lengthText, + renderer?.lengthSeconds, + renderer?.isLive + ) + lengthMs = parsed.lengthMs + isStream = parsed.isStream + + artworkUrl = artworkUrl || extractThumbnail(renderer, videoId) + uri = `https://www.youtube.com/watch?v=${videoId}` + } + + let sourceName = sourceNameOverride + if (!sourceName) { + if (uri.includes('music.youtube.com')) { + sourceName = 'ytmusic' + } else { + sourceName = 'youtube' + } + } + + const trackInfo = { + identifier: videoId, + isSeekable: !isStream, + author, + length: lengthMs, + isStream, + position: 0, + title, + uri, + artworkUrl, + isrc: null, + sourceName + } + + trackInfo.title = safeString(trackInfo.title, FALLBACK_TITLE) + trackInfo.author = safeString(trackInfo.author, FALLBACK_AUTHOR) + trackInfo.identifier = safeString(trackInfo.identifier, '') + trackInfo.uri = safeString(trackInfo.uri, '') + trackInfo.sourceName = safeString(trackInfo.sourceName, 'youtube') + + if (!trackInfo.identifier) { + logger('warn', 'buildTrack', 'Track identifier is empty after processing') + return null + } + + const audioFormats = fullApiResponse?.streamingData + ? extractAudioFormats(fullApiResponse.streamingData) + : [] + + const audioTracks = fullApiResponse?.streamingData + ? extractAudioTracks(fullApiResponse.streamingData) + : [] + + const basicTrack = { + encoded: encodeTrack(trackInfo), + info: trackInfo, + pluginInfo: { + captions: extractCaptions(fullApiResponse?.captions), + audioFormats, + audioTracks + } + } + + if (enableHolo) { + return await buildHoloTrack( + trackInfo, + itemData, + itemType, + fullApiResponse, + config + ) + } + + return basicTrack +} + export async function buildHoloTrack( trackInfo, itemData, @@ -585,43 +978,14 @@ export async function buildHoloTrack( ? 'https://music.youtube.com' : 'https://www.youtube.com' - const getItemValue = (obj, paths, defaultValue = null) => { - for (const path of paths) { - const value = path.split('.').reduce((o, k) => o?.[k], obj) - if (value !== undefined && value !== null) return value - } - return defaultValue - } - - const getRunsText = (runsArray, defaultValue = null) => { - if (Array.isArray(runsArray) && runsArray.length > 0) { - return runsArray.map((run) => run.text).join('') - } - return defaultValue - } - - let renderer = null - if (itemType === 'ytmusic') { - renderer = getItemValue(itemData, [ - 'musicResponsiveListItemRenderer', - 'playlistPanelVideoRenderer', - 'musicTwoColumnItemRenderer' - ]) - } else { - renderer = - getItemValue(itemData, [ - 'videoRenderer', - 'compactVideoRenderer', - 'playlistPanelVideoRenderer', - 'gridVideoRenderer' - ]) || (itemData.videoId ? itemData : null) - } + const renderer = getRendererFromItemData(itemData, itemType) const channelData = { name: trackInfo.author, id: null, url: null, icon: null, + banner: null, subscribers: null, verified: false, description: null, @@ -629,6 +993,7 @@ export async function buildHoloTrack( featuredVideo: null, links: [] } + let thumbnails = {} let viewCount = null let badges = [] @@ -827,6 +1192,10 @@ export async function buildHoloTrack( ? extractAudioFormats(fullApiResponse.streamingData) : [] + const audioTracks = fullApiResponse?.streamingData + ? extractAudioTracks(fullApiResponse.streamingData) + : [] + const pluginInfo = { type: 'holo', accessibility: accessibilityLabel, @@ -866,251 +1235,77 @@ export async function buildHoloTrack( category: category }, videoQualities, - audioFormats, - captions: fullApiResponse?.captions - } - - return { - encoded: encodeTrack(trackInfo), - info: trackInfo, - pluginInfo - } -} - -export function checkURLType(url, type) { - const source = type === 'ytmusic' ? 'music' : 'www' - const videoRegex = new RegExp( - `^https?://${source === 'music' ? 'music\\.' : '(?:www\\.)?'}youtube.com/watch\\?v=[\\w-]+` - ) - const playlistRegex = new RegExp( - `^https?://${source === 'music' ? 'music\\.' : '(?:www\\.)?'}youtube.com/playlist\\?list=[\\w-]+` - ) - const shortUrlRegex = /^https?:\/\/youtu\.be\/[\w-]+/ - const shortsRegex = /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[\w-]+/ - - if ( - playlistRegex.test(url) || - (videoRegex.test(url) && url.includes('&list=')) - ) { - return YOUTUBE_CONSTANTS.PLAYLIST - } - if (videoRegex.test(url)) { - return YOUTUBE_CONSTANTS.VIDEO - } - if (type !== 'ytmusic') { - if (shortsRegex.test(url)) { - return YOUTUBE_CONSTANTS.SHORTS; - } - if (shortUrlRegex.test(url)) { - return YOUTUBE_CONSTANTS.VIDEO; - } - } - return YOUTUBE_CONSTANTS.UNKNOWN -} - -function parseLengthAndStream(lengthText, lengthSeconds, isLive) { - if (isLive) { - return { lengthMs: -1, isStream: true } + audioFormats, + audioTracks, + captions: extractCaptions(fullApiResponse?.captions) } - let lengthMs = 0 - let isStream = true - - if (lengthText && /[:\d]+/.test(lengthText)) { - const parts = lengthText.split(':').map(Number) - lengthMs = parts.reduce((acc, val) => acc * 60 + val, 0) * 1000 - isStream = !Number.isFinite(lengthMs) - } else if (lengthSeconds) { - lengthMs = Number.parseInt(lengthSeconds, 10) * 1000 - isStream = !!isLive + return { + encoded: encodeTrack(trackInfo), + info: trackInfo, + pluginInfo } - return { lengthMs, isStream } } -export async function buildTrack( - itemData, - itemType, - sourceNameOverride = null, - fullApiResponse = null, - enableHolo = false, - config = {} -) { - let videoId - let title - let author - let lengthMs = 0 - let isStream = true - let artworkUrl - let uri +export function checkURLType(url, type) { + const isMusicSource = type === 'ytmusic' - const getItemValue = (obj, paths, defaultValue = null) => { - for (const path of paths) { - const value = path.split('.').reduce((o, k) => o?.[k], obj) - if (value !== undefined && value !== null) return value - } - return defaultValue + if (URL_PATTERNS.listParam.test(url)) { + return YOUTUBE_CONSTANTS.PLAYLIST } - const getRunsText = (runsArray, defaultValue = 'Unknown') => { - if (Array.isArray(runsArray) && runsArray.length > 0) { - return runsArray.map((run) => run.text).join('') + if (isMusicSource) { + if (URL_PATTERNS.musicVideo.test(url)) { + return YOUTUBE_CONSTANTS.VIDEO } - return defaultValue - } - - let renderer = null - if (itemType === 'ytmusic') { - renderer = getItemValue(itemData, [ - 'musicResponsiveListItemRenderer', - 'playlistPanelVideoRenderer', - 'musicTwoColumnItemRenderer' - ]) } else { - renderer = - getItemValue(itemData, [ - 'videoRenderer', - 'compactVideoRenderer', - 'playlistPanelVideoRenderer', - 'gridVideoRenderer' - ]) || (itemData.videoId ? itemData : null) - } - - if (!renderer && !itemData.videoId) return null - - videoId = getItemValue( - renderer, - [ - 'playlistItemData.videoId', - 'navigationEndpoint.watchEndpoint.videoId', - 'videoId' - ], - itemData.videoId || renderer?.videoId - ) - - if (!videoId) return null; - - if (itemType === 'ytmusic') { - title = getRunsText(getItemValue(renderer, ['title.runs']), 'Unknown Title') - - let authorText = 'Unknown Artist'; - const subtitleRuns = getItemValue(renderer, ['subtitle.runs']); - if (Array.isArray(subtitleRuns) && subtitleRuns.length > 0) { - authorText = subtitleRuns[0]?.text || authorText; + if (URL_PATTERNS.video.test(url)) { + return YOUTUBE_CONSTANTS.VIDEO } - author = authorText; - - let lengthText = null; - if (Array.isArray(subtitleRuns)) { - lengthText = subtitleRuns.find(run => run.text && /^\d{1,2}:\d{2}$/.test(run.text || ''))?.text; + if (URL_PATTERNS.shorts.test(url)) { + return YOUTUBE_CONSTANTS.SHORTS } - const { lengthMs: parsedLengthMs, isStream: parsedIsStream } = - parseLengthAndStream( - lengthText, - itemData.lengthSeconds, - itemData.isLive - ) - lengthMs = parsedLengthMs - isStream = parsedIsStream - - const thumbnails = getItemValue(renderer, ['thumbnail.musicThumbnailRenderer.thumbnail.thumbnails']); - if (Array.isArray(thumbnails) && thumbnails.length > 0) { - artworkUrl = thumbnails[thumbnails.length - 1]?.url; - } else { - artworkUrl = itemData.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.pop()?.url || itemData.thumbnail?.thumbnails?.pop()?.url; + if (URL_PATTERNS.shortUrl.test(url)) { + return YOUTUBE_CONSTANTS.VIDEO } - - uri = `https://music.youtube.com/watch?v=${videoId}` - } else { - title = - typeof renderer.title === 'string' - ? renderer.title - : getRunsText( - renderer.title?.runs, - getItemValue(fullApiResponse, [ - 'videoDetails.endscreen.endscreenRenderer.elements.1.endscreenElementRenderer.title.simpleText' - ]), - getItemValue(renderer, ['title.simpleText'], 'Unknown Title') - ) - author = - renderer.author || - getRunsText( - getItemValue(renderer, [ - 'longBylineText.runs', - 'shortBylineText.runs', - 'ownerText.runs' - ]), - getItemValue(fullApiResponse, [ - 'videoDetails.endscreen.endscreenRenderer.elements.0.endscreenElementRenderer.title.simpleText' - ]), - 'Unknown Channel' - ) - const { lengthMs: parsedLengthMs, isStream: parsedIsStream } = - parseLengthAndStream( - getItemValue( - renderer, - ['lengthText.simpleText'], - getRunsText(renderer.lengthText?.runs) - ), - renderer.lengthSeconds, - renderer.isLive - ) - lengthMs = parsedLengthMs - isStream = parsedIsStream - artworkUrl = renderer.thumbnail?.thumbnails?.pop()?.url - uri = `https://www.youtube.com/watch?v=${videoId}` } - const trackInfo = { - identifier: videoId, - isSeekable: !isStream, - author, - length: lengthMs, - isStream, - position: 0, - title, - uri, - artworkUrl: artworkUrl || null, - isrc: null, - sourceName: - sourceNameOverride || (itemType === 'ytmusic' ? 'ytmusic' : 'youtube') - } + return YOUTUBE_CONSTANTS.UNKNOWN +} - if (trackInfo.uri?.includes('music.youtube.com')) { - trackInfo.sourceName = 'ytmusic' - } else if (trackInfo.uri?.includes('youtube.com') || trackInfo.uri?.includes('youtu.be')) { - trackInfo.sourceName = 'youtube' - } +export async function fetchEncryptedHostFlags(videoId) { + try { + const embedUrl = `https://www.youtube.com/embed/${videoId}` + + const { body, statusCode, error } = await makeRequest(embedUrl, { + method: 'GET', + headers: { + 'Referer': 'https://www.google.com', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }) - if (trackInfo.sourceName === 'ytmusic' && renderer) { - const musicThumbnails = getItemValue(renderer, ['thumbnail.musicThumbnailRenderer.thumbnail.thumbnails']); - if (Array.isArray(musicThumbnails) && musicThumbnails.length > 0) { - trackInfo.artworkUrl = musicThumbnails[musicThumbnails.length - 1]?.url; + if (error || statusCode !== 200 || !body) { + logger('warn', 'fetchEncryptedHostFlags', + `Failed to fetch embed page: ${statusCode} - ${error?.message}`) + return null } - } - if (!trackInfo.artworkUrl) { - trackInfo.artworkUrl = artworkUrl; - } - trackInfo.artworkUrl = trackInfo.artworkUrl?.split('?')[0] || trackInfo.artworkUrl; + const match = body.match(/"encryptedHostFlags":"([^"]+)"/) - const basicTrack = { - encoded: encodeTrack(trackInfo), - info: trackInfo, - pluginInfo: { - captions: fullApiResponse?.captions + if (match && match[1]) { + logger('debug', 'fetchEncryptedHostFlags', + `Successfully extracted encryptedHostFlags for ${videoId}`) + return match[1] } - } - if (enableHolo) { - return await buildHoloTrack( - trackInfo, - itemData, - itemType, - fullApiResponse, - config - ) + logger('debug', 'fetchEncryptedHostFlags', + 'encryptedHostFlags not found in embed page') + return null + } catch (e) { + logger('error', 'fetchEncryptedHostFlags', + `Error fetching encryptedHostFlags: ${e.message}`) + return null } - - return basicTrack } export class BaseClient { @@ -1163,6 +1358,17 @@ export class BaseClient { requestBody.params = playerParams } + if (this.isEmbedded()) { + const encryptedHostFlags = await fetchEncryptedHostFlags(videoId) + if (encryptedHostFlags) { + requestBody.playbackContext = requestBody.playbackContext || {} + requestBody.playbackContext.contentPlaybackContext = + requestBody.playbackContext.contentPlaybackContext || {} + requestBody.playbackContext.contentPlaybackContext.encryptedHostFlags = + encryptedHostFlags + } + } + if (this.requirePlayerScript() && cipherManager) { try { const playerScript = await cipherManager.getCachedPlayerScript() @@ -1170,20 +1376,18 @@ export class BaseClient { const signatureTimestamp = await cipherManager.getTimestamp( playerScript.url ) - requestBody.playbackContext = { - contentPlaybackContext: { - signatureTimestamp: signatureTimestamp - } - } + requestBody.playbackContext = requestBody.playbackContext || {} + requestBody.playbackContext.contentPlaybackContext = + requestBody.playbackContext.contentPlaybackContext || {} + requestBody.playbackContext.contentPlaybackContext.signatureTimestamp = + signatureTimestamp } } catch (e) { - logger( - 'warn', - `youtube-${this.name}`, - `Failed to get signature timestamp for player request: ${e.message}` - ) + logger('warn', `youtube-${this.name}`, + `Failed to get signature timestamp: ${e.message}`) } } + const response = await makeRequest( `${apiEndpoint}/youtubei/v1/player?prettyPrint=false`, { @@ -1191,9 +1395,7 @@ export class BaseClient { headers: { 'User-Agent': this.getClient(context).client.userAgent, ...(this.getClient(context).client.visitorData - ? { - 'X-Goog-Visitor-Id': this.getClient(context).client.visitorData - } + ? { 'X-Goog-Visitor-Id': this.getClient(context).client.visitorData } : {}), ...(this.isEmbedded() ? { Referer: 'https://www.youtube.com' } : {}), ...headers @@ -1202,15 +1404,26 @@ export class BaseClient { disableBodyCompression: true } ) + if (response.statusCode !== 200) { - const message = `Failed to get player data for stream. Status: ${response.statusCode}` + const message = `Failed to get player data. Status: ${response.statusCode}` logger('error', `youtube-${this.name}`, message) return { exception: { message, severity: 'common', cause: 'Upstream' } } } + return response } async _handlePlayerResponse(playerResponse, sourceName, videoId, context) { + if (!playerResponse || typeof playerResponse !== 'object') { + logger( + 'error', + `youtube-${this.name}`, + `Null or invalid player response for ${videoId}` + ) + return { loadType: 'empty', data: {} } + } + if (playerResponse.error) { logger( 'error', @@ -1241,8 +1454,25 @@ export class BaseClient { } } + const videoDetails = playerResponse.videoDetails + if (!videoDetails || !videoDetails.videoId) { + logger( + 'error', + `youtube-${this.name}`, + `Missing videoDetails for ${videoId}` + ) + return { + loadType: 'error', + data: { + message: 'No video details in response.', + severity: 'fault', + cause: 'NoVideoDetails' + } + } + } + const track = await buildTrack( - playerResponse.videoDetails, + videoDetails, sourceName, null, playerResponse, @@ -1252,16 +1482,23 @@ export class BaseClient { fetchChannelInfo: this.config.fetchChannelInfo } ) + if (!track) { + logger( + 'error', + `youtube-${this.name}`, + `Failed to build track for ${videoId}` + ) return { loadType: 'error', data: { message: 'Failed to process video data.', severity: 'fault', - cause: 'Internal' + cause: 'TrackBuildFailed' } } } + return { loadType: 'track', data: track } } @@ -1286,32 +1523,27 @@ export class BaseClient { } } - const contentsRoot = playlistResponse.contents.singleColumnWatchNextResults || - playlistResponse.contents.singleColumnMusicWatchNextResultsRenderer - + const contentsRoot = + playlistResponse.contents.singleColumnWatchNextResults || + playlistResponse.contents.singleColumnMusicWatchNextResultsRenderer + let playlistContent = null - + if (contentsRoot?.playlist?.playlist?.contents) { playlistContent = contentsRoot.playlist.playlist.contents - } else if (contentsRoot?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.musicQueueRenderer) { - const musicQueue = contentsRoot.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content.musicQueueRenderer - playlistContent = musicQueue.content?.playlistPanelRenderer?.contents || musicQueue.contents + } else if ( + contentsRoot?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.[0] + ?.tabRenderer?.content?.musicQueueRenderer + ) { + const musicQueue = + contentsRoot.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0] + .tabRenderer.content.musicQueueRenderer + playlistContent = + musicQueue.content?.playlistPanelRenderer?.contents || + musicQueue.contents } if (!playlistContent || playlistContent.length === 0) { - logger( - 'debug', - `youtube-${this.name}`, - `Playlist structure keys: ${Object.keys(playlistResponse.contents || {}).join(', ')}` - ) - if (contentsRoot?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.musicQueueRenderer) { - const musicQueue = contentsRoot.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content.musicQueueRenderer - logger( - 'debug', - `youtube-${this.name}`, - `musicQueueRenderer keys: ${Object.keys(musicQueue).join(', ')}` - ) - } logger( 'info', `youtube-${this.name}`, @@ -1382,18 +1614,30 @@ export class BaseClient { context ) { if (browseResponse?.error) { - const errMsg = browseResponse?.error?.message || 'Failed to browse playlist.' - logger('error', `youtube-${this.name}`, `Error browsing playlist ${playlistId}: ${errMsg}`) + const errMsg = + browseResponse?.error?.message || 'Failed to browse playlist.' + logger( + 'error', + `youtube-${this.name}`, + `Error browsing playlist ${playlistId}: ${errMsg}` + ) return { loadType: 'error', data: { message: errMsg, severity: 'common', cause: 'Upstream' } } } - const shelf = browseResponse.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.musicPlaylistShelfRenderer - + const shelf = + browseResponse.contents?.singleColumnBrowseResultsRenderer?.tabs?.[0] + ?.tabRenderer?.content?.sectionListRenderer?.contents?.[0] + ?.musicPlaylistShelfRenderer + if (!shelf || !shelf.contents || shelf.contents.length === 0) { - logger('info', `youtube-${this.name}`, `Browse playlist ${playlistId} is empty or inaccessible.`) + logger( + 'info', + `youtube-${this.name}`, + `Browse playlist ${playlistId} is empty or inaccessible.` + ) return { loadType: 'empty', data: {} } } @@ -1418,18 +1662,29 @@ export class BaseClient { tracks.push(track) } } catch (err) { - logger('warn', `youtube-${this.name}`, `Failed to build track: ${err.message}`) + logger( + 'warn', + `youtube-${this.name}`, + `Failed to build track: ${err.message}` + ) } } if (tracks.length === 0) { - logger('info', `youtube-${this.name}`, `No valid tracks parsed from browse playlist ${playlistId}.`) + logger( + 'info', + `youtube-${this.name}`, + `No valid tracks parsed from browse playlist ${playlistId}.` + ) return { loadType: 'empty', data: {} } } - const playlistTitle = browseResponse.header?.musicDetailHeaderRenderer?.title?.runs?.[0]?.text || - browseResponse.header?.musicEditablePlaylistDetailHeaderRenderer?.header?.musicDetailHeaderRenderer?.title?.runs?.[0]?.text || - 'Unknown Playlist' + const playlistTitle = + browseResponse.header?.musicDetailHeaderRenderer?.title?.runs?.[0] + ?.text || + browseResponse.header?.musicEditablePlaylistDetailHeaderRenderer?.header + ?.musicDetailHeaderRenderer?.title?.runs?.[0]?.text || + 'Unknown Playlist' return { loadType: 'playlist', @@ -1469,16 +1724,8 @@ export class BaseClient { let targetItags = [] if (itag) { - logger('debug', `youtube-${this.name}`, `Using requested itag: ${itag}`) - targetItags = [Number(itag)] } else if (targetItag) { - logger( - 'debug', - `youtube-${this.name}`, - `Using target itag: ${targetItag}` - ) - targetItags = [Number(targetItag)] } else { const qualityPriority = this._getQualityPriority() @@ -1495,126 +1742,175 @@ export class BaseClient { ...(streamingData.formats || []) ] - const formats = allFormats.map((f) => ({ + let formats = allFormats.map((f) => ({ itag: f.itag, mimeType: f.mimeType, qualityLabel: f.qualityLabel, bitrate: f.bitrate, - audioQuality: f.audioQuality + audioQuality: f.audioQuality, + url: f.url, + signatureCipher: f.signatureCipher, + audioTrack: f.audioTrack })) - const filteredFormats = allFormats - .filter((format) => targetItags.includes(format.itag)) - .sort((a, b) => targetItags.indexOf(a.itag) - targetItags.indexOf(b.itag)) + if (decodedTrack.audioTrackId) { + const requestedFormats = formats.filter( + (f) => f.audioTrack && f.audioTrack.id === decodedTrack.audioTrackId + ) - if (filteredFormats.length === 0) { - if (streamingData.hlsManifestUrl) { + if (requestedFormats.length > 0) { logger( 'debug', `youtube-${this.name}`, - `No suitable audio stream found for the configured quality. Falling back to HLS.` + `Found requested audio track: ${decodedTrack.audioTrackId}` ) + formats = requestedFormats } else { + const hasAudioTracks = formats.some((f) => f.audioTrack) + if (hasAudioTracks) { + logger( + 'warn', + `youtube-${this.name}`, + `Requested audio track ${decodedTrack.audioTrackId} not found in client ${this.name}.` + ) + return { + exception: { + message: 'Requested audio track not available in this client.', + severity: 'common', + cause: 'AudioTrackNotFound' + } + } + } + } + } else { + const defaultFormats = formats.filter( + (f) => f.audioTrack && f.audioTrack.audioIsDefault + ) + + if (defaultFormats.length > 0) { logger( 'debug', `youtube-${this.name}`, - `No suitable audio stream found for the configured quality. Available itags: ${allFormats.map((f) => f.itag).join(', ')}` + `Using default audio track.` ) - return { - exception: { - message: - 'No suitable audio stream found for the configured quality.', - severity: 'common', - cause: 'Upstream' - } - } + formats = defaultFormats } } - let resolvedFormat = null + const _attemptCipherResolution = async (formatToResolve, playerScript, context) => { + let currentStreamUrl = formatToResolve.url + let currentEncryptedSignature + let currentNParam + let currentSignatureKey + + if (formatToResolve.signatureCipher) { + const cipher = new URLSearchParams(formatToResolve.signatureCipher) + currentStreamUrl = cipher.get('url') + currentEncryptedSignature = cipher.get('s') + currentSignatureKey = cipher.get('sp') || 'sig' + currentNParam = cipher.get('n') + } - if (this.requirePlayerScript()) { - const playerScript = await cipherManager.getCachedPlayerScript() if (!playerScript) { - logger( - 'error', - `youtube-${this.name}`, - 'Failed to obtain player script for deciphering. Cannot extract stream data.' - ) - return { - exception: { - message: 'Failed to obtain player script for deciphering.', - severity: 'fault', - cause: 'Internal' - } + if (currentEncryptedSignature) { + return null } - } - for (const format of filteredFormats) { - let currentStreamUrl = format.url - let currentEncryptedSignature - let currentNParam - let currentSignatureKey - - if (format.signatureCipher) { - const cipher = new URLSearchParams(format.signatureCipher) - currentStreamUrl = cipher.get('url') - currentEncryptedSignature = cipher.get('s') - currentSignatureKey = cipher.get('sp') || 'sig' - currentNParam = cipher.get('n') + if (currentStreamUrl) { + formatToResolve.url = currentStreamUrl + return formatToResolve } + return null + } - if (currentStreamUrl) { - try { - const decipheredUrl = await cipherManager.resolveUrl( - currentStreamUrl, - currentEncryptedSignature, - currentNParam, - currentSignatureKey, - playerScript, - context - ) - format.url = decipheredUrl - resolvedFormat = format - logger( - 'debug', - `youtube-${this.name}`, - `Successfully resolved URL for itag ${format.itag}.` - ) - break - } catch (e) { - logger( - 'warn', - `youtube-${this.name}`, - `Failed to resolve format URL for itag ${format.itag}: ${e.message}` - ) - } + if (currentStreamUrl) { + try { + const decipheredUrl = await cipherManager.resolveUrl( + currentStreamUrl, + currentEncryptedSignature, + currentNParam, + currentSignatureKey, + playerScript, + context + ) + formatToResolve.url = decipheredUrl + return formatToResolve + } catch (e) { + logger( + 'warn', + `youtube-${this.name}`, + `Failed to resolve format URL for itag ${formatToResolve.itag}: ${e.message}` + ) } } - } else { - resolvedFormat = filteredFormats[0] + return null + } + + let resolvedFormat = null + const playerScript = this.requirePlayerScript() + ? await cipherManager.getCachedPlayerScript() + : null + + if (this.requirePlayerScript() && !playerScript) { + logger( + 'error', + `youtube-${this.name}`, + 'Failed to obtain player script for deciphering. Cannot extract stream data.' + ) + return { + exception: { + message: 'Failed to obtain player script for deciphering.', + severity: 'fault', + cause: 'Internal', + }, + } + } + + logger('debug', `youtube-${this.name}`, `Initial target itags (from config/quality priority): ${targetItags.join(', ')}`) + + const opusAudioCandidates = formats + .filter((format) => targetItags.includes(format.itag) && format.mimeType?.startsWith('audio/')) + .sort((a, b) => targetItags.indexOf(a.itag) - targetItags.indexOf(b.itag)) + + logger('debug', `youtube-${this.name}`, `Opus audio-only candidates: ${opusAudioCandidates.map(f => f.itag).join(', ')}`) + + for (const format of opusAudioCandidates) { + resolvedFormat = await _attemptCipherResolution(format, playerScript, context) + if (resolvedFormat) { + logger('debug', `youtube-${this.name}`, `Resolved format: itag ${resolvedFormat.itag}, mimeType ${resolvedFormat.mimeType}`) + break + } } if (!resolvedFormat) { - if (streamingData.hlsManifestUrl) { - logger( - 'debug', - `youtube-${this.name}`, - 'Could not resolve a working URL from the filtered formats. Falling back to HLS.' - ) - } else { - logger( - 'debug', - `youtube-${this.name}`, - 'Could not resolve a working URL from the filtered formats.' - ) - return { - exception: { - message: 'Could not resolve a working URL.', - severity: 'fault', - cause: 'Cipher' - } + logger('debug', `youtube-${this.name}`, `Opus audio-only failed. Attempting fallback to itag 18.`) + const itag18Format = formats.find(format => format.itag === 18) + + if (itag18Format) { + resolvedFormat = await _attemptCipherResolution(itag18Format, playerScript, context) + if (resolvedFormat) { + logger('debug', `youtube-${this.name}`, `Resolved format from itag 18 fallback: itag ${resolvedFormat.itag}, mimeType ${resolvedFormat.mimeType}`) + } else { + logger('debug', `youtube-${this.name}`, `Itag 18 found but could not be resolved.`) } + } else { + logger('debug', `youtube-${this.name}`, `Itag 18 not found in available formats.`) + } + } + + if (!resolvedFormat && !streamingData.hlsManifestUrl) { + logger('debug', `youtube-${this.name}`, 'No suitable stream found after all fallbacks, and no HLS manifest URL.') + return { + exception: { + message: 'No suitable audio stream found after all fallbacks.', + severity: 'common', + cause: 'Upstream', + }, + formats, } + } else if (!resolvedFormat && streamingData.hlsManifestUrl) { + logger('debug', `youtube-${this.name}`, 'No suitable stream found after all fallbacks, but HLS manifest URL is available. Proceeding with HLS.') + } else { + logger('debug', `youtube-${this.name}`, `Final resolved format: itag ${resolvedFormat?.itag}, mimeType ${resolvedFormat?.mimeType}`) } const directUrl = @@ -1623,24 +1919,17 @@ export class BaseClient { : undefined if (!directUrl && !streamingData.hlsManifestUrl) { - logger( - 'debug', - - `youtube-${this.name}`, - - `No suitable audio stream found. Available streamingData: ${JSON.stringify(streamingData)}` - ) - + logger('debug', `youtube-${this.name}`, 'No direct URL resolved and no HLS manifest. Returning error.') return { exception: { message: 'No suitable audio stream found.', severity: 'common', - cause: 'Upstream' + cause: 'Upstream', }, - formats + formats, } } @@ -1681,16 +1970,16 @@ export class BaseClient { hlsUrl: streamingData.hlsManifestUrl || null, - formats + formats, } } _getQualityPriority() { return { - high: [251, 141], + high: [251, 250, 140], medium: [250, 140], - low: [249], - lowest: [249] + low: [249, 250, 140], + lowest: [249, 139] } } @@ -1819,12 +2108,6 @@ export class BaseClient { async getTrackUrl(decodedTrack, context, cipherManager) { const sourceName = decodedTrack.sourceName || 'youtube' - const apiEndpoint = this.getApiEndpoint() - logger( - 'debug', - `youtube-${this.name}`, - `Getting stream URL for: ${decodedTrack.title} (ID: ${decodedTrack.identifier}) on ${sourceName}` - ) const headers = this.oauth ? await this.getAuthHeaders() : {} const { body: playerResponse, statusCode } = await this._makePlayerRequest( diff --git a/src/utils.js b/src/utils.js index 8079ec54..9513dece 100644 --- a/src/utils.js +++ b/src/utils.js @@ -199,9 +199,24 @@ function logger(level, ...args) { const verifyDiscordID = (id) => DISCORD_ID_REGEX.test(String(id)) -function validateProperty(property, validator, errorMessage) { - if (!validator(property)) { - throw new Error(errorMessage) +function validateProperty(value, path, expected, validator) { + if (value === undefined || value === null) { + throw new Error( + `Configuration error:\n` + + `- Property: ${path}\n` + + `- Problem: missing required value\n` + + `- Expected: ${expected}\n\n` + + `Please define ${path} in your config.js file.` + ) + } + + if (!validator(value)) { + throw new Error( + `Configuration error:\n` + + `- Property: ${path}\n` + + `- Received: ${JSON.stringify(value)} (${typeof value})\n` + + `- Expected: ${expected}` + ) } } @@ -227,6 +242,36 @@ function getVersion(type = 'string') { } } +function modifyPayload(nodelink, data) { + if (!data || typeof data !== 'object') return data + const modifiers = nodelink.extensions?.trackModifiers + if (!modifiers || modifiers.length === 0) return data + + if (Array.isArray(data)) { + return data.map((item) => modifyPayload(nodelink, item)) + } + + const modifiedData = { ...data } + + if (modifiedData.info && modifiedData.encoded !== undefined) { + for (const modifier of modifiers) { + try { + modifier(modifiedData) + } catch (e) { + logger('error', 'PluginManager', `Track modifier error: ${e.message}`) + } + } + } + + for (const key in modifiedData) { + if (typeof modifiedData[key] === 'object' && key !== 'info') { + modifiedData[key] = modifyPayload(nodelink, modifiedData[key]) + } + } + + return modifiedData +} + function sendResponse(req, res, data, status, trace = false) { const headers = {} @@ -236,9 +281,11 @@ function sendResponse(req, res, data, status, trace = false) { return } - let finalData = data - if (data.trace && !trace) { - const { trace: _, ...rest } = data + const nodelink = global.nodelink + let finalData = nodelink ? modifyPayload(nodelink, data) : data + + if (finalData.trace && !trace) { + const { trace: _, ...rest } = finalData finalData = rest } @@ -285,6 +332,10 @@ function sendResponse(req, res, data, status, trace = false) { } function getGitInfo() { + if (typeof __BUILD_GIT_INFO__ !== 'undefined') { + return __BUILD_GIT_INFO__ + } + const isBun = typeof Bun !== 'undefined' && !!process.versions.bun // bun is too weird if (isBun) { @@ -370,7 +421,7 @@ function getStats(nodelink) { let frameStats = null if (players > 0) { frameStats = { sent: 0, nulled: 0, deficit: 0, expected: 0 } - if (nodelink.workerManager) { + if (nodelink.workerManager) { for (const workerStats of nodelink.workerManager.workerStats.values()) { if (workerStats.frameStats) { frameStats.sent += workerStats.frameStats.sent || 0 @@ -379,7 +430,7 @@ function getStats(nodelink) { } } frameStats.deficit = Math.max(0, frameStats.expected - frameStats.sent) - } else { + } else { for (const session of nodelink.sessions.values()) { if (!session.players) continue for (const player of session.players.players.values()) { @@ -491,7 +542,7 @@ function decodeTrack(encoded) { author: read.utf(), length: Number(read.long()), identifier: read.utf(), - isSeekable: true, + isSeekable: !!read.byte(), isStream: !!read.byte(), uri: version >= 2 && read.byte() ? read.utf() : null, artworkUrl: version === 3 && read.byte() ? read.utf() : null, @@ -545,6 +596,7 @@ function encodeTrack(track) { write('utf', track.author) write('long', track.length) write('utf', track.identifier) + write('byte', track.isSeekable ? 1 : 0) write('byte', track.isStream ? 1 : 0) if (version >= 2) { @@ -599,6 +651,12 @@ const httpAgent = new http.Agent({ keepAlive: true }) const httpsAgent = new https.Agent({ keepAlive: true }) const http2FailedHosts = new Set() +setInterval(() => { + if (http2FailedHosts.size > 0) { + http2FailedHosts.clear() + } +}, 6 * 60 * 60 * 1000).unref() + async function _internalHttp1Request(urlString, options = {}) { const { method = 'GET', diff --git a/src/worker.js b/src/worker.js index 03b765de..2953cf5f 100644 --- a/src/worker.js +++ b/src/worker.js @@ -5,6 +5,7 @@ let lastCpuTime = Date.now() import ConnectionManager from './managers/connectionManager.js' import LyricsManager from './managers/lyricsManager.js' +import PluginManager from './managers/pluginManager.js' import RoutePlannerManager from './managers/routePlannerManager.js' import SourceManager from './managers/sourceManager.js' import StatsManager from './managers/statsManager.js' @@ -32,11 +33,52 @@ nodelink.sources = new SourceManager(nodelink) nodelink.lyrics = new LyricsManager(nodelink) nodelink.routePlanner = new RoutePlannerManager(nodelink) nodelink.connectionManager = new ConnectionManager(nodelink) +nodelink.pluginManager = new PluginManager(nodelink) +nodelink.registry = null +if (process.embedder === 'nodejs') { + try { + nodelink.registry = await import('./registry.js') + } catch (e) { + logger('error', 'Worker', `Failed to load registry: ${e.message}`) + } +} + +nodelink.extensions = { + workerInterceptors: [], + audioInterceptors: [] +} + +nodelink.registerWorkerInterceptor = (fn) => { + nodelink.extensions.workerInterceptors.push(fn) + logger('info', 'Worker', 'Registered worker command interceptor') +} + +nodelink.registerSource = (name, source) => { + if (!nodelink.sources) { + logger('warn', 'Worker', 'Cannot register source (sources manager not ready).') + return + } + nodelink.sources.sources.set(name, source) + logger('info', 'Worker', `Registered custom source: ${name}`) +} + +nodelink.registerFilter = (name, filter) => { + if (!nodelink.extensions.filters) nodelink.extensions.filters = new Map() + nodelink.extensions.filters.set(name, filter) + logger('info', 'Worker', `Registered custom filter: ${name}`) +} + +nodelink.registerAudioInterceptor = (interceptor) => { + if (!nodelink.extensions.audioInterceptors) nodelink.extensions.audioInterceptors = [] + nodelink.extensions.audioInterceptors.push(interceptor) + logger('info', 'Worker', 'Registered custom audio interceptor') +} async function initialize() { await nodelink.sources.loadFolder() await nodelink.lyrics.loadFolder() await nodelink.statsManager.initialize() + await nodelink.pluginManager.load('worker') logger( 'info', 'Worker', @@ -78,6 +120,25 @@ async function processQueue() { const { type, requestId, payload } = commandQueue.shift() + // Execute Worker Interceptors + const interceptors = nodelink.extensions.workerInterceptors + if (interceptors && interceptors.length > 0) { + for (const interceptor of interceptors) { + try { + const shouldBlock = await interceptor(type, payload) + if (shouldBlock === true) { + if (process.connected && requestId) { + process.send({ type: 'commandResult', requestId, payload: { intercepted: true } }) + } + setImmediate(processQueue) + return + } + } catch (e) { + logger('error', 'Worker', `Interceptor error: ${e.message}`) + } + } + } + try { let result switch (type) { @@ -430,34 +491,6 @@ setInterval(() => { ) } } - - if (player.track && !player._isRestoring) { - try { - const playerKey = `${player.session.id}:${player.guildId}` - process.send({ - type: 'playerSnapshot', - payload: { - playerKey, - playerState: { - sessionId: player.session.id, - userId: player.session.userId, - track: player.track, - position: player._realPosition(), - isPaused: player.isPaused, - volume: player.volumePercent, - filters: player.filters, - voice: player.voice - } - } - }) - } catch (e) { - logger( - 'error', - 'Worker-IPC', - `Failed to send playerSnapshot for guild ${player.guildId}: ${e.message}` - ) - } - } } localFrameStats.deficit += Math.max(