From 32c142df70c7aff4278a40002456d923f4798280 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Thu, 30 Apr 2026 13:50:38 -0300 Subject: [PATCH 01/58] fix: soundcloud urls with query params failing to match --- dist/package.json | 2 +- dist/src/sources/soundcloud.js | 2 +- package.json | 2 +- src/sources/soundcloud.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dist/package.json b/dist/package.json index 642296c5..8a000d09 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.0-dev.20260422.1", + "version": "3.8.1-dev.20260430.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js index 79c18e75..b699c337 100644 --- a/dist/src/sources/soundcloud.js +++ b/dist/src/sources/soundcloud.js @@ -6,7 +6,7 @@ const BASE_URL = 'https://api-v2.soundcloud.com'; const SOUNDCLOUD_URL = 'https://soundcloud.com'; const ASSET_PATTERN = /https:\/\/a-v2\.sndcdn\.com\/assets\/[a-zA-Z0-9-]+\.js/g; const CLIENT_ID_PATTERN = /(?:[?&/]?(?:client_id)[\s:=&]*"?|"data":{"id":")([A-Za-z0-9]{32})"?/; -const TRACK_PATTERN = /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/\s]+\/(?:sets\/)?[^/\s]+$/; +const TRACK_PATTERN = /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/\s]+\/(?:sets\/)?[^/\s?]+(\?.*)?$/; const SEARCH_URL_PATTERN = /^https?:\/\/(?:www\.)?soundcloud\.com\/search(?:\/(sounds|people|albums|sets))?(?:\?|$)/; const BATCH_SIZE = 50; const DEFAULT_PRIORITY = 85; diff --git a/package.json b/package.json index 62f78aa3..90c1d1ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.0-dev.20260422.1", + "version": "3.8.1-dev.20260430.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index d5b89638..080de1e4 100644 --- a/src/sources/soundcloud.ts +++ b/src/sources/soundcloud.ts @@ -27,7 +27,7 @@ const ASSET_PATTERN = /https:\/\/a-v2\.sndcdn\.com\/assets\/[a-zA-Z0-9-]+\.js/g const CLIENT_ID_PATTERN = /(?:[?&/]?(?:client_id)[\s:=&]*"?|"data":{"id":")([A-Za-z0-9]{32})"?/ const TRACK_PATTERN = - /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/\s]+\/(?:sets\/)?[^/\s]+$/ + /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\/[^/\s]+\/(?:sets\/)?[^/\s?]+(\?.*)?$/ const SEARCH_URL_PATTERN = /^https?:\/\/(?:www\.)?soundcloud\.com\/search(?:\/(sounds|people|albums|sets))?(?:\?|$)/ const BATCH_SIZE = 50 From b385604fe622f383b23f044729e0f79b34250d0b Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Thu, 30 Apr 2026 19:06:53 -0300 Subject: [PATCH 02/58] improve: add v8 heap statics on profiler this is the gap between heapTotal and the v8 heap space, this revels v8 internal memory. --- dist/src/api/profiler.ui.js | 64 ++++++++++++++++++++++--------------- src/api/profiler.ui.ts | 17 +++++++++- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/dist/src/api/profiler.ui.js b/dist/src/api/profiler.ui.js index fee87c3a..7ce27848 100644 --- a/dist/src/api/profiler.ui.js +++ b/dist/src/api/profiler.ui.js @@ -94,6 +94,7 @@ function buildPage(code) {
Trace Buffer
0
network + events
Players Active
0
across workers
Heap Pressure
0.0%
heapUsed/heapTotal
+
V8 Internal
0 MB
v8 gap
@@ -611,7 +612,17 @@ function buildPage(code) { const safeRss = Math.max(rss, 1) const heapReservedFree = Math.max(0, allocated - used) - const trackedNative = Math.max(0, Math.max(external, arrayBuffers)) + const trackedNative = Math.max(0, external + arrayBuffers) + + let v8HeapTotal = 0 + for (const p of procs) { + const spaces = p?.heapSpaces || p?.runtime?.heapSpaces || [] + for (const s of spaces) { + v8HeapTotal += Number(s?.spaceSize || 0) + } + } + const v8Internal = Math.max(0, allocated - v8HeapTotal) + const unattributed = Math.max(0, rss - used - heapReservedFree - trackedNative) const usedPct = Math.max(0, Math.min(100, (used / safeRss) * 100)) @@ -656,6 +667,9 @@ function buildPage(code) { if (memHeroMachineOtherMetric) memHeroMachineOtherMetric.textContent = fmtBytes(machineOtherAbs) + ' · ' + machineOtherPct.toFixed(1) + '%' if (memHeroMachineFreeMetric) memHeroMachineFreeMetric.textContent = fmtBytes(machineFree) + ' · ' + machineFreePct.toFixed(1) + '%' + const v8InternalEl = document.getElementById('v8Internal') + if (v8InternalEl) v8InternalEl.textContent = fmtBytes(v8Internal) + ' · ' + ((v8Internal / Math.max(rss, 1)) * 100).toFixed(1) + '%' + const masterRss = Number(snapshot?.master?.memory?.rss || 0) let workersRss = 0 for (const w of (snapshot?.workers || [])) workersRss += Number(w?.response?.memory?.rss || 0) @@ -1376,13 +1390,13 @@ function buildPage(code) { for (const w of (snapshot?.workers || [])) { const bp = w?.response?.workersContext?.bufferPool if (!bp) continue - - const rejectionRate = (bp.releaseCalls || 0) > 0 + + const rejectionRate = (bp.releaseCalls || 0) > 0 ? ((bp.rejectedReleases || 0) / bp.releaseCalls * 100).toFixed(1) : '0.0' - const rejectionLevel = parseFloat(rejectionRate) > 30 ? 'warn' : + const rejectionLevel = parseFloat(rejectionRate) > 30 ? 'warn' : parseFloat(rejectionRate) > 10 ? 'neutral' : 'ok' - + rows.push({ text: 'worker ' + (w.pid || '-') + '' + @@ -1433,12 +1447,12 @@ function buildPage(code) { function renderDebugInternals(snapshot) { const rows = [] - + for (const w of (snapshot?.workers || [])) { const dbg = w?.response?.debugInternals if (!dbg) continue const pid = w.pid || '-' - + if (dbg.sourceManager) { const sm = dbg.sourceManager rows.push({ @@ -1449,7 +1463,7 @@ function buildPage(code) { 'patterns: ' + (sm.patternMapLength ?? '-') + '' }) } - + if (dbg.trackCache) { const tc = dbg.trackCache rows.push({ @@ -1458,7 +1472,7 @@ function buildPage(code) { 'max: ' + (tc.maxEntries ?? '-') + '' }) } - + if (dbg.credentials) { const cr = dbg.credentials rows.push({ @@ -1467,7 +1481,7 @@ function buildPage(code) { 'expired: ' + (cr.expiredEntries ?? '-') + '' }) } - + if (dbg.connection) { const cn = dbg.connection rows.push({ @@ -1477,7 +1491,7 @@ function buildPage(code) { 'interval: ' + (cn.hasInterval ? 'yes' : 'no') + '' }) } - + if (dbg.extensions) { const ex = dbg.extensions rows.push({ @@ -1488,7 +1502,7 @@ function buildPage(code) { (ex.customFilterNames?.length ? '[' + ex.customFilterNames.join(', ') + ']' : '') }) } - + if (dbg.httpAgents) { const ha = dbg.httpAgents rows.push({ @@ -1500,12 +1514,12 @@ function buildPage(code) { }) } } - + for (const s of (snapshot?.sourceWorkers || [])) { const dbg = s?.response?.debugInternals if (!dbg) continue const pid = s.pid || '-' - + if (dbg.sourceManager) { const sm = dbg.sourceManager rows.push({ @@ -1516,7 +1530,7 @@ function buildPage(code) { 'patterns: ' + (sm.patternMapLength ?? '-') + '' }) } - + if (dbg.trackCache) { const tc = dbg.trackCache rows.push({ @@ -1525,7 +1539,7 @@ function buildPage(code) { 'max: ' + (tc.maxEntries ?? '-') + '' }) } - + if (dbg.credentials) { const cr = dbg.credentials rows.push({ @@ -1534,7 +1548,7 @@ function buildPage(code) { 'expired: ' + (cr.expiredEntries ?? '-') + '' }) } - + if (dbg.httpAgents) { const ha = dbg.httpAgents rows.push({ @@ -1546,7 +1560,7 @@ function buildPage(code) { }) } } - + setList(debugInternals, rows, '', 0) } @@ -1557,17 +1571,17 @@ function buildPage(code) { if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB' return (n / 1024 / 1024).toFixed(2) + ' MB' } - + for (const w of (snapshot?.workers || [])) { const ipc = w?.response?.debugInternals?.ipcTracker if (!ipc) continue const pid = w.pid || '-' - + rows.push({ text: 'Worker ' + pid + ' - Sent', level: 'ok' }) - + const sentSorted = (ipc.sent || []).slice(0, 10) for (const s of sentSorted) { rows.push({ @@ -1578,12 +1592,12 @@ function buildPage(code) { 'max: ' + fmtBytes(s.maxBytes) + '' }) } - + rows.push({ text: 'Worker ' + pid + ' - Received', level: 'ok' }) - + const recvSorted = (ipc.received || []).slice(0, 10) for (const r of recvSorted) { rows.push({ @@ -1595,11 +1609,11 @@ function buildPage(code) { }) } } - + if (rows.length === 0) { rows.push({ text: 'No IPC traffic data yet.' }) } - + setList(ipcTraffic, rows, '', 0) } diff --git a/src/api/profiler.ui.ts b/src/api/profiler.ui.ts index 61e7f9fb..d7ccdf3c 100644 --- a/src/api/profiler.ui.ts +++ b/src/api/profiler.ui.ts @@ -131,6 +131,7 @@ function buildPage(code: string): string {
Trace Buffer
0
network + events
Players Active
0
across workers
Heap Pressure
0.0%
heapUsed/heapTotal
+
V8 Internal
0 MB
v8 gap
@@ -648,7 +649,18 @@ function buildPage(code: string): string { const safeRss = Math.max(rss, 1) const heapReservedFree = Math.max(0, allocated - used) - const trackedNative = Math.max(0, Math.max(external, arrayBuffers)) + const trackedNative = Math.max(0, external + arrayBuffers) + + // V8 heap spaces sum + let v8HeapTotal = 0 + for (const p of procs) { + const spaces = p?.heapSpaces || p?.runtime?.heapSpaces || [] + for (const s of spaces) { + v8HeapTotal += Number(s?.spaceSize || 0) + } + } + const v8Internal = Math.max(0, allocated - v8HeapTotal) + const unattributed = Math.max(0, rss - used - heapReservedFree - trackedNative) const usedPct = Math.max(0, Math.min(100, (used / safeRss) * 100)) @@ -693,6 +705,9 @@ function buildPage(code: string): string { if (memHeroMachineOtherMetric) memHeroMachineOtherMetric.textContent = fmtBytes(machineOtherAbs) + ' · ' + machineOtherPct.toFixed(1) + '%' if (memHeroMachineFreeMetric) memHeroMachineFreeMetric.textContent = fmtBytes(machineFree) + ' · ' + machineFreePct.toFixed(1) + '%' + const v8InternalEl = document.getElementById('v8Internal') + if (v8InternalEl) v8InternalEl.textContent = fmtBytes(v8Internal) + ' · ' + ((v8Internal / Math.max(rss, 1)) * 100).toFixed(1) + '%' + const masterRss = Number(snapshot?.master?.memory?.rss || 0) let workersRss = 0 for (const w of (snapshot?.workers || [])) workersRss += Number(w?.response?.memory?.rss || 0) From 1eb3fa7dcbac5fbb3bf7849b1f51c286ac9bc227 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Thu, 30 Apr 2026 19:07:35 -0300 Subject: [PATCH 03/58] update: add missing type in potoken fixes the npm build error. --- src/sources/youtube/sabr/potoken.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sources/youtube/sabr/potoken.ts b/src/sources/youtube/sabr/potoken.ts index 549dbf25..6e0a34f5 100644 --- a/src/sources/youtube/sabr/potoken.ts +++ b/src/sources/youtube/sabr/potoken.ts @@ -517,7 +517,7 @@ export class PoTokenManager { url: 'https://www.youtube.com/', referrer: 'https://www.youtube.com/', userAgent: PO_CONFIG.userAgent - } + } as import('jsdom').ConstructorOptions ) this._applyDomGlobals(this._dom) From 34daff834e97e690f12b78d1e5bfd00f8dcede59 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Thu, 30 Apr 2026 22:18:41 -0300 Subject: [PATCH 04/58] add: tiktok video support Added support for playing tiktok videos. --- config.default.js | 5 +- dist/config.default.js | 5 +- dist/src/sources/tiktok.js | 265 +++++++++++++++++++++++++++++ src/sources/tiktok.ts | 335 +++++++++++++++++++++++++++++++++++++ 4 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 dist/src/sources/tiktok.js create mode 100644 src/sources/tiktok.ts diff --git a/config.default.js b/config.default.js index 01e3f7c0..1f8c891f 100644 --- a/config.default.js +++ b/config.default.js @@ -486,7 +486,10 @@ export default { }, googledrive: { enabled: true - } + }, + tiktok: { + enabled: true, + }, }, lyrics: { fallbackSource: 'genius', diff --git a/dist/config.default.js b/dist/config.default.js index c4732495..4e6b3b50 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -486,7 +486,10 @@ export default { }, googledrive: { enabled: true - } + }, + tiktok: { + enabled: true, + }, }, lyrics: { fallbackSource: 'genius', diff --git a/dist/src/sources/tiktok.js b/dist/src/sources/tiktok.js new file mode 100644 index 00000000..0022a2f2 --- /dev/null +++ b/dist/src/sources/tiktok.js @@ -0,0 +1,265 @@ +import { PassThrough } from 'node:stream'; +import { encodeTrack, http1makeRequest, logger } from "../utils.js"; +/** + * Browser user agent sent with TikTok requests. + * @internal + */ +const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'; +/** + * TikTok player API endpoint used by embeds. + * @internal + */ +const API_BASE = 'https://www.tiktok.com/player/api/v1/items'; +/** + * Regex pattern to extract the video ID from a TikTok URL. + * @internal + */ +const VIDEO_ID_RE = /\/video\/(\d{15,})/; +/** + * Returns the first valid string from an array of unknown values. + * @param list - Array of unknown values (usually strings from API). + * @returns The first valid string, or an empty string if none found. + * @internal + */ +function firstString(list) { + if (Array.isArray(list)) { + for (const v of list) { + if (typeof v === 'string' && v.length) + return v; + } + } + return ''; +} +/** + * TikTok source implementation. + * + * Resolves `tiktok.com/@user/video/ID` URLs and extracts MP4 playback streams + * using the TikTok player API endpoint. + * @public + */ +export default class TiktokSource { + /** + * Runtime worker context. + */ + nodelink; + /** + * URL patterns this source handles. + */ + patterns; + /** + * Match priority used by the source manager (higher wins). + */ + priority; + /** + * Creates a new TikTok source wrapper. + * @param nodelink - Worker runtime context. + */ + constructor(nodelink) { + this.nodelink = nodelink; + this.patterns = [ + /^https?:\/\/(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)/i, + /^https?:\/\/(?:www\.)?vm\.tiktok\.com\/([\w-]+)/i, + /^https?:\/\/(?:www\.)?m\.tiktok\.com\/([\w-]+)/i + ]; + this.priority = 60; + } + /** + * Initializes source resources. No async setup required. + * @returns `true` when the source is ready to accept requests. + */ + async setup() { + logger('info', 'Sources', 'Loaded TikTok source.'); + return true; + } + /** + * TikTok does not support keyword search. + * @param _query - Unused search query. + * @param _sourceName - Unused source name. + * @param _searchType - Unused search type. + * @returns An empty result. + */ + async search(_query, _sourceName, _searchType) { + return { loadType: 'empty', data: {} }; + } + /** + * Resolves a TikTok video URL into a playable track. + * + * Fetches metadata and playback URLs from the TikTok player API endpoint. + * @param url - Public TikTok video URL. + * @param _type - Unused type hint kept for source-manager compatibility. + * @returns A track result, an empty payload, or a structured exception. + */ + async resolve(url, _type) { + const match = url.match(VIDEO_ID_RE); + const videoId = match?.[1]; + if (!videoId) + return { loadType: 'empty', data: {} }; + try { + const params = new URLSearchParams({ + item_ids: videoId, + language: 'en', + aid: '1284', + app_name: 'tiktok_web', + device_platform: 'web_pc' + }); + const response = await http1makeRequest(`${API_BASE}?${params}`, { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT, + Referer: `https://www.tiktok.com/player/v1/${videoId}`, + Accept: 'application/json, text/plain, */*' + } + }); + if (response.error || response.statusCode !== 200) { + return { + loadType: 'error', + exception: { + message: `TikTok API failed: ${response.error || `Status ${response.statusCode}`}`, + severity: 'fault' + } + }; + } + let data = response.body; + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } + catch { + return { + loadType: 'error', + exception: { message: 'Invalid TikTok JSON', severity: 'fault' } + }; + } + } + const items = data?.items; + const item = (Array.isArray(items) ? items[0] : undefined); + if (!item) + return { loadType: 'empty', data: {} }; + const vi = item.video_info ?? {}; + const profile = Array.isArray(vi.profiles) ? vi.profiles[0] : null; + const directUrl = firstString(profile?.play_addr?.url_list) || firstString(vi.url_list); + if (!directUrl) { + return { + loadType: 'error', + exception: { + message: 'No playback URL in TikTok response', + severity: 'fault' + } + }; + } + const author = item.author_info?.nickname || 'TikTok User'; + const desc = item.desc || ''; + const title = desc.split('#')[0]?.trim() || 'TikTok Video'; + const rawDuration = vi.meta?.duration ?? 0; + const durationMs = rawDuration > 0 + ? rawDuration < 1000 + ? rawDuration * 1000 + : rawDuration + : -1; + const thumbnail = firstString(vi.cover?.url_list) || null; + const info = { + identifier: videoId, + isSeekable: true, + author, + length: durationMs, + isStream: false, + position: 0, + title, + uri: url, + artworkUrl: thumbnail, + isrc: null, + sourceName: 'tiktok', + details: [] + }; + const trackData = { + encoded: encodeTrack(info), + info: info, + pluginInfo: { directUrl, videoId } + }; + return { loadType: 'track', data: trackData }; + } + catch (e) { + const message = e instanceof Error ? e.message : String(e); + logger('error', 'TikTok', `Resolve failed: ${message}`); + return { loadType: 'error', exception: { message, severity: 'fault' } }; + } + } + /** + * Returns the cached or resolved direct playback URL. + * + * When the track already carries a `pluginInfo.directUrl`, the URL is + * returned immediately. Otherwise the video is re-resolved. + * @param trackInfo - Decoded TikTok track information. + * @param _itag - Unused itag placeholder kept for source-manager compatibility. + * @param _isRecovering - Whether this is a recovery attempt. + * @returns Direct playback URL metadata or a structured exception. + */ + async getTrackUrl(trackInfo, _itag, _isRecovering) { + const pluginInfo = trackInfo + .pluginInfo; + const directUrl = pluginInfo?.directUrl; + if (directUrl && typeof directUrl === 'string') { + return { url: directUrl, protocol: 'https', format: 'mp4' }; + } + const uri = trackInfo.uri; + if (uri && VIDEO_ID_RE.test(uri)) { + const result = await this.resolve(uri); + if (result.loadType === 'track') { + const trackData = result.data; + const url = trackData?.pluginInfo?.directUrl; + if (url) + return { url, protocol: 'https', format: 'mp4' }; + } + } + return { + url: undefined, + protocol: undefined, + format: undefined, + exception: { + message: 'No playable URL for TikTok track', + severity: 'fault' + } + }; + } + /** + * Loads the MP4 stream for a TikTok video. + * + * Proxies the direct playback URL through a `PassThrough` stream. + * @param track - Decoded track information. + * @param url - Resolved playback URL. + * @param _protocol - Optional protocol hint (unused). + * @param _additionalData - Optional additional data (unused). + * @returns A readable stream or a structured exception. + */ + async loadStream(_track, url, _protocol, _additionalData) { + try { + const response = await http1makeRequest(url, { + method: 'GET', + streamOnly: true, + headers: { + 'User-Agent': USER_AGENT, + Referer: 'https://www.tiktok.com/' + } + }); + if (response.error || !response.stream) { + throw new Error(response.error || 'No stream returned'); + } + const stream = new PassThrough(); + response.stream.on('data', (chunk) => stream.write(chunk)); + response.stream.on('end', () => { + stream.emit('finishBuffering'); + stream.end(); + }); + response.stream.on('error', (err) => stream.destroy(err)); + return { stream, type: 'video/mp4' }; + } + catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { + stream: undefined, + type: undefined, + exception: { message, severity: 'fault' } + }; + } + } +} diff --git a/src/sources/tiktok.ts b/src/sources/tiktok.ts new file mode 100644 index 00000000..777b06b7 --- /dev/null +++ b/src/sources/tiktok.ts @@ -0,0 +1,335 @@ +import { PassThrough } from 'node:stream' + +import type { + SourceInstance, + SourceResult, + TrackData, + TrackInfo, + TrackStreamResult, + TrackUrlResult, + WorkerNodeLink +} from '../typings/sources/source.types.ts' +import type { TrackEncodeInput } from '../typings/utils.types.ts' +import { encodeTrack, http1makeRequest, logger } from '../utils.ts' + +/** + * Browser user agent sent with TikTok requests. + * @internal + */ +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36' + +/** + * TikTok player API endpoint used by embeds. + * @internal + */ +const API_BASE = 'https://www.tiktok.com/player/api/v1/items' + +/** + * Regex pattern to extract the video ID from a TikTok URL. + * @internal + */ +const VIDEO_ID_RE = /\/video\/(\d{15,})/ + +/** + * JSON-compatible item structure returned by the TikTok player API. + * @internal + */ +interface TikTokApiItem { + author_info?: { nickname?: string } + desc?: string + video_info?: { + meta?: { duration?: number } + cover?: { url_list?: unknown[] } + profiles?: Array<{ play_addr?: { url_list?: unknown[] } }> + url_list?: unknown[] + } +} + +/** + * Returns the first valid string from an array of unknown values. + * @param list - Array of unknown values (usually strings from API). + * @returns The first valid string, or an empty string if none found. + * @internal + */ +function firstString(list: unknown): string { + if (Array.isArray(list)) { + for (const v of list) { + if (typeof v === 'string' && v.length) return v + } + } + return '' +} + +/** + * TikTok source implementation. + * + * Resolves `tiktok.com/@user/video/ID` URLs and extracts MP4 playback streams + * using the TikTok player API endpoint. + * @public + */ +export default class TiktokSource implements SourceInstance { + /** + * Runtime worker context. + */ + public readonly nodelink: WorkerNodeLink + + /** + * URL patterns this source handles. + */ + public readonly patterns: RegExp[] + + /** + * Match priority used by the source manager (higher wins). + */ + public readonly priority: number + + /** + * Creates a new TikTok source wrapper. + * @param nodelink - Worker runtime context. + */ + public constructor(nodelink: WorkerNodeLink) { + this.nodelink = nodelink + this.patterns = [ + /^https?:\/\/(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)/i, + /^https?:\/\/(?:www\.)?vm\.tiktok\.com\/([\w-]+)/i, + /^https?:\/\/(?:www\.)?m\.tiktok\.com\/([\w-]+)/i + ] + this.priority = 60 + } + + /** + * Initializes source resources. No async setup required. + * @returns `true` when the source is ready to accept requests. + */ + public async setup(): Promise { + logger('info', 'Sources', 'Loaded TikTok source.') + return true + } + + /** + * TikTok does not support keyword search. + * @param _query - Unused search query. + * @param _sourceName - Unused source name. + * @param _searchType - Unused search type. + * @returns An empty result. + */ + public async search( + _query?: string, + _sourceName?: string, + _searchType?: string + ): Promise { + return { loadType: 'empty', data: {} } + } + + /** + * Resolves a TikTok video URL into a playable track. + * + * Fetches metadata and playback URLs from the TikTok player API endpoint. + * @param url - Public TikTok video URL. + * @param _type - Unused type hint kept for source-manager compatibility. + * @returns A track result, an empty payload, or a structured exception. + */ + public async resolve(url: string, _type?: string): Promise { + const match = url.match(VIDEO_ID_RE) + const videoId = match?.[1] + if (!videoId) return { loadType: 'empty', data: {} } + + try { + const params = new URLSearchParams({ + item_ids: videoId, + language: 'en', + aid: '1284', + app_name: 'tiktok_web', + device_platform: 'web_pc' + }) + + const response = await http1makeRequest(`${API_BASE}?${params}`, { + method: 'GET', + headers: { + 'User-Agent': USER_AGENT, + Referer: `https://www.tiktok.com/player/v1/${videoId}`, + Accept: 'application/json, text/plain, */*' + } + }) + + if (response.error || response.statusCode !== 200) { + return { + loadType: 'error', + exception: { + message: `TikTok API failed: ${response.error || `Status ${response.statusCode}`}`, + severity: 'fault' + } + } + } + + let data = response.body + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch { + return { + loadType: 'error', + exception: { message: 'Invalid TikTok JSON', severity: 'fault' } + } + } + } + + const items = (data as Record)?.items + const item = (Array.isArray(items) ? items[0] : undefined) as + | TikTokApiItem + | undefined + if (!item) return { loadType: 'empty', data: {} } + + const vi = item.video_info ?? {} + + const profile = Array.isArray(vi.profiles) ? vi.profiles[0] : null + const directUrl: string = + firstString(profile?.play_addr?.url_list) || firstString(vi.url_list) + + if (!directUrl) { + return { + loadType: 'error', + exception: { + message: 'No playback URL in TikTok response', + severity: 'fault' + } + } + } + + const author: string = item.author_info?.nickname || 'TikTok User' + const desc: string = item.desc || '' + const title: string = desc.split('#')[0]?.trim() || 'TikTok Video' + + const rawDuration: number = vi.meta?.duration ?? 0 + const durationMs: number = + rawDuration > 0 + ? rawDuration < 1000 + ? rawDuration * 1000 + : rawDuration + : -1 + + const thumbnail: string | null = firstString(vi.cover?.url_list) || null + + const info: TrackEncodeInput = { + identifier: videoId, + isSeekable: true, + author, + length: durationMs, + isStream: false, + position: 0, + title, + uri: url, + artworkUrl: thumbnail, + isrc: null, + sourceName: 'tiktok', + details: [] + } + + const trackData: TrackData = { + encoded: encodeTrack(info), + info: info as unknown as TrackInfo, + pluginInfo: { directUrl, videoId } + } + + return { loadType: 'track', data: trackData } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + logger('error', 'TikTok', `Resolve failed: ${message}`) + return { loadType: 'error', exception: { message, severity: 'fault' } } + } + } + + /** + * Returns the cached or resolved direct playback URL. + * + * When the track already carries a `pluginInfo.directUrl`, the URL is + * returned immediately. Otherwise the video is re-resolved. + * @param trackInfo - Decoded TikTok track information. + * @param _itag - Unused itag placeholder kept for source-manager compatibility. + * @param _isRecovering - Whether this is a recovery attempt. + * @returns Direct playback URL metadata or a structured exception. + */ + public async getTrackUrl( + trackInfo: TrackInfo, + _itag?: number, + _isRecovering?: boolean + ): Promise { + const pluginInfo = (trackInfo as { pluginInfo?: Record }) + .pluginInfo + const directUrl = pluginInfo?.directUrl as string | undefined + + if (directUrl && typeof directUrl === 'string') { + return { url: directUrl, protocol: 'https', format: 'mp4' } + } + + const uri = trackInfo.uri + if (uri && VIDEO_ID_RE.test(uri)) { + const result = await this.resolve(uri) + if (result.loadType === 'track') { + const trackData = result.data as TrackData + const url = trackData?.pluginInfo?.directUrl as string | undefined + if (url) return { url, protocol: 'https', format: 'mp4' } + } + } + + return { + url: undefined, + protocol: undefined, + format: undefined, + exception: { + message: 'No playable URL for TikTok track', + severity: 'fault' + } + } + } + + /** + * Loads the MP4 stream for a TikTok video. + * + * Proxies the direct playback URL through a `PassThrough` stream. + * @param track - Decoded track information. + * @param url - Resolved playback URL. + * @param _protocol - Optional protocol hint (unused). + * @param _additionalData - Optional additional data (unused). + * @returns A readable stream or a structured exception. + */ + public async loadStream( + _track: TrackInfo, + url: string, + _protocol?: string, + _additionalData?: Record + ): Promise { + try { + const response = await http1makeRequest(url, { + method: 'GET', + streamOnly: true, + headers: { + 'User-Agent': USER_AGENT, + Referer: 'https://www.tiktok.com/' + } + }) + + if (response.error || !response.stream) { + throw new Error(response.error || 'No stream returned') + } + + const stream = new PassThrough() + response.stream.on('data', (chunk: Buffer) => stream.write(chunk)) + response.stream.on('end', () => { + stream.emit('finishBuffering') + stream.end() + }) + response.stream.on('error', (err: Error) => stream.destroy(err)) + + return { stream, type: 'video/mp4' } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + return { + stream: undefined, + type: undefined, + exception: { message, severity: 'fault' } + } + } + } +} From fa09a3b3276b56c708cf80c83ee636723ce96bac Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Fri, 1 May 2026 22:52:08 -0400 Subject: [PATCH 05/58] fix: improve error handling and logging in YouTube cipher service --- package.json | 8 +++--- src/sources/youtube/CipherManager.ts | 41 +++++++++++++++++++--------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 90c1d1ba..3b00d61f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260430.1", + "version": "3.8.1-dev.20260501.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", @@ -24,16 +24,16 @@ "@toddynnn/symphonia-decoder": "1.0.6", "@toddynnn/voice-opus": "^1.0.1", "fastest-validator": "^1.19.1", - "jsdom": "^29.0.2", + "jsdom": "^29.1.1", "mp4box": "^2.3.0", "prom-client": "^15.1.3", "proxy-agent": "^8.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.12", + "@biomejs/biome": "^2.4.13", "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", - "@types/bun": "^1.3.12", + "@types/bun": "^1.3.13", "@types/jsdom": "^28.0.1", "@types/node": "^25.6.0", "dotenv": "^17.4.2", diff --git a/src/sources/youtube/CipherManager.ts b/src/sources/youtube/CipherManager.ts index 80de114f..74d22f12 100644 --- a/src/sources/youtube/CipherManager.ts +++ b/src/sources/youtube/CipherManager.ts @@ -406,9 +406,17 @@ export default class CipherManager implements ICipherManager { const parsedBody = (body ?? {}) as YouTubeCipherServiceResponse if (error || statusCode !== 200 || !parsedBody.sts) { - throw new Error( - `Failed to get STS: ${error || parsedBody.message || 'Invalid response'}` + const detail = + error || + parsedBody.message || + (typeof body === 'object' ? JSON.stringify(body) : body) || + 'Invalid response' + logger( + 'error', + 'YouTube-Cipher', + `Failed to get STS from cipher service (Status: ${statusCode ?? 'unknown'}): ${detail}` ) + throw new Error(`Failed to get STS: ${detail}`) } logger('debug', 'YouTube-Cipher', `Received STS: ${parsedBody.sts}`) @@ -581,19 +589,27 @@ export default class CipherManager implements ICipherManager { Date.now() - startTime ) - logger( - 'debug', - 'YouTube-Cipher', - `Received from cipher service (Status: ${statusCode})` - ) - const parsedBody = (body ?? {}) as YouTubeCipherServiceResponse if (error || statusCode !== 200 || !parsedBody.resolved_url) { - throw new Error( - `Failed to resolve URL: ${error || parsedBody.message || 'Invalid response'}` + const detail = + error || + parsedBody.message || + (typeof body === 'object' ? JSON.stringify(body) : body) || + 'Invalid response' + logger( + 'error', + 'YouTube-Cipher', + `Failed to resolve URL via cipher service (Status: ${statusCode ?? 'unknown'}): ${detail}` ) + throw new Error(`Failed to resolve URL: ${detail}`) } + logger( + 'debug', + 'YouTube-Cipher', + `Received from cipher service (Status: ${statusCode}): ${parsedBody.resolved_url}` + ) + logger( 'debug', 'YouTube-Cipher', @@ -646,9 +662,8 @@ export default class CipherManager implements ICipherManager { const watchPage = typeof body === 'string' ? body : '' if (error || statusCode !== 200 || !watchPage) { - throw new Error( - `Failed to fetch watch page for player script: ${error || statusCode || 'unknown'}` - ) + const reason = error || `Status ${statusCode ?? 'unknown'}` + throw new Error(`Failed to fetch watch page for player script: ${reason}`) } const jsUrlMatch = watchPage.match(/"jsUrl":"([^"]+)"/) From 5c15b29c536de1f52e50afb566c24d3fb352a35a Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Fri, 1 May 2026 22:52:39 -0400 Subject: [PATCH 06/58] fix: improve error handling and logging in YouTube cipher service --- dist/package.json | 8 ++-- dist/src/api/profiler.ui.js | 49 ++++++++++++----------- dist/src/sources/youtube/CipherManager.js | 19 +++++++-- 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/dist/package.json b/dist/package.json index 8a000d09..8af12a39 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260430.1", + "version": "3.8.1-dev.20260501.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", @@ -24,16 +24,16 @@ "@toddynnn/symphonia-decoder": "1.0.6", "@toddynnn/voice-opus": "^1.0.1", "fastest-validator": "^1.19.1", - "jsdom": "^29.0.2", + "jsdom": "^29.1.1", "mp4box": "^2.3.0", "prom-client": "^15.1.3", "proxy-agent": "^8.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.12", + "@biomejs/biome": "^2.4.13", "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", - "@types/bun": "^1.3.12", + "@types/bun": "^1.3.13", "@types/jsdom": "^28.0.1", "@types/node": "^25.6.0", "dotenv": "^17.4.2", diff --git a/dist/src/api/profiler.ui.js b/dist/src/api/profiler.ui.js index 7ce27848..e7d11329 100644 --- a/dist/src/api/profiler.ui.js +++ b/dist/src/api/profiler.ui.js @@ -614,6 +614,7 @@ function buildPage(code) { const heapReservedFree = Math.max(0, allocated - used) const trackedNative = Math.max(0, external + arrayBuffers) + // V8 heap spaces sum let v8HeapTotal = 0 for (const p of procs) { const spaces = p?.heapSpaces || p?.runtime?.heapSpaces || [] @@ -1390,13 +1391,13 @@ function buildPage(code) { for (const w of (snapshot?.workers || [])) { const bp = w?.response?.workersContext?.bufferPool if (!bp) continue - - const rejectionRate = (bp.releaseCalls || 0) > 0 + + const rejectionRate = (bp.releaseCalls || 0) > 0 ? ((bp.rejectedReleases || 0) / bp.releaseCalls * 100).toFixed(1) : '0.0' - const rejectionLevel = parseFloat(rejectionRate) > 30 ? 'warn' : + const rejectionLevel = parseFloat(rejectionRate) > 30 ? 'warn' : parseFloat(rejectionRate) > 10 ? 'neutral' : 'ok' - + rows.push({ text: 'worker ' + (w.pid || '-') + '' + @@ -1447,12 +1448,12 @@ function buildPage(code) { function renderDebugInternals(snapshot) { const rows = [] - + for (const w of (snapshot?.workers || [])) { const dbg = w?.response?.debugInternals if (!dbg) continue const pid = w.pid || '-' - + if (dbg.sourceManager) { const sm = dbg.sourceManager rows.push({ @@ -1463,7 +1464,7 @@ function buildPage(code) { 'patterns: ' + (sm.patternMapLength ?? '-') + '' }) } - + if (dbg.trackCache) { const tc = dbg.trackCache rows.push({ @@ -1472,7 +1473,7 @@ function buildPage(code) { 'max: ' + (tc.maxEntries ?? '-') + '' }) } - + if (dbg.credentials) { const cr = dbg.credentials rows.push({ @@ -1481,7 +1482,7 @@ function buildPage(code) { 'expired: ' + (cr.expiredEntries ?? '-') + '' }) } - + if (dbg.connection) { const cn = dbg.connection rows.push({ @@ -1491,7 +1492,7 @@ function buildPage(code) { 'interval: ' + (cn.hasInterval ? 'yes' : 'no') + '' }) } - + if (dbg.extensions) { const ex = dbg.extensions rows.push({ @@ -1502,7 +1503,7 @@ function buildPage(code) { (ex.customFilterNames?.length ? '[' + ex.customFilterNames.join(', ') + ']' : '') }) } - + if (dbg.httpAgents) { const ha = dbg.httpAgents rows.push({ @@ -1514,12 +1515,12 @@ function buildPage(code) { }) } } - + for (const s of (snapshot?.sourceWorkers || [])) { const dbg = s?.response?.debugInternals if (!dbg) continue const pid = s.pid || '-' - + if (dbg.sourceManager) { const sm = dbg.sourceManager rows.push({ @@ -1530,7 +1531,7 @@ function buildPage(code) { 'patterns: ' + (sm.patternMapLength ?? '-') + '' }) } - + if (dbg.trackCache) { const tc = dbg.trackCache rows.push({ @@ -1539,7 +1540,7 @@ function buildPage(code) { 'max: ' + (tc.maxEntries ?? '-') + '' }) } - + if (dbg.credentials) { const cr = dbg.credentials rows.push({ @@ -1548,7 +1549,7 @@ function buildPage(code) { 'expired: ' + (cr.expiredEntries ?? '-') + '' }) } - + if (dbg.httpAgents) { const ha = dbg.httpAgents rows.push({ @@ -1560,7 +1561,7 @@ function buildPage(code) { }) } } - + setList(debugInternals, rows, '', 0) } @@ -1571,17 +1572,17 @@ function buildPage(code) { if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB' return (n / 1024 / 1024).toFixed(2) + ' MB' } - + for (const w of (snapshot?.workers || [])) { const ipc = w?.response?.debugInternals?.ipcTracker if (!ipc) continue const pid = w.pid || '-' - + rows.push({ text: 'Worker ' + pid + ' - Sent', level: 'ok' }) - + const sentSorted = (ipc.sent || []).slice(0, 10) for (const s of sentSorted) { rows.push({ @@ -1592,12 +1593,12 @@ function buildPage(code) { 'max: ' + fmtBytes(s.maxBytes) + '' }) } - + rows.push({ text: 'Worker ' + pid + ' - Received', level: 'ok' }) - + const recvSorted = (ipc.received || []).slice(0, 10) for (const r of recvSorted) { rows.push({ @@ -1609,11 +1610,11 @@ function buildPage(code) { }) } } - + if (rows.length === 0) { rows.push({ text: 'No IPC traffic data yet.' }) } - + setList(ipcTraffic, rows, '', 0) } diff --git a/dist/src/sources/youtube/CipherManager.js b/dist/src/sources/youtube/CipherManager.js index b27efc5c..9ff654d5 100644 --- a/dist/src/sources/youtube/CipherManager.js +++ b/dist/src/sources/youtube/CipherManager.js @@ -272,7 +272,12 @@ export default class CipherManager { this.reportProxyStatus(proxy, !error && statusCode === 200, statusCode ?? 500, Date.now() - startTime); const parsedBody = (body ?? {}); if (error || statusCode !== 200 || !parsedBody.sts) { - throw new Error(`Failed to get STS: ${error || parsedBody.message || 'Invalid response'}`); + const detail = error || + parsedBody.message || + (typeof body === 'object' ? JSON.stringify(body) : body) || + 'Invalid response'; + logger('error', 'YouTube-Cipher', `Failed to get STS from cipher service (Status: ${statusCode ?? 'unknown'}): ${detail}`); + throw new Error(`Failed to get STS: ${detail}`); } logger('debug', 'YouTube-Cipher', `Received STS: ${parsedBody.sts}`); this.stsCache.set(playerUrl, parsedBody.sts); @@ -387,11 +392,16 @@ export default class CipherManager { } const { body, error, statusCode } = response; this.reportProxyStatus(proxy, !error && statusCode === 200, statusCode ?? 500, Date.now() - startTime); - logger('debug', 'YouTube-Cipher', `Received from cipher service (Status: ${statusCode})`); const parsedBody = (body ?? {}); if (error || statusCode !== 200 || !parsedBody.resolved_url) { - throw new Error(`Failed to resolve URL: ${error || parsedBody.message || 'Invalid response'}`); + const detail = error || + parsedBody.message || + (typeof body === 'object' ? JSON.stringify(body) : body) || + 'Invalid response'; + logger('error', 'YouTube-Cipher', `Failed to resolve URL via cipher service (Status: ${statusCode ?? 'unknown'}): ${detail}`); + throw new Error(`Failed to resolve URL: ${detail}`); } + logger('debug', 'YouTube-Cipher', `Received from cipher service (Status: ${statusCode}): ${parsedBody.resolved_url}`); logger('debug', 'YouTube-Cipher', `Resolved URL: ${parsedBody.resolved_url}`); return parsedBody.resolved_url; } @@ -430,7 +440,8 @@ export default class CipherManager { this.reportProxyStatus(proxy, !error && statusCode === 200, statusCode ?? 500, Date.now() - startTime); const watchPage = typeof body === 'string' ? body : ''; if (error || statusCode !== 200 || !watchPage) { - throw new Error(`Failed to fetch watch page for player script: ${error || statusCode || 'unknown'}`); + const reason = error || `Status ${statusCode ?? 'unknown'}`; + throw new Error(`Failed to fetch watch page for player script: ${reason}`); } const jsUrlMatch = watchPage.match(/"jsUrl":"([^"]+)"/); const scriptUrl = jsUrlMatch?.[1]; From 62223ad2a0458005fe6d513c3922396f60c63626 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Wed, 6 May 2026 18:20:16 -0300 Subject: [PATCH 07/58] update: make aead_xchacha20_poly1305_rtpsize the default encryption After some researching and testing, aead_xchacha20_poly1305_rtpsize runs natively on libsodum + has support for encrypt_into apis, that on my machine, gave an 300% speed boost in decrypt/encrypt packets, since it runs on sodium-native or libsodium-wrappers, only by running on these packages makes it faster. Also tested with bun, sodium-native is still faster than it by ~80%. --- config.default.js | 6 +++--- dist/config.default.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config.default.js b/config.default.js index 1f8c891f..dea59d84 100644 --- a/config.default.js +++ b/config.default.js @@ -488,8 +488,8 @@ export default { enabled: true }, tiktok: { - enabled: true, - }, + enabled: true + } }, lyrics: { fallbackSource: 'genius', @@ -532,7 +532,7 @@ export default { }, audio: { quality: 'high', // high, medium, low, lowest - encryption: 'aead_aes256_gcm_rtpsize', + encryption: 'aead_xchacha20_poly1305_rtpsize', // aead_aes256_gcm_rtpsize or aead_xchacha20_poly1305_rtpsize resamplingQuality: 'best', // best, medium, fastest, zero order holder, linear loudnessNormalizer: false, // Enable/disable AGC globally lookaheadMs: 5, // Limiter lookahead buffer in milliseconds diff --git a/dist/config.default.js b/dist/config.default.js index 4e6b3b50..8a79638b 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -488,8 +488,8 @@ export default { enabled: true }, tiktok: { - enabled: true, - }, + enabled: true + } }, lyrics: { fallbackSource: 'genius', @@ -532,7 +532,7 @@ export default { }, audio: { quality: 'high', // high, medium, low, lowest - encryption: 'aead_aes256_gcm_rtpsize', + encryption: 'aead_xchacha20_poly1305_rtpsize', // aead_aes256_gcm_rtpsize or aead_xchacha20_poly1305_rtpsize resamplingQuality: 'best', // best, medium, fastest, zero order holder, linear loudnessNormalizer: false, // Enable/disable AGC globally lookaheadMs: 5, // Limiter lookahead buffer in milliseconds From e8cc8a50dca67e54a24de8da5c96dfece766227c Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Wed, 6 May 2026 18:29:25 -0300 Subject: [PATCH 08/58] fix: resolve 11 incompatibilities between player and performanc/voice Fixed fadeTo/tapeTo/setFadeVolume silently no-oping on connection.audioStream (missing from _assignStream proxy) Fixed 'audioStream' event listener being dead code (voice lib never emits it) Fixed 'reconnecting' connection status never firing (voice lib uses connecting state, not reconnecting) Fixed pause detection broken (voice lib uses idle+reason='paused', not status='paused') Fixed double-destroy race in _cleanupCurrentAudioStream Fixed stuck detection conflict (set stuckTimeout above player threshold) Fixed force=true reconnect being no-op when token+endpoint unchanged Fixed ping -1 not caught by nullish coalescing Implement setLoudnessNormalizer runtime toggle via VolumeTransformer.setAGCEnabled Handle circuit breaker disconnect reason Update type declarations to match actual voice lib behavior Remove 'seamless_bridge' from playing reason check, add 'unpaused' Formmated the entire code with biome. --- dist/src/index.js | 4 +- dist/src/playback/player.js | 101 ++++++--- .../src/playback/processing/FlowController.js | 6 + .../playback/processing/VolumeTransformer.js | 15 ++ .../playback/processing/streamProcessor.js | 19 ++ dist/src/utils.js | 6 +- .../sessions.id.players.id.sponsorblock.ts | 101 +++++++-- src/index.ts | 11 +- src/managers/playerManager.ts | 8 +- src/managers/sessionManager.ts | 6 +- src/playback/player.ts | 193 +++++++++++++----- src/playback/processing/FlowController.ts | 9 + src/playback/processing/VolumeTransformer.ts | 13 ++ src/playback/processing/streamProcessor.ts | 47 ++++- src/typings/performanc.d.ts | 48 +++-- src/typings/playback/player.types.ts | 10 +- src/typings/playback/processing.types.ts | 1 + src/utils.ts | 53 +++-- src/workers/main.ts | 7 +- 19 files changed, 505 insertions(+), 153 deletions(-) diff --git a/dist/src/index.js b/dist/src/index.js index 720b2751..ac64df35 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -26,7 +26,9 @@ import { parseVoiceFrameHeader } from "./voice/voiceFrames.js"; import { createVoiceRelay } from "./voice/voiceRelay.js"; let requestHandlerPromise = null; let profilerApiPromise = null; -const isRuntimeAtLeast = (current, minimum) => current.replace(/^v/, '').localeCompare(minimum.replace(/^v/, ''), undefined, { +const isRuntimeAtLeast = (current, minimum) => current + .replace(/^v/, '') + .localeCompare(minimum.replace(/^v/, ''), undefined, { numeric: true }) >= 0; const NODE_LTS_CREDENTIAL_KEY = 'runtime.node.latestLts'; diff --git a/dist/src/playback/player.js b/dist/src/playback/player.js index 813c3860..7692e671 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -87,6 +87,7 @@ export class Player { _isStopping = false; _pausedAtPosition = undefined; stuckRecoveryCount = 0; + _positionAtRecoveryStart = 0; static MAX_STUCK_RECOVERY_ATTEMPTS = 3; constructor(options) { if (!options.nodelink || @@ -228,6 +229,8 @@ export class Player { channelId: this.voice.channelId || this.guildId, encryption: this.nodelink.options?.audio?.encryption ?? null }); + this.connection.stuckTimeout = + Math.max(this.nodelink.options.trackStuckThresholdMs, 30000) + 5000; this.connection.on('stateChange', (_, s) => { logger('debug', 'Player', `Voice connection state change for guild ${this.guildId} in session ${this.session.id}: ${s.status}`); this._onConn(s); @@ -237,20 +240,10 @@ export class Player { logger('error', 'Player', `Voice connection error for guild ${this.guildId} in session ${this.session.id}:`, err); this._onError(err); }); - this.connection.on('audioStream', (audioStream) => { - const dataHandler = () => { - this._lastStreamDataTime = Date.now(); - if (this.isLyricsSubscribed && !this.isPaused && this.track) { - this._syncLyrics(); - } - }; - audioStream.on('data', dataHandler); - audioStream.once('close', () => { - audioStream.off('data', dataHandler); - }); - audioStream.once('end', () => { - audioStream.off('data', dataHandler); - }); + this.connection.on('stuck', () => { + if (this.destroying) + return; + logger('warn', 'Player', `Voice library detected stuck stream for guild ${this.guildId}`); }); if (this.nodelink.voiceRelay?.attach) { this.nodelink.voiceRelay.attach(this.connection, this.guildId); @@ -262,6 +255,7 @@ export class Player { _onConn(state) { if (this.destroying) return; + const previousStatus = this.connStatus; this.connStatus = state.status; if (state.status === 'connected') { logger('info', 'Player', `Voice connection established for guild ${this.guildId} in session ${this.session.id}`); @@ -275,17 +269,31 @@ export class Player { logger('debug', 'Player', `Unpaused track on reconnection for guild ${this.guildId}`); } } - else if (state.status === 'reconnecting') { - logger('info', 'Player', `Voice connection is reconnecting for guild ${this.guildId}`); - this.emitEvent(GatewayEvents.PLAYER_RECONNECTING, { - guildId: this.guildId, - voice: { ...this.voice } - }); + else if (state.status === 'connecting') { + if (previousStatus !== 'disconnected' || this.connection?.audioStream) { + logger('info', 'Player', `Voice connection is reconnecting for guild ${this.guildId}`); + this.emitEvent(GatewayEvents.PLAYER_RECONNECTING, { + guildId: this.guildId, + voice: { ...this.voice } + }); + } } else if (state.status === 'disconnected') { + const reason = state.reason; + if (reason === 'reconnect_circuit_breaker') { + logger('error', 'Player', `Voice connection circuit breaker triggered for guild ${this.guildId}. Too many reconnection attempts.`); + this.emitEvent(GatewayEvents.TRACK_EXCEPTION, { + track: this.track, + exception: { + message: 'Voice reconnection circuit breaker triggered', + severity: 'fault', + cause: 'RECONNECT_CIRCUIT_BREAKER' + } + }); + } this.emitEvent(GatewayEvents.WEBSOCKET_CLOSED, { code: state.code, - reason: state.closeReason, + reason: state.closeReason ?? state.reason, byRemote: true }); } @@ -368,12 +376,13 @@ export class Player { else if (state.status === 'playing' && this.track && !this._isSeeking && - (['requested', 'reconnected', 'seamless_bridge'].includes(state.reason ?? '') || + (['requested', 'reconnected', 'unpaused'].includes(state.reason ?? '') || this._pendingTrackStartFade)) { const wasResuming = this._isResuming; this._isResuming = false; this.isPaused = false; - if (wasResuming && state.reason !== 'seamless_bridge') { + this._lastStreamDataTime = Date.now(); + if (wasResuming) { this._fading('trackEndSchedule', { startPosition: this._pausedAtPosition ?? this._realPosition() }); @@ -386,9 +395,16 @@ export class Player { this._emitTrackStart().catch((err) => this._onError(err)); } } - else if (state.status === 'paused') { + else if (state.status === 'idle' && state.reason === 'paused') { this.isPaused = true; } + else if (state.status === 'idle' && state.reason === 'reconnecting') { + logger('info', 'Player', `Voice library reports reconnecting for guild ${this.guildId}`); + this.emitEvent(GatewayEvents.PLAYER_RECONNECTING, { + guildId: this.guildId, + voice: { ...this.voice } + }); + } } /** * Handles playback errors and emits exception events. @@ -502,14 +518,17 @@ export class Player { catch { // Ignore cleanup errors } + if (audioStream.destroyed) { + if (conn) + conn.audioStream = null; + return; + } try { audioStream.destroy?.(); } catch (err) { logger('debug', 'Player', `Failed to destroy audio stream during ${context} for guild ${this.guildId}: ${err?.message ?? String(err)}`); } - if (conn) - conn.audioStream = null; } /** * Optionally runs forced GC after finish for leak diagnostics. @@ -743,7 +762,8 @@ export class Player { const position = this._realPosition(); if (this.sponsorBlock.enabled && this.track) { // Periodic log to verify position and sb state - if (Math.abs(position - this._lastPosition) > 1000 || this._lastPosition === 0) { + if (Math.abs(position - this._lastPosition) > 1000 || + this._lastPosition === 0) { logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Current position: ${Math.round(position)}ms, Segments: ${this.sponsorBlock.segments.length}, LastSkipped: ${this.sponsorBlock.lastSkippedUuid}`); } } @@ -803,8 +823,7 @@ export class Player { this._resetTrack(); return false; } - if (this.stuckRecoveryCount >= - Player.MAX_STUCK_RECOVERY_ATTEMPTS) { + if (this.stuckRecoveryCount >= Player.MAX_STUCK_RECOVERY_ATTEMPTS) { logger('error', 'Player', `Player for guild ${this.guildId} exceeded max recovery attempts (${Player.MAX_STUCK_RECOVERY_ATTEMPTS}). Stopping track.`); this.emitEvent(GatewayEvents.TRACK_STUCK, { guildId: this.guildId, @@ -828,6 +847,7 @@ export class Player { }); this._isRecovering = true; this.stuckRecoveryCount++; + this._positionAtRecoveryStart = position; this.seek(this._lastPosition, this.track.endTime, true) .then((success) => { if (success) { @@ -865,8 +885,12 @@ export class Player { } if (position !== this._lastPosition) { this._lastStreamDataTime = Date.now(); - if (this.stuckRecoveryCount > 0) - this.stuckRecoveryCount = 0; + if (this.stuckRecoveryCount > 0) { + const meaningfulAdvance = 2000; + if (position - this._positionAtRecoveryStart >= meaningfulAdvance) { + this.stuckRecoveryCount = 0; + } + } } this._lastPosition = position; this._syncLyrics(); @@ -913,7 +937,9 @@ export class Player { time: Date.now(), position, connected: this.connStatus === 'connected', - ping: this.connection.ping ?? 0 + ping: this.connection && this.connection.ping >= 0 + ? this.connection.ping + : 0 } })); return true; @@ -989,7 +1015,9 @@ export class Player { const { fetchSponsorBlockSegments } = await import("../utils.js"); fetchSponsorBlockSegments(videoId, this.sponsorBlock.categories, this.sponsorBlock.actionTypes, sbConfig?.api) .then((segments) => { - if (this.destroying || !this.track || this.track.info.identifier !== videoId) { + if (this.destroying || + !this.track || + this.track.info.identifier !== videoId) { logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Ignoring fetched segments for ${videoId} (track changed or player destroyed)`); return; } @@ -1720,6 +1748,9 @@ export class Player { this.connection.channelId = this.voice.channelId; } this.connection?.voiceStateUpdate({ session_id: this.voice.sessionId }); + if (force && this.connection?.voiceServer) { + this.connection.voiceServer = null; + } this.connection?.voiceServerUpdate({ token: this.voice.token, endpoint: this.voice.endpoint @@ -2092,7 +2123,9 @@ export class Player { time: Date.now(), position: this._realPosition(), connected: this.connStatus === 'connected', - ping: this.connection?.ping ?? 0 + ping: this.connection && this.connection.ping >= 0 + ? this.connection.ping + : 0 }, voice: { ...this.voice } }; diff --git a/dist/src/playback/processing/FlowController.js b/dist/src/playback/processing/FlowController.js index 9c2430fa..baa8e488 100644 --- a/dist/src/playback/processing/FlowController.js +++ b/dist/src/playback/processing/FlowController.js @@ -100,6 +100,12 @@ export class FlowController extends Transform { checkScratchEffectCompleted() { return this.scratch.checkEffectCompleted(); } + setLoudnessNormalizer(enabled) { + if ('setAGCEnabled' in this.volume && + typeof this.volume.setAGCEnabled === 'function') { + this.volume.setAGCEnabled(enabled); + } + } /** * Updates filters in the pipeline via the FlowController. * Note: FlowController currently doesn't manage filters itself, diff --git a/dist/src/playback/processing/VolumeTransformer.js b/dist/src/playback/processing/VolumeTransformer.js index 422e321f..512a038a 100644 --- a/dist/src/playback/processing/VolumeTransformer.js +++ b/dist/src/playback/processing/VolumeTransformer.js @@ -82,6 +82,21 @@ export class VolumeTransformer extends Transform { }) : null; } + setAGCEnabled(enabled) { + if (enabled && !this.agc) { + ; + this.agc = + new LoudnessNormalizer({ + sampleRate: this.sampleRate, + channels: this.channels, + targetLoudness: -14 + }); + } + else if (!enabled && this.agc) { + ; + this.agc = null; + } + } _getFadeCurveValue(progress) { const clamped = Math.min(1, Math.max(0, progress)); switch (this.fadeCurve) { diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index 7cb0d37c..5bcf6052 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -392,6 +392,10 @@ class BaseAudioResource { voiceStream.getEffectiveRate = () => this.getEffectiveRate(); voiceStream.getRMS = () => this.getRMS(); voiceStream.isSilent = () => this.isSilent(); + voiceStream.setFadeVolume = (volume) => this.setFadeVolume(volume); + voiceStream.fadeTo = (volume, durationMs, curve) => this.fadeTo(volume, durationMs, curve); + voiceStream.tapeTo = (durationMs, type, curve) => this.tapeTo(durationMs, type, curve); + voiceStream.setLoudnessNormalizer = (enabled) => this.setLoudnessNormalizer(enabled); this.stream = voiceStream; } _end() { @@ -447,6 +451,8 @@ class BaseAudioResource { checkScratchEffectCompleted() { return false; } + tapeTo(_durationMs, _type, _curve) { } + setLoudnessNormalizer(_enabled) { } setVolume(volume) { if (!this.pipes) return; @@ -2055,6 +2061,19 @@ class StreamAudioResource extends BaseAudioResource { flowController.scratchTo(durationMs, style); } } + setLoudnessNormalizer(enabled) { + if (!this.pipes) + return; + const volumeTransformer = this.pipes.find((p) => p instanceof VolumeTransformer); + if (volumeTransformer) { + volumeTransformer.setAGCEnabled(enabled); + return; + } + const flowController = this.pipes.find((p) => p instanceof FlowController); + if (flowController) { + flowController.setLoudnessNormalizer(enabled); + } + } _createPCMOutputPipeline(pcmStream, volume, enableAGC = true) { if (volume !== 1.0 || enableAGC) { const volumeTransformer = new VolumeTransformer({ diff --git a/dist/src/utils.js b/dist/src/utils.js index c0f2333a..57bdff23 100644 --- a/dist/src/utils.js +++ b/dist/src/utils.js @@ -11,7 +11,7 @@ import util from 'node:util'; import zlib from 'node:zlib'; import packageJson from '../package.json' with { type: 'json' }; import { DEFAULT_MAX_REDIRECTS, DISCORD_ID_REGEX, REDIRECT_STATUS_CODES, SEMVER_PATTERN } from "./constants.js"; -const isBun = typeof process !== "undefined" && process.versions?.bun; +const isBun = typeof process !== 'undefined' && process.versions?.bun; /** * Reference to the runtime NodeLink instance stored on the global object. * @@ -1365,7 +1365,9 @@ async function makeRequest(urlString, options, nodelink) { // Note: bun v1.3.12, crashes with "authority" argument must be a type of string, object or URL. received type Number (825110816) // Crashes the source worker ^^, could be related to monochrome's request or anything else that uses http/2 // UPDATE: Bun v1.3.13 has fixed this crash, since it was released today as this commit, i will be checking the version but can be removed later. - if (isBun && process.versions.bun.localeCompare('1.3.13', undefined, { numeric: true }) < 0) { + if (isBun && + process.versions.bun.localeCompare('1.3.13', undefined, { numeric: true }) < + 0) { return http1makeRequest(urlString, options); } if (options.proxy) { diff --git a/src/api/sessions.id.players.id.sponsorblock.ts b/src/api/sessions.id.players.id.sponsorblock.ts index 90ce6947..fbc3f241 100644 --- a/src/api/sessions.id.players.id.sponsorblock.ts +++ b/src/api/sessions.id.players.id.sponsorblock.ts @@ -26,7 +26,9 @@ interface SponsorBlockPlayerManager { */ updateSponsorBlock: ( guildId: string, - updates: Partial> + updates: Partial< + Omit + > ) => void /** @@ -175,13 +177,27 @@ async function handlePatchSponsorBlock( ): Promise { const session = runtime.sessions.get(pathParams.sessionId) if (!session) { - sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '') + sendErrorResponse( + req, + res, + 404, + 'Not Found', + "The provided sessionId doesn't exist.", + req.url || '' + ) return } const body = req.body as Partial if (!body || typeof body !== 'object' || Array.isArray(body)) { - sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid body', req.url || '') + sendErrorResponse( + req, + res, + 400, + 'Bad Request', + 'Invalid body', + req.url || '' + ) return } @@ -192,9 +208,15 @@ async function handlePatchSponsorBlock( actionTypes: body.actionTypes, skipMarginMs: body.skipMarginMs }) - sendResponse(req, res, session.players.getSponsorBlock(pathParams.guildId), 200) + sendResponse( + req, + res, + session.players.getSponsorBlock(pathParams.guildId), + 200 + ) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Player not found' + const errorMessage = + error instanceof Error ? error.message : 'Player not found' sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '') } } @@ -211,21 +233,41 @@ async function handlePostSponsorBlock( ): Promise { const session = runtime.sessions.get(pathParams.sessionId) if (!session) { - sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '') + sendErrorResponse( + req, + res, + 404, + 'Not Found', + "The provided sessionId doesn't exist.", + req.url || '' + ) return } const body = req.body as { segments: SponsorBlockSegment[] } if (!body?.segments || !Array.isArray(body.segments)) { - sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid segments array', req.url || '') + sendErrorResponse( + req, + res, + 400, + 'Bad Request', + 'Invalid segments array', + req.url || '' + ) return } try { session.players.setSponsorBlockSegments(pathParams.guildId, body.segments) - sendResponse(req, res, session.players.getSponsorBlock(pathParams.guildId), 200) + sendResponse( + req, + res, + session.players.getSponsorBlock(pathParams.guildId), + 200 + ) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Player not found' + const errorMessage = + error instanceof Error ? error.message : 'Player not found' sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '') } } @@ -241,7 +283,14 @@ async function handleDeleteSponsorBlock( ): Promise { const session = runtime.sessions.get(pathParams.sessionId) if (!session) { - sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '') + sendErrorResponse( + req, + res, + 404, + 'Not Found', + "The provided sessionId doesn't exist.", + req.url || '' + ) return } @@ -250,7 +299,8 @@ async function handleDeleteSponsorBlock( res.writeHead(204) res.end() } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Player not found' + const errorMessage = + error instanceof Error ? error.message : 'Player not found' sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '') } } @@ -267,13 +317,29 @@ async function handler( ): Promise { const runtime = getSponsorBlockRuntime(nodelink) if (!runtime) { - sendErrorResponse(req, res, 500, 'Internal Server Error', 'SponsorBlock runtime contract is incomplete.', parsedUrl.pathname, true) + sendErrorResponse( + req, + res, + 500, + 'Internal Server Error', + 'SponsorBlock runtime contract is incomplete.', + parsedUrl.pathname, + true + ) return } const pathParams = getPathParams(parsedUrl) if (!pathParams) { - sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid path parameters', parsedUrl.pathname, true) + sendErrorResponse( + req, + res, + 400, + 'Bad Request', + 'Invalid path parameters', + parsedUrl.pathname, + true + ) return } @@ -297,7 +363,14 @@ async function handler( return } - sendErrorResponse(req, res, 405, 'Method Not Allowed', 'Method Not Allowed', parsedUrl.pathname) + sendErrorResponse( + req, + res, + 405, + 'Method Not Allowed', + 'Method Not Allowed', + parsedUrl.pathname + ) } const sponsorBlockRoute: ApiRouteModule = { diff --git a/src/index.ts b/src/index.ts index b39d32ca..00c10fce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,9 +73,11 @@ type ProfilerApiModule = typeof import('./api/profiler.ts') let profilerApiPromise: Promise | null = null const isRuntimeAtLeast = (current: string, minimum: string): boolean => - current.replace(/^v/, '').localeCompare(minimum.replace(/^v/, ''), undefined, { - numeric: true - }) >= 0 + current + .replace(/^v/, '') + .localeCompare(minimum.replace(/^v/, ''), undefined, { + numeric: true + }) >= 0 type NodeLtsCacheEntry = { version: string @@ -85,7 +87,8 @@ type NodeLtsCacheEntry = { const NODE_LTS_CREDENTIAL_KEY = 'runtime.node.latestLts' const NODE_LTS_CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000 const NODE_LTS_MEMORY_TTL_MS = 10 * 60 * 1000 -let latestNodeLtsCache: { value: string | null; expiresAt: number } | null = null +let latestNodeLtsCache: { value: string | null; expiresAt: number } | null = + null const getLatestNodeLtsVersion = async ( credentialManager: CredentialManager | null diff --git a/src/managers/playerManager.ts b/src/managers/playerManager.ts index dc68c6f1..2673c369 100644 --- a/src/managers/playerManager.ts +++ b/src/managers/playerManager.ts @@ -5,11 +5,11 @@ import type { FiltersState, NodeLink as PlaybackNodeLink, Session as PlaybackSession, + PlayerSponsorBlockState, PlayerStateJSON, PlayerTrack, PlayerVoiceState, PlayPayload, - PlayerSponsorBlockState, SponsorBlockSegment } from '../typings/playback/player.types.ts' import { logger } from '../utils.ts' @@ -948,7 +948,11 @@ export default class PlayerManager { guildId: string ): Promise { if (this.isCluster) { - return this.runClusterPlayerCommand(guildId, 'clearSponsorBlock', []) as any + return this.runClusterPlayerCommand( + guildId, + 'clearSponsorBlock', + [] + ) as any } const player = this.getLocalPlayerOrThrow(this.getPlayerKey(guildId)) diff --git a/src/managers/sessionManager.ts b/src/managers/sessionManager.ts index 24a597db..06740a90 100644 --- a/src/managers/sessionManager.ts +++ b/src/managers/sessionManager.ts @@ -130,7 +130,11 @@ export default class SessionManager { public pause(sessionId: string): void { // [feat] session-resuming: guard double-pause and clear stale socket if (this.resumableSessions.has(sessionId)) { - logger('debug', 'SessionManager', `Session ${sessionId} is already paused.`) + logger( + 'debug', + 'SessionManager', + `Session ${sessionId} is already paused.` + ) return } diff --git a/src/playback/player.ts b/src/playback/player.ts index 5b43fa40..fc301773 100644 --- a/src/playback/player.ts +++ b/src/playback/player.ts @@ -150,6 +150,7 @@ export class Player { private _isStopping = false private _pausedAtPosition: number | undefined = undefined public stuckRecoveryCount = 0 + private _positionAtRecoveryStart = 0 private static MAX_STUCK_RECOVERY_ATTEMPTS = 3 constructor(options: PlayerOptions) { @@ -331,6 +332,8 @@ export class Player { channelId: this.voice.channelId || this.guildId, encryption: this.nodelink.options?.audio?.encryption ?? null }) + this.connection.stuckTimeout = + Math.max(this.nodelink.options.trackStuckThresholdMs, 30000) + 5000 this.connection.on( 'stateChange', (_: VoiceConnectionState | null, s: VoiceConnectionState) => { @@ -356,20 +359,13 @@ export class Player { ) this._onError(err) }) - this.connection.on('audioStream', (audioStream: VoiceAudioStream) => { - const dataHandler = () => { - this._lastStreamDataTime = Date.now() - if (this.isLyricsSubscribed && !this.isPaused && this.track) { - this._syncLyrics() - } - } - audioStream.on('data', dataHandler) - audioStream.once('close', () => { - audioStream.off('data', dataHandler) - }) - audioStream.once('end', () => { - audioStream.off('data', dataHandler) - }) + this.connection.on('stuck', () => { + if (this.destroying) return + logger( + 'warn', + 'Player', + `Voice library detected stuck stream for guild ${this.guildId}` + ) }) if (this.nodelink.voiceRelay?.attach) { @@ -382,6 +378,7 @@ export class Player { */ private _onConn(state: VoiceConnectionState): void { if (this.destroying) return + const previousStatus = this.connStatus this.connStatus = state.status if (state.status === 'connected') { logger( @@ -402,20 +399,38 @@ export class Player { `Unpaused track on reconnection for guild ${this.guildId}` ) } - } else if (state.status === 'reconnecting') { - logger( - 'info', - 'Player', - `Voice connection is reconnecting for guild ${this.guildId}` - ) - this.emitEvent(GatewayEvents.PLAYER_RECONNECTING, { - guildId: this.guildId, - voice: { ...this.voice } - }) + } else if (state.status === 'connecting') { + if (previousStatus !== 'disconnected' || this.connection?.audioStream) { + logger( + 'info', + 'Player', + `Voice connection is reconnecting for guild ${this.guildId}` + ) + this.emitEvent(GatewayEvents.PLAYER_RECONNECTING, { + guildId: this.guildId, + voice: { ...this.voice } + }) + } } else if (state.status === 'disconnected') { + const reason = state.reason + if (reason === 'reconnect_circuit_breaker') { + logger( + 'error', + 'Player', + `Voice connection circuit breaker triggered for guild ${this.guildId}. Too many reconnection attempts.` + ) + this.emitEvent(GatewayEvents.TRACK_EXCEPTION, { + track: this.track, + exception: { + message: 'Voice reconnection circuit breaker triggered', + severity: 'fault', + cause: 'RECONNECT_CIRCUIT_BREAKER' + } + }) + } this.emitEvent(GatewayEvents.WEBSOCKET_CLOSED, { code: state.code, - reason: state.closeReason, + reason: state.closeReason ?? state.reason, byRemote: true }) } else if (state.status === 'destroyed') { @@ -550,16 +565,15 @@ export class Player { state.status === 'playing' && this.track && !this._isSeeking && - (['requested', 'reconnected', 'seamless_bridge'].includes( - state.reason ?? '' - ) || + (['requested', 'reconnected', 'unpaused'].includes(state.reason ?? '') || this._pendingTrackStartFade) ) { const wasResuming = this._isResuming this._isResuming = false this.isPaused = false + this._lastStreamDataTime = Date.now() - if (wasResuming && state.reason !== 'seamless_bridge') { + if (wasResuming) { this._fading('trackEndSchedule', { startPosition: this._pausedAtPosition ?? this._realPosition() }) @@ -570,8 +584,18 @@ export class Player { this._fading('trackStart') this._emitTrackStart().catch((err) => this._onError(err)) } - } else if (state.status === 'paused') { + } else if (state.status === 'idle' && state.reason === 'paused') { this.isPaused = true + } else if (state.status === 'idle' && state.reason === 'reconnecting') { + logger( + 'info', + 'Player', + `Voice library reports reconnecting for guild ${this.guildId}` + ) + this.emitEvent(GatewayEvents.PLAYER_RECONNECTING, { + guildId: this.guildId, + voice: { ...this.voice } + }) } } @@ -741,6 +765,11 @@ export class Player { // Ignore cleanup errors } + if (audioStream.destroyed) { + if (conn) conn.audioStream = null + return + } + try { audioStream.destroy?.() } catch (err) { @@ -752,8 +781,6 @@ export class Player { }` ) } - - if (conn) conn.audioStream = null } /** @@ -1086,8 +1113,15 @@ export class Player { if (this.sponsorBlock.enabled && this.track) { // Periodic log to verify position and sb state - if (Math.abs(position - this._lastPosition) > 1000 || this._lastPosition === 0) { - logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Current position: ${Math.round(position)}ms, Segments: ${this.sponsorBlock.segments.length}, LastSkipped: ${this.sponsorBlock.lastSkippedUuid}`) + if ( + Math.abs(position - this._lastPosition) > 1000 || + this._lastPosition === 0 + ) { + logger( + 'debug', + 'Player', + `[SponsorBlock][${this.guildId}] Current position: ${Math.round(position)}ms, Segments: ${this.sponsorBlock.segments.length}, LastSkipped: ${this.sponsorBlock.lastSkippedUuid}` + ) } } @@ -1178,10 +1212,7 @@ export class Player { return false } - if ( - this.stuckRecoveryCount >= - Player.MAX_STUCK_RECOVERY_ATTEMPTS - ) { + if (this.stuckRecoveryCount >= Player.MAX_STUCK_RECOVERY_ATTEMPTS) { logger( 'error', 'Player', @@ -1216,6 +1247,7 @@ export class Player { ) this._isRecovering = true this.stuckRecoveryCount++ + this._positionAtRecoveryStart = position this.seek(this._lastPosition, this.track.endTime, true) .then((success) => { @@ -1263,10 +1295,15 @@ export class Player { } } - if (position !== this._lastPosition) { - this._lastStreamDataTime = Date.now() - if (this.stuckRecoveryCount > 0) this.stuckRecoveryCount = 0 - } + if (position !== this._lastPosition) { + this._lastStreamDataTime = Date.now() + if (this.stuckRecoveryCount > 0) { + const meaningfulAdvance = 2000 + if (position - this._positionAtRecoveryStart >= meaningfulAdvance) { + this.stuckRecoveryCount = 0 + } + } + } this._lastPosition = position this._syncLyrics() @@ -1299,17 +1336,30 @@ export class Player { this.seek(segment.end) .then((success) => { if (success) { - logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Successfully jumped to ${segment.end}ms`) + logger( + 'debug', + 'Player', + `[SponsorBlock][${this.guildId}] Successfully jumped to ${segment.end}ms` + ) this.emitEvent(GatewayEvents.SPONSORBLOCK_SEGMENT_SKIPPED, { segment, skippedMs }) } else { - logger('warn', 'Player', `[SponsorBlock][${this.guildId}] Failed to jump to ${segment.end}ms for segment ${segment.uuid}`) + logger( + 'warn', + 'Player', + `[SponsorBlock][${this.guildId}] Failed to jump to ${segment.end}ms for segment ${segment.uuid}` + ) } }) .catch((err) => { - logger('error', 'Player', `[SponsorBlock][${this.guildId}] Error while seeking to segment end:`, err) + logger( + 'error', + 'Player', + `[SponsorBlock][${this.guildId}] Error while seeking to segment end:`, + err + ) }) return true } @@ -1325,7 +1375,10 @@ export class Player { time: Date.now(), position, connected: this.connStatus === 'connected', - ping: this.connection.ping ?? 0 + ping: + this.connection && this.connection.ping >= 0 + ? this.connection.ping + : 0 } }) ) @@ -1440,7 +1493,11 @@ export class Player { const sbConfig = this.nodelink.options.sponsorblock if (this.sponsorBlock.enabled) { - logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Initiating segment fetch for video ${videoId}`) + logger( + 'debug', + 'Player', + `[SponsorBlock][${this.guildId}] Initiating segment fetch for video ${videoId}` + ) const { fetchSponsorBlockSegments } = await import('../utils.ts') fetchSponsorBlockSegments( videoId, @@ -1449,12 +1506,24 @@ export class Player { sbConfig?.api ) .then((segments) => { - if (this.destroying || !this.track || this.track.info.identifier !== videoId) { - logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Ignoring fetched segments for ${videoId} (track changed or player destroyed)`) + if ( + this.destroying || + !this.track || + this.track.info.identifier !== videoId + ) { + logger( + 'debug', + 'Player', + `[SponsorBlock][${this.guildId}] Ignoring fetched segments for ${videoId} (track changed or player destroyed)` + ) return } this.sponsorBlock.segments = segments - logger('info', 'Player', `[SponsorBlock][${this.guildId}] Applied ${segments.length} segments for video ${videoId}`) + logger( + 'info', + 'Player', + `[SponsorBlock][${this.guildId}] Applied ${segments.length} segments for video ${videoId}` + ) if (segments.length > 0) { this.emitEvent(GatewayEvents.SPONSORBLOCK_SEGMENTS_LOADED, { segments @@ -1465,10 +1534,19 @@ export class Player { } }) .catch((err) => { - logger('error', 'Player', `[SponsorBlock][${this.guildId}] Error fetching segments for ${videoId}:`, err) + logger( + 'error', + 'Player', + `[SponsorBlock][${this.guildId}] Error fetching segments for ${videoId}:`, + err + ) }) } else { - logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Auto-skip disabled, skipping segment fetch for ${videoId}`) + logger( + 'debug', + 'Player', + `[SponsorBlock][${this.guildId}] Auto-skip disabled, skipping segment fetch for ${videoId}` + ) } } @@ -2482,6 +2560,9 @@ export class Player { this.connection.channelId = this.voice.channelId } this.connection?.voiceStateUpdate({ session_id: this.voice.sessionId }) + if (force && this.connection?.voiceServer) { + this.connection.voiceServer = null + } this.connection?.voiceServerUpdate({ token: this.voice.token, endpoint: this.voice.endpoint @@ -2750,7 +2831,8 @@ export class Player { Omit > ): void { - if (updates.enabled !== undefined) this.sponsorBlock.enabled = updates.enabled + if (updates.enabled !== undefined) + this.sponsorBlock.enabled = updates.enabled if (updates.categories !== undefined) this.sponsorBlock.categories = updates.categories if (updates.actionTypes !== undefined) @@ -2956,7 +3038,10 @@ export class Player { time: Date.now(), position: this._realPosition(), connected: this.connStatus === 'connected', - ping: this.connection?.ping ?? 0 + ping: + this.connection && this.connection.ping >= 0 + ? this.connection.ping + : 0 }, voice: { ...this.voice } } diff --git a/src/playback/processing/FlowController.ts b/src/playback/processing/FlowController.ts index 28b77bde..f82396bd 100644 --- a/src/playback/processing/FlowController.ts +++ b/src/playback/processing/FlowController.ts @@ -136,6 +136,15 @@ export class FlowController extends Transform { return this.scratch.checkEffectCompleted() } + public setLoudnessNormalizer(enabled: boolean): void { + if ( + 'setAGCEnabled' in this.volume && + typeof this.volume.setAGCEnabled === 'function' + ) { + this.volume.setAGCEnabled(enabled) + } + } + /** * Updates filters in the pipeline via the FlowController. * Note: FlowController currently doesn't manage filters itself, diff --git a/src/playback/processing/VolumeTransformer.ts b/src/playback/processing/VolumeTransformer.ts index ca4eedc2..e51cbc67 100644 --- a/src/playback/processing/VolumeTransformer.ts +++ b/src/playback/processing/VolumeTransformer.ts @@ -124,6 +124,19 @@ export class VolumeTransformer extends Transform implements IVolumeTransformer { : null } + public setAGCEnabled(enabled: boolean): void { + if (enabled && !this.agc) { + ;(this as unknown as { agc: LoudnessNormalizer | null }).agc = + new LoudnessNormalizer({ + sampleRate: this.sampleRate, + channels: this.channels, + targetLoudness: -14 + }) + } else if (!enabled && this.agc) { + ;(this as unknown as { agc: LoudnessNormalizer | null }).agc = null + } + } + private _getFadeCurveValue(progress: number): number { const clamped = Math.min(1, Math.max(0, progress)) switch (this.fadeCurve) { diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index 7e948bf4..80c13e66 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -603,6 +603,14 @@ class BaseAudioResource { getEffectiveRate?: () => number getRMS?: () => number isSilent?: () => boolean + setFadeVolume?: (volume: number) => void + fadeTo?: (volume: number, durationMs: number, curve?: string) => void + tapeTo?: ( + durationMs: number, + type: 'start' | 'stop', + curve?: string + ) => void + setLoudnessNormalizer?: (enabled: boolean) => void } voiceStream.setVolume = (volume: number) => this.setVolume(volume) voiceStream.setFilters = (filters: FiltersState) => this.setFilters(filters) @@ -616,6 +624,16 @@ class BaseAudioResource { voiceStream.getEffectiveRate = () => this.getEffectiveRate() voiceStream.getRMS = () => this.getRMS() voiceStream.isSilent = () => this.isSilent() + voiceStream.setFadeVolume = (volume: number) => this.setFadeVolume(volume) + voiceStream.fadeTo = (volume: number, durationMs: number, curve?: string) => + this.fadeTo(volume, durationMs, curve) + voiceStream.tapeTo = ( + durationMs: number, + type: 'start' | 'stop', + curve?: string + ) => this.tapeTo(durationMs, type, curve) + voiceStream.setLoudnessNormalizer = (enabled: boolean) => + this.setLoudnessNormalizer(enabled) this.stream = voiceStream } @@ -704,6 +722,10 @@ class BaseAudioResource { return false } + tapeTo(_durationMs: number, _type: 'start' | 'stop', _curve?: string): void {} + + setLoudnessNormalizer(_enabled: boolean): void {} + setVolume(volume: number): void { if (!this.pipes) return @@ -2694,7 +2716,11 @@ class StreamAudioResource extends BaseAudioResource { return flowController?.checkScratchEffectCompleted() ?? false } - tapeTo(durationMs: number, type: 'start' | 'stop', curve?: string): void { + override tapeTo( + durationMs: number, + type: 'start' | 'stop', + curve?: string + ): void { if (!this.pipes) return const flowController = this.pipes.find( @@ -2719,6 +2745,25 @@ class StreamAudioResource extends BaseAudioResource { } } + override setLoudnessNormalizer(enabled: boolean): void { + if (!this.pipes) return + + const volumeTransformer = this.pipes.find( + (p) => p instanceof VolumeTransformer + ) as VolumeTransformer | undefined + if (volumeTransformer) { + volumeTransformer.setAGCEnabled(enabled) + return + } + + const flowController = this.pipes.find( + (p) => p instanceof FlowController + ) as FlowController | undefined + if (flowController) { + flowController.setLoudnessNormalizer(enabled) + } + } + _createPCMOutputPipeline( pcmStream: Transform, volume: number, diff --git a/src/typings/performanc.d.ts b/src/typings/performanc.d.ts index f6798a0e..e2c16319 100644 --- a/src/typings/performanc.d.ts +++ b/src/typings/performanc.d.ts @@ -104,27 +104,28 @@ declare module '@performanc/voice' { import type { Readable } from 'node:stream' export interface VoiceConnectionState { - status: - | 'connecting' - | 'connected' - | 'disconnected' - | 'destroyed' - | 'reconnecting' + status: 'connecting' | 'connected' | 'disconnected' | 'destroyed' + reason?: string code?: number closeReason?: string } export interface VoicePlayerState { - status: 'idle' | 'playing' | 'paused' + status: 'idle' | 'playing' reason?: string } export interface VoiceStatistics { packetsExpected: number + packetsSent: number + packetsLost: number [key: string]: number | undefined } export interface VoiceUdpInfo { + ssrc?: number + ip?: string + port?: number secretKey?: Uint8Array | Buffer } @@ -135,20 +136,27 @@ declare module '@performanc/voice' { setFilters(filters: unknown): void setFadeVolume?(volume: number): void fadeTo?(volume: number, durationMs: number, curve?: string): void + tapeTo?(durationMs: number, type: 'start' | 'stop', curve?: string): void + scratchTo?(durationMs: number, style: string): void + checkTapeRampCompleted?(): boolean + checkScratchEffectCompleted?(): boolean + getEffectiveRate?(): number setLoudnessNormalizer?(enabled: boolean): void + canStop?: boolean destroy(): void - pause?(reason?: string): void - unpause?(reason?: string): void - stop?(reason?: string): void read?(): unknown } export interface VoiceConnection extends EventEmitter { + guildId: string + userId: string channelId?: string | null - udpInfo?: VoiceUdpInfo + udpInfo?: VoiceUdpInfo | null statistics?: VoiceStatistics - ping?: number + ping: number audioStream?: VoiceAudioStream | null + voiceServer: { token: string; endpoint: string } | null + stuckTimeout: number on( event: 'stateChange', @@ -165,7 +173,7 @@ declare module '@performanc/voice' { ) => void ): this on(event: 'error', listener: (error: Error) => void): this - on(event: 'audioStream', listener: (stream: VoiceAudioStream) => void): this + on(event: 'stuck', listener: () => void): this on( event: 'speakStart', listener: (userId: string, ssrc: number) => void @@ -178,18 +186,22 @@ declare module '@performanc/voice' { play(resource: unknown): VoiceAudioStream | null | undefined stop(reason?: string): void - pause?(reason?: string): void - unpause?(reason?: string): void + pause(reason?: string): void + unpause(reason?: string): void destroy(): void voiceStateUpdate(state: { session_id: string }): void - voiceServerUpdate(update: { token: string; endpoint: string }): void - connect(callback?: () => void): void + voiceServerUpdate(update: { + token: string + endpoint: string + channel_id?: string + }): void + connect(callback?: () => void, reconnection?: boolean): void } export interface JoinVoiceChannelOptions { guildId: string userId: string - channelId: string + channelId?: string | null encryption?: string | null } diff --git a/src/typings/playback/player.types.ts b/src/typings/playback/player.types.ts index 2dc3219d..32bd2e8b 100644 --- a/src/typings/playback/player.types.ts +++ b/src/typings/playback/player.types.ts @@ -132,18 +132,10 @@ export interface AudioResource { style: import('./processing.types.ts').ScratchStyle ): void checkScratchEffectCompleted?(): boolean - /** - * Reports the current effective playback rate (combining all filters and effects). - */ getEffectiveRate?: () => number - /** - * Returns the current RMS level of the audio stream. - */ getRMS?: () => number - /** - * Returns true if the audio stream is currently silent. - */ isSilent?: () => boolean + setLoudnessNormalizer?(enabled: boolean): void destroy(): void stream?: VoiceAudioStream | null } diff --git a/src/typings/playback/processing.types.ts b/src/typings/playback/processing.types.ts index a0ab807b..2d1e4229 100644 --- a/src/typings/playback/processing.types.ts +++ b/src/typings/playback/processing.types.ts @@ -50,6 +50,7 @@ export interface IVolumeTransformer extends Transform { readonly channels: number setVolume(volume: number): void process(chunk: Buffer): Buffer + setAGCEnabled?(enabled: boolean): void } /** diff --git a/src/utils.ts b/src/utils.ts index 79ce29b7..ea494fef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -43,7 +43,7 @@ import type { declare const __BUILD_GIT_INFO__: GitInfo | undefined -const isBun = typeof process !== "undefined" && process.versions?.bun; +const isBun = typeof process !== 'undefined' && process.versions?.bun /** * Reference to the runtime NodeLink instance stored on the global object. @@ -1679,8 +1679,12 @@ async function makeRequest( // Note: bun v1.3.12, crashes with "authority" argument must be a type of string, object or URL. received type Number (825110816) // Crashes the source worker ^^, could be related to monochrome's request or anything else that uses http/2 // UPDATE: Bun v1.3.13 has fixed this crash, since it was released today as this commit, i will be checking the version but can be removed later. - if (isBun && process.versions.bun.localeCompare('1.3.13', undefined, { numeric: true }) < 0) { - return http1makeRequest(urlString, options); + if ( + isBun && + process.versions.bun.localeCompare('1.3.13', undefined, { numeric: true }) < + 0 + ) { + return http1makeRequest(urlString, options) } if (options.proxy) { @@ -2313,7 +2317,11 @@ async function fetchSponsorBlockSegments( const url = `${apiBase.replace(/\/+$/, '')}/api/skipSegments/${prefix}?${params.toString()}` - logger('debug', 'SponsorBlock', `Fetching segments for video ${videoId} (prefix: ${prefix}) from ${url}`) + logger( + 'debug', + 'SponsorBlock', + `Fetching segments for video ${videoId} (prefix: ${prefix}) from ${url}` + ) try { const startTime = Date.now() @@ -2321,25 +2329,41 @@ async function fetchSponsorBlockSegments( const duration = Date.now() - startTime if (result.statusCode !== 200) { - logger('warn', 'SponsorBlock', `API returned status ${result.statusCode} for video ${videoId} after ${duration}ms`) + logger( + 'warn', + 'SponsorBlock', + `API returned status ${result.statusCode} for video ${videoId} after ${duration}ms` + ) return [] } if (!Array.isArray(result.body)) { - logger('debug', 'SponsorBlock', `No segments found for prefix ${prefix} (Status: ${result.statusCode}, Duration: ${duration}ms)`) + logger( + 'debug', + 'SponsorBlock', + `No segments found for prefix ${prefix} (Status: ${result.statusCode}, Duration: ${duration}ms)` + ) return [] } - const videoMatch = (result.body as Array<{ videoID: string; segments: any[] }>).find( - (entry) => entry.videoID === videoId - ) + const videoMatch = ( + result.body as Array<{ videoID: string; segments: any[] }> + ).find((entry) => entry.videoID === videoId) if (!videoMatch?.segments) { - logger('debug', 'SponsorBlock', `No exact match for video ${videoId} in prefix results (Results: ${result.body.length}, Duration: ${duration}ms)`) + logger( + 'debug', + 'SponsorBlock', + `No exact match for video ${videoId} in prefix results (Results: ${result.body.length}, Duration: ${duration}ms)` + ) return [] } - logger('debug', 'SponsorBlock', `Successfully loaded ${videoMatch.segments.length} segments for video ${videoId} in ${duration}ms`) + logger( + 'debug', + 'SponsorBlock', + `Successfully loaded ${videoMatch.segments.length} segments for video ${videoId} in ${duration}ms` + ) return videoMatch.segments.map((s) => ({ uuid: s.UUID, @@ -2353,7 +2377,12 @@ async function fetchSponsorBlockSegments( description: s.description || '' })) } catch (error) { - logger('warn', 'SponsorBlock', `Failed to fetch segments for video ${videoId}:`, error) + logger( + 'warn', + 'SponsorBlock', + `Failed to fetch segments for video ${videoId}:`, + error + ) return [] } } diff --git a/src/workers/main.ts b/src/workers/main.ts index c0473883..09a944d9 100644 --- a/src/workers/main.ts +++ b/src/workers/main.ts @@ -47,7 +47,12 @@ import type { WorkerNodeLink, WorkerPlayer } from '../typings/workers/worker.types.ts' -import { applyEnvOverrides, cleanupHttpAgents, initLogger, logger } from '../utils.ts' +import { + applyEnvOverrides, + cleanupHttpAgents, + initLogger, + logger +} from '../utils.ts' import { createVoiceRelay } from '../voice/voiceRelay.ts' import { createHeadQueue, From af7b09247c20621ebfe4b810c7e99921129e31f5 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Wed, 6 May 2026 18:38:48 -0300 Subject: [PATCH 09/58] improve: replace ring buffer with leftover strategy in opus encoder Replaced the circular ring buffer (RING_SIZE, writePos/readPos tracking, swap buffer for wrap-around copies) with a straightforward leftover strategy. Incoming chunks are concatenated with any leftover bytes using bufferPool, full frames are encoded in a single pass, and remaining bytes are saved for the next transform call. This reduces memory overhead (no fixed 512KB ring allocation), eliminates wrap-around complexity, and properly releases pooled buffers on error and flush. After testing this for 2d, managed to get a 97% buffer reuse, which alones gets an ~200/400mb saving in memory. --- dist/src/playback/opus/Opus.js | 111 +++++++++++++-------------------- src/playback/opus/Opus.ts | 109 +++++++++++++------------------- 2 files changed, 85 insertions(+), 135 deletions(-) diff --git a/dist/src/playback/opus/Opus.js b/dist/src/playback/opus/Opus.js index 5969403c..b6ae469f 100644 --- a/dist/src/playback/opus/Opus.js +++ b/dist/src/playback/opus/Opus.js @@ -8,14 +8,6 @@ const OPUS_CTL = { PLP: 4014, DTX: 4016 }; -const parsePositiveIntEnv = (key, fallback) => { - const raw = process.env[key]; - if (!raw) - return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -}; -const RING_SIZE = parsePositiveIntEnv('NODELINK_OPUS_ENCODER_RING_BYTES', 512 * 1024); let ACTIVE_LIB = null; const _getLib = () => { if (ACTIVE_LIB) @@ -76,10 +68,7 @@ export class Encoder extends Transform { lib; frameSize; frameBytes; - ring; - swap; - writePos; - readPos; + leftover; constructor({ rate = 48000, channels = 2, frameSize = 960, application = 'audio' } = {}) { super({ readableObjectMode: true }); const { instance, lib } = _createInstance(rate, channels, application); @@ -87,75 +76,65 @@ export class Encoder extends Transform { this.lib = lib; this.frameSize = frameSize; this.frameBytes = frameSize * channels * 2; - this.ring = bufferPool.acquire(RING_SIZE); - this.swap = bufferPool.acquire(this.frameBytes); - this.writePos = 0; - this.readPos = 0; + this.leftover = null; } _transform(chunk, _encoding, cb) { if (!chunk?.length) { cb(); return; } - if (!this.ring || !this.swap) { + if (!this.enc) { cb(new Error('Encoder destroyed.')); return; } - let wp = this.writePos; - let rp = this.readPos; - const total = chunk.length; - let remaining = total; - while (remaining > 0) { - const space = RING_SIZE - wp; - const canWrite = remaining < space ? remaining : space; - chunk.copy(this.ring, wp, total - remaining, total - remaining + canWrite); - remaining -= canWrite; - wp += canWrite; - if (wp === RING_SIZE) - wp = 0; + let buf; + let pooledBuf = null; + if (this.leftover?.length) { + const totalLen = this.leftover.length + chunk.length; + pooledBuf = bufferPool.acquire(totalLen); + this.leftover.copy(pooledBuf, 0); + chunk.copy(pooledBuf, this.leftover.length); + bufferPool.release(this.leftover); + this.leftover = null; + buf = pooledBuf; } - while (true) { - const available = wp >= rp ? wp - rp : RING_SIZE - rp + wp; - if (available < this.frameBytes) - break; - let frame; - const end = rp + this.frameBytes; - if (end <= RING_SIZE) { - frame = this.ring.subarray(rp, end); - } - else { - const first = RING_SIZE - rp; - this.ring.copy(this.swap, 0, rp, RING_SIZE); - this.ring.copy(this.swap, first, 0, this.frameBytes - first); - frame = this.swap.subarray(0, this.frameBytes); - } + else { + buf = chunk; + } + const frames = Math.floor(buf.length / this.frameBytes); + const consumed = frames * this.frameBytes; + for (let i = 0; i < frames; i++) { + const off = i * this.frameBytes; + const frame = buf.subarray(off, off + this.frameBytes); try { - if (!this.enc) - throw new Error('Encoder not ready.'); - if (this.lib.name === 'opusscript') { - this.push(this.enc.encode(frame, this.frameSize)); - } - else { - this.push(this.enc.encode(frame)); - } + const encoded = this.lib.name === 'opusscript' + ? this.enc.encode(frame, this.frameSize) + : this.enc.encode(frame); + this.push(encoded); } catch (e) { - this.writePos = wp; - this.readPos = rp; + this.leftover = null; + if (pooledBuf) + bufferPool.release(pooledBuf); cb(e instanceof Error ? e : new Error(String(e))); return; } - rp += this.frameBytes; - if (rp >= RING_SIZE) - rp -= RING_SIZE; } - this.writePos = wp; - this.readPos = rp; + if (consumed < buf.length) { + const remaining = buf.subarray(consumed); + this.leftover = bufferPool.acquire(remaining.length); + remaining.copy(this.leftover, 0, 0, remaining.length); + } + // Release the temporary concatenation buffer back to the pool + if (pooledBuf) + bufferPool.release(pooledBuf); cb(); } _flush(cb) { - this.writePos = 0; - this.readPos = 0; + if (this.leftover) { + bufferPool.release(this.leftover); + this.leftover = null; + } cb(); } _destroy(err, cb) { @@ -163,13 +142,9 @@ export class Encoder extends Transform { this.enc.delete(); } this.enc = null; - if (this.ring) { - bufferPool.release(this.ring); - this.ring = null; - } - if (this.swap) { - bufferPool.release(this.swap); - this.swap = null; + if (this.leftover) { + bufferPool.release(this.leftover); + this.leftover = null; } cb(err); } diff --git a/src/playback/opus/Opus.ts b/src/playback/opus/Opus.ts index 3de6aee9..3ddd330b 100644 --- a/src/playback/opus/Opus.ts +++ b/src/playback/opus/Opus.ts @@ -19,17 +19,6 @@ const OPUS_CTL = { DTX: 4016 } -const parsePositiveIntEnv = (key: string, fallback: number): number => { - const raw = process.env[key] - if (!raw) return fallback - const parsed = Number.parseInt(raw, 10) - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback -} - -const RING_SIZE = parsePositiveIntEnv( - 'NODELINK_OPUS_ENCODER_RING_BYTES', - 512 * 1024 -) let ACTIVE_LIB: OpusLibrary | null = null const _getLib = (): OpusLibrary => { @@ -109,10 +98,7 @@ export class Encoder extends Transform { private lib: OpusLibrary private frameSize: number private frameBytes: number - private ring: Buffer | null - private swap: Buffer | null - private writePos: number - private readPos: number + private leftover: Buffer | null constructor({ rate = 48000, @@ -132,10 +118,7 @@ export class Encoder extends Transform { this.lib = lib this.frameSize = frameSize this.frameBytes = frameSize * channels * 2 - this.ring = bufferPool.acquire(RING_SIZE) - this.swap = bufferPool.acquire(this.frameBytes) - this.writePos = 0 - this.readPos = 0 + this.leftover = null } override _transform( @@ -147,68 +130,64 @@ export class Encoder extends Transform { cb() return } - if (!this.ring || !this.swap) { + if (!this.enc) { cb(new Error('Encoder destroyed.')) return } - let wp = this.writePos - let rp = this.readPos - const total = chunk.length - let remaining = total - - while (remaining > 0) { - const space = RING_SIZE - wp - const canWrite = remaining < space ? remaining : space - chunk.copy(this.ring, wp, total - remaining, total - remaining + canWrite) - remaining -= canWrite - wp += canWrite - if (wp === RING_SIZE) wp = 0 + let buf: Buffer + let pooledBuf: Buffer | null = null + + if (this.leftover?.length) { + const totalLen = this.leftover.length + chunk.length + pooledBuf = bufferPool.acquire(totalLen) + this.leftover.copy(pooledBuf, 0) + chunk.copy(pooledBuf, this.leftover.length) + bufferPool.release(this.leftover) + this.leftover = null + buf = pooledBuf + } else { + buf = chunk } - while (true) { - const available = wp >= rp ? wp - rp : RING_SIZE - rp + wp - if (available < this.frameBytes) break + const frames = Math.floor(buf.length / this.frameBytes) + const consumed = frames * this.frameBytes - let frame: Buffer - const end = rp + this.frameBytes - - if (end <= RING_SIZE) { - frame = this.ring.subarray(rp, end) - } else { - const first = RING_SIZE - rp - this.ring.copy(this.swap, 0, rp, RING_SIZE) - this.ring.copy(this.swap, first, 0, this.frameBytes - first) - frame = this.swap.subarray(0, this.frameBytes) - } + for (let i = 0; i < frames; i++) { + const off = i * this.frameBytes + const frame = buf.subarray(off, off + this.frameBytes) try { - if (!this.enc) throw new Error('Encoder not ready.') + const encoded = + this.lib.name === 'opusscript' + ? this.enc.encode(frame, this.frameSize) + : this.enc.encode(frame) - if (this.lib.name === 'opusscript') { - this.push(this.enc.encode(frame, this.frameSize)) - } else { - this.push(this.enc.encode(frame)) - } + this.push(encoded) } catch (e) { - this.writePos = wp - this.readPos = rp + this.leftover = null + if (pooledBuf) bufferPool.release(pooledBuf) cb(e instanceof Error ? e : new Error(String(e))) return } + } - rp += this.frameBytes - if (rp >= RING_SIZE) rp -= RING_SIZE + if (consumed < buf.length) { + const remaining = buf.subarray(consumed) + this.leftover = bufferPool.acquire(remaining.length) + remaining.copy(this.leftover, 0, 0, remaining.length) } - this.writePos = wp - this.readPos = rp + // Release the temporary concatenation buffer back to the pool + if (pooledBuf) bufferPool.release(pooledBuf) cb() } override _flush(cb: (err?: Error) => void): void { - this.writePos = 0 - this.readPos = 0 + if (this.leftover) { + bufferPool.release(this.leftover) + this.leftover = null + } cb() } @@ -217,13 +196,9 @@ export class Encoder extends Transform { this.enc.delete() } this.enc = null - if (this.ring) { - bufferPool.release(this.ring) - this.ring = null - } - if (this.swap) { - bufferPool.release(this.swap) - this.swap = null + if (this.leftover) { + bufferPool.release(this.leftover) + this.leftover = null } cb(err) } From 3cd77575800f36e5c676aa58bbac57cd458fe9e7 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Wed, 6 May 2026 18:45:35 -0300 Subject: [PATCH 10/58] add: node 26 support since its gonna be an LTS in october, and can run TS natively, i have updated the experimental flag and fixed an error on config validator causing it to crash on startup. --- dist/package.json | 4 ++-- dist/src/managers/configValidationManager.js | 2 +- package.json | 4 ++-- src/managers/configValidationManager.ts | 5 ++++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/dist/package.json b/dist/package.json index 8af12a39..5c3eac70 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,10 +1,10 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260501.1", + "version": "3.8.1-dev.20260506.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", - "start:experimental": "node --experimental-transform-types --dns-result-order=ipv4first src/index.ts", + "start:experimental": "node --dns-result-order=ipv4first src/index.ts", "start:dist": "node --dns-result-order=ipv4first dist/src/index.js", "start:bun": "bun run --dns-result-order=ipv4first src/index.ts", "type-check": "tsc --noEmit", diff --git a/dist/src/managers/configValidationManager.js b/dist/src/managers/configValidationManager.js index 01a80231..d20db182 100644 --- a/dist/src/managers/configValidationManager.js +++ b/dist/src/managers/configValidationManager.js @@ -38,8 +38,8 @@ const VALID_ROUTE_STRATEGIES = new Set([ ]); const VALID_METRICS_AUTH_TYPES = new Set(['Bearer', 'Basic']); export default class ConfigValidationManager { - options; warnings = []; + options; constructor(options) { this.options = options; } diff --git a/package.json b/package.json index 3b00d61f..caeb6af8 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260501.1", + "version": "3.8.1-dev.20260506.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", - "start:experimental": "node --experimental-transform-types --dns-result-order=ipv4first src/index.ts", + "start:experimental": "node --dns-result-order=ipv4first src/index.ts", "start:dist": "node --dns-result-order=ipv4first dist/src/index.js", "start:bun": "bun run --dns-result-order=ipv4first src/index.ts", "type-check": "tsc --noEmit", diff --git a/src/managers/configValidationManager.ts b/src/managers/configValidationManager.ts index be5de867..2ffb97ed 100644 --- a/src/managers/configValidationManager.ts +++ b/src/managers/configValidationManager.ts @@ -55,8 +55,11 @@ const VALID_METRICS_AUTH_TYPES = new Set(['Bearer', 'Basic']) export default class ConfigValidationManager { private warnings: ValidationWarning[] = [] + private options: NodelinkConfig - constructor(private options: NodelinkConfig) {} + constructor(options: NodelinkConfig) { + this.options = options + } validate(): void { this.warnings = [] From 82168e35a335224b4ced93d601c16d46df10401a Mon Sep 17 00:00:00 2001 From: UnschooledGamer <76094069+UnschooledGamer@users.noreply.github.com> Date: Fri, 8 May 2026 19:51:06 +0530 Subject: [PATCH 11/58] fix: enhance error handling for HEAD requests in HTTP source --- dist/src/sources/http.js | 3 +++ src/sources/http.ts | 16 ++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/dist/src/sources/http.js b/dist/src/sources/http.js index 5f6933cb..7ee28dae 100644 --- a/dist/src/sources/http.js +++ b/dist/src/sources/http.js @@ -214,6 +214,9 @@ export default class HttpSource { let data = await http1makeRequest(url, { method: 'HEAD', headers: requestHeaders + }).catch((err) => { + logger('warn', 'HTTP Source', `HEAD request failed for URL: ${url} with error: ${err.message} - falling back to GET request (this is expected for some servers that do not support HEAD)`); + return { error: err }; }); const headContentType = headerToString(data.headers?.['content-type']); const headOk = !data.error && diff --git a/src/sources/http.ts b/src/sources/http.ts index 59728f58..0dc4b65e 100644 --- a/src/sources/http.ts +++ b/src/sources/http.ts @@ -15,6 +15,7 @@ import type { WorkerNodeLink } from '../typings/sources/source.types.ts' import type { + HttpRequestResult, HttpResponseHeaders, TrackEncodeInput } from '../typings/utils.types.ts' @@ -260,14 +261,17 @@ export default class HttpSource { let data = await http1makeRequest(url, { method: 'HEAD', headers: requestHeaders - }) + }).catch((err) => { + logger('warn', 'HTTP Source', `HEAD request failed for URL: ${url} with error: ${err.message} - falling back to GET request (this is expected for some servers that do not support HEAD)`); + return { error: err }; + }); const headContentType = headerToString( - (data.headers as Record)?.['content-type'] + ((data as HttpRequestResult).headers as Record)?.['content-type'] ) const headOk = !data.error && - (data.statusCode || 0) < 400 && + ((data as HttpRequestResult).statusCode || 0) < 400 && isValidMediaType(headContentType) if (!headOk) { @@ -290,17 +294,17 @@ export default class HttpSource { } } - if ((data.statusCode || 0) >= 400) { + if (((data as HttpRequestResult).statusCode || 0) >= 400) { return { loadType: 'error', exception: { - message: `HTTP error ${data.statusCode} while resolving`, + message: `HTTP error ${(data as HttpRequestResult).statusCode} while resolving`, severity: 'common' } } } - const headers = data.headers as HttpResponseHeaders | undefined + const headers = (data as HttpRequestResult).headers as HttpResponseHeaders | undefined const contentType = headerToString( (headers as Record)?.['content-type'] ) From d37908e842090e3d8399e6b0d5f92f63d3d6ce22 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 10 May 2026 14:20:10 -0300 Subject: [PATCH 12/58] update: add support for qobuz in monochrome Since tidal does not seem to work now, will be default to qobuz. --- config.default.js | 3 +- dist/config.default.js | 3 +- dist/src/sources/monochrome.js | 148 ++++++++++++++++--- src/sources/monochrome.ts | 183 ++++++++++++++++++++---- src/typings/config/config.types.ts | 6 + src/typings/sources/monochrome.types.ts | 2 + 6 files changed, 293 insertions(+), 52 deletions(-) diff --git a/config.default.js b/config.default.js index dea59d84..da53ad9f 100644 --- a/config.default.js +++ b/config.default.js @@ -482,7 +482,8 @@ export default { enabled: true, instances: [], // (optional) list of API instances streamingInstances: [], // (optional) list of streaming instances - quality: 'HI_RES_LOSSLESS' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW + qobuzInstances: [], // (optional) list of Qobuz API instances + quality: 'HIGH' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW }, googledrive: { enabled: true diff --git a/dist/config.default.js b/dist/config.default.js index 8a79638b..2581b081 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -482,7 +482,8 @@ export default { enabled: true, instances: [], // (optional) list of API instances streamingInstances: [], // (optional) list of streaming instances - quality: 'HI_RES_LOSSLESS' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW + qobuzInstances: [], // (optional) list of Qobuz API instances + quality: 'HIGH' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW }, googledrive: { enabled: true diff --git a/dist/src/sources/monochrome.js b/dist/src/sources/monochrome.js index 47cbde75..8bc01c1b 100644 --- a/dist/src/sources/monochrome.js +++ b/dist/src/sources/monochrome.js @@ -28,6 +28,7 @@ class MonochromeSource { priority = 100; apiInstances = []; streamingInstances = []; + qobuzInstances = []; /** * Initializes the Monochrome source with health-tracked instance pools. * @param nodelink - The worker server context. @@ -51,6 +52,10 @@ class MonochromeSource { 'https://hund.qqdl.site', 'https://api.monochrome.tf' ]; + const defaultQobuzUrls = [ + 'https://qobuz.kennyy.com.br', + 'https://trypt-hifi-dl-456461932686.us-west1.run.app' + ]; const initPool = (urls) => urls.map((url) => ({ url: url.replace(/\/$/, ''), score: 100, @@ -65,8 +70,12 @@ class MonochromeSource { const streamingInstances = this.config.streamingInstances?.length ? this.config.streamingInstances : instances; + const qobuzInstances = this.config.qobuzInstances?.length + ? this.config.qobuzInstances + : defaultQobuzUrls; this.apiInstances = initPool(instances); this.streamingInstances = initPool(streamingInstances); + this.qobuzInstances = initPool(qobuzInstances); this.patterns = [ /^https?:\/\/monochrome\.tf\/(track|album|playlist|artist|video)\/[\w-]+/, /^https?:\/\/(?:www\.)?tidal\.com\/(?:browse\/)?(track|album|playlist|artist|video)\/[\w-]+/ @@ -389,6 +398,15 @@ class MonochromeSource { */ async getTrackUrl(track) { const isVideo = track.uri.includes('/video/'); + if (!isVideo && track.isrc) { + const qobuzUrl = await this.fetchQobuzProxyUrl(track.isrc); + if (qobuzUrl) { + return { + url: qobuzUrl, + protocol: 'https' + }; + } + } const quality = this.config.quality || 'LOSSLESS'; const params = new URLSearchParams({ id: track.identifier, @@ -411,32 +429,28 @@ class MonochromeSource { ? `/video/?${params.toString()}` : `/trackManifests/?${params.toString()}`; const response = await this.fetchWithRetry(endpoint, 'streaming', '2.7'); - if (!response) - return { - exception: { - message: 'Failed to fetch playback manifest', - severity: 'fault' - } - }; - const uri = this.extractStreamUrl(response); - if (!uri) - return { - exception: { - message: 'Failed to extract playable URI from manifest', - severity: 'fault' + if (response) { + const uri = this.extractStreamUrl(response); + if (uri) { + const attr = response?.data?.data?.attributes; + if (attr?.trackAudioNormalizationData) { + logger('debug', 'Monochrome', `Normalization for ${track.identifier}: Gain ${attr.trackAudioNormalizationData.replayGain} dB, Peak ${attr.trackAudioNormalizationData.peakAmplitude}`); } - }; - const attr = response?.data?.data?.attributes; - if (attr?.trackAudioNormalizationData) { - logger('debug', 'Monochrome', `Normalization for ${track.identifier}: Gain ${attr.trackAudioNormalizationData.replayGain} dB, Peak ${attr.trackAudioNormalizationData.peakAmplitude}`); + return { + url: uri, + protocol: uri.includes('.mpd') + ? 'dash' + : uri.includes('.m3u8') + ? 'hls' + : 'http' + }; + } } return { - url: uri, - protocol: uri.includes('.mpd') - ? 'dash' - : uri.includes('.m3u8') - ? 'hls' - : 'http' + exception: { + message: 'Failed to fetch playback manifest', + severity: 'fault' + } }; } /** @@ -597,6 +611,94 @@ class MonochromeSource { return null; } } + /** + * Fetches a direct stream URL from the Qobuz proxy instances. + * Steps: search by ISRC → get track ID → download music. + * @param isrc - Track ISRC code. + * @returns Direct stream URL or null. + * @private + */ + async fetchQobuzProxyUrl(isrc) { + const pool = this.qobuzInstances.filter((i) => i.score > 0); + if (pool.length === 0) { + logger('warn', 'Monochrome', 'No Qobuz proxy instances available.'); + return null; + } + const proxyHeaders = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36', + Accept: '*/*', + 'Accept-Language': 'en;q=0.8', + Origin: 'https://monochrome.tf', + Referer: 'https://monochrome.tf/', + Pragma: 'no-cache', + 'Cache-Control': 'no-cache' + }; + for (const instance of pool) { + instance.activeRequests++; + try { + const searchUrl = `${instance.url}/api/get-music?q=${encodeURIComponent(isrc)}&offset=0`; + const { body: searchBody, statusCode: searchStatus } = await http1makeRequest(searchUrl, { + method: 'GET', + timeout: 5000, + headers: proxyHeaders + }); + if (searchStatus !== 200 || !searchBody) { + instance.score = Math.max(instance.score - 10, 0); + instance.failures++; + instance.lastFailure = Date.now(); + continue; + } + const searchData = typeof searchBody === 'string' + ? JSON.parse(searchBody) + : searchBody; + const tracks = searchData?.data + ?.tracks; + const items = tracks?.items; + const track = items?.[0]; + if (!track) { + instance.score = Math.max(instance.score - 5, 0); + continue; + } + const trackId = track.id; + if (!trackId) { + instance.score = Math.max(instance.score - 5, 0); + continue; + } + const downloadUrl = `${instance.url}/api/download-music?track_id=${trackId}&quality=5`; + const { body: downloadBody, statusCode: downloadStatus } = await http1makeRequest(downloadUrl, { + method: 'GET', + timeout: 5000, + headers: proxyHeaders + }); + if (downloadStatus !== 200 || !downloadBody) { + instance.score = Math.max(instance.score - 10, 0); + instance.failures++; + instance.lastFailure = Date.now(); + continue; + } + const downloadData = typeof downloadBody === 'string' + ? JSON.parse(downloadBody) + : downloadBody; + const url = downloadData?.data + ?.url; + if (url) { + instance.score = Math.min(instance.score + 5, 100); + logger('debug', 'Monochrome', `Qobuz proxy resolved track ${isrc} via ${instance.url}`); + return url; + } + instance.score = Math.max(instance.score - 5, 0); + } + catch (e) { + instance.score = Math.max(instance.score - 20, 0); + instance.lastFailure = Date.now(); + logger('error', 'Monochrome', `Qobuz proxy error for ${isrc} on ${instance.url}: ${e instanceof Error ? e.message : String(e)}`); + } + finally { + instance.activeRequests--; + } + } + return null; + } /** * Normalizes a raw track object into NodeLink's TrackInfo structure. * @param t - Raw Tidal track data. diff --git a/src/sources/monochrome.ts b/src/sources/monochrome.ts index 36bf745c..8f2f4f4f 100644 --- a/src/sources/monochrome.ts +++ b/src/sources/monochrome.ts @@ -48,6 +48,7 @@ class MonochromeSource implements SourceInstance { private apiInstances: InstanceHealth[] = [] private streamingInstances: InstanceHealth[] = [] + private qobuzInstances: InstanceHealth[] = [] /** * Initializes the Monochrome source with health-tracked instance pools. @@ -76,6 +77,11 @@ class MonochromeSource implements SourceInstance { 'https://api.monochrome.tf' ] + const defaultQobuzUrls = [ + 'https://qobuz.kennyy.com.br', + 'https://trypt-hifi-dl-456461932686.us-west1.run.app' + ] + const initPool = (urls: string[]) => urls.map((url) => ({ url: url.replace(/\/$/, ''), @@ -92,9 +98,13 @@ class MonochromeSource implements SourceInstance { const streamingInstances = this.config.streamingInstances?.length ? this.config.streamingInstances : instances + const qobuzInstances = this.config.qobuzInstances?.length + ? this.config.qobuzInstances + : defaultQobuzUrls this.apiInstances = initPool(instances) this.streamingInstances = initPool(streamingInstances) + this.qobuzInstances = initPool(qobuzInstances) this.patterns = [ /^https?:\/\/monochrome\.tf\/(track|album|playlist|artist|video)\/[\w-]+/, @@ -539,6 +549,17 @@ class MonochromeSource implements SourceInstance { */ public async getTrackUrl(track: TrackInfo): Promise { const isVideo = track.uri.includes('/video/') + + if (!isVideo && track.isrc) { + const qobuzUrl = await this.fetchQobuzProxyUrl(track.isrc) + if (qobuzUrl) { + return { + url: qobuzUrl, + protocol: 'https' + } + } + } + const quality = this.config.quality || 'LOSSLESS' const params = new URLSearchParams({ @@ -567,39 +588,33 @@ class MonochromeSource implements SourceInstance { '2.7' ) - if (!response) - return { - exception: { - message: 'Failed to fetch playback manifest', - severity: 'fault' + if (response) { + const uri = this.extractStreamUrl(response) + if (uri) { + const attr = response?.data?.data?.attributes + if (attr?.trackAudioNormalizationData) { + logger( + 'debug', + 'Monochrome', + `Normalization for ${track.identifier}: Gain ${attr.trackAudioNormalizationData.replayGain} dB, Peak ${attr.trackAudioNormalizationData.peakAmplitude}` + ) } - } - - const uri = this.extractStreamUrl(response) - if (!uri) - return { - exception: { - message: 'Failed to extract playable URI from manifest', - severity: 'fault' + return { + url: uri, + protocol: uri.includes('.mpd') + ? 'dash' + : uri.includes('.m3u8') + ? 'hls' + : 'http' } } - - const attr = response?.data?.data?.attributes - if (attr?.trackAudioNormalizationData) { - logger( - 'debug', - 'Monochrome', - `Normalization for ${track.identifier}: Gain ${attr.trackAudioNormalizationData.replayGain} dB, Peak ${attr.trackAudioNormalizationData.peakAmplitude}` - ) } return { - url: uri, - protocol: uri.includes('.mpd') - ? 'dash' - : uri.includes('.m3u8') - ? 'hls' - : 'http' + exception: { + message: 'Failed to fetch playback manifest', + severity: 'fault' + } } } @@ -820,6 +835,120 @@ class MonochromeSource implements SourceInstance { } } + /** + * Fetches a direct stream URL from the Qobuz proxy instances. + * Steps: search by ISRC → get track ID → download music. + * @param isrc - Track ISRC code. + * @returns Direct stream URL or null. + * @private + */ + private async fetchQobuzProxyUrl(isrc: string): Promise { + const pool = this.qobuzInstances.filter((i) => i.score > 0) + if (pool.length === 0) { + logger('warn', 'Monochrome', 'No Qobuz proxy instances available.') + return null + } + + const proxyHeaders = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36', + Accept: '*/*', + 'Accept-Language': 'en;q=0.8', + Origin: 'https://monochrome.tf', + Referer: 'https://monochrome.tf/', + Pragma: 'no-cache', + 'Cache-Control': 'no-cache' + } + + for (const instance of pool) { + instance.activeRequests++ + try { + const searchUrl = `${instance.url}/api/get-music?q=${encodeURIComponent(isrc)}&offset=0` + const { body: searchBody, statusCode: searchStatus } = + await http1makeRequest(searchUrl, { + method: 'GET', + timeout: 5000, + headers: proxyHeaders + }) + + if (searchStatus !== 200 || !searchBody) { + instance.score = Math.max(instance.score - 10, 0) + instance.failures++ + instance.lastFailure = Date.now() + continue + } + + const searchData = + typeof searchBody === 'string' + ? (JSON.parse(searchBody) as Record) + : (searchBody as Record) + + const tracks = (searchData?.data as Record | undefined) + ?.tracks as Record | undefined + const items = tracks?.items as + | Array> + | undefined + const track = items?.[0] + if (!track) { + instance.score = Math.max(instance.score - 5, 0) + continue + } + + const trackId = track.id as number | undefined + if (!trackId) { + instance.score = Math.max(instance.score - 5, 0) + continue + } + + const downloadUrl = `${instance.url}/api/download-music?track_id=${trackId}&quality=5` + const { body: downloadBody, statusCode: downloadStatus } = + await http1makeRequest(downloadUrl, { + method: 'GET', + timeout: 5000, + headers: proxyHeaders + }) + + if (downloadStatus !== 200 || !downloadBody) { + instance.score = Math.max(instance.score - 10, 0) + instance.failures++ + instance.lastFailure = Date.now() + continue + } + + const downloadData = + typeof downloadBody === 'string' + ? (JSON.parse(downloadBody) as Record) + : (downloadBody as Record) + + const url = (downloadData?.data as Record | undefined) + ?.url as string | undefined + if (url) { + instance.score = Math.min(instance.score + 5, 100) + logger( + 'debug', + 'Monochrome', + `Qobuz proxy resolved track ${isrc} via ${instance.url}` + ) + return url + } + + instance.score = Math.max(instance.score - 5, 0) + } catch (e) { + instance.score = Math.max(instance.score - 20, 0) + instance.lastFailure = Date.now() + logger( + 'error', + 'Monochrome', + `Qobuz proxy error for ${isrc} on ${instance.url}: ${e instanceof Error ? e.message : String(e)}` + ) + } finally { + instance.activeRequests-- + } + } + + return null + } + /** * Normalizes a raw track object into NodeLink's TrackInfo structure. * @param t - Raw Tidal track data. diff --git a/src/typings/config/config.types.ts b/src/typings/config/config.types.ts index 4ff12a0b..58112d7e 100644 --- a/src/typings/config/config.types.ts +++ b/src/typings/config/config.types.ts @@ -530,6 +530,12 @@ export interface MonochromeSourceConfig extends SourceConfigBase { */ streamingInstances?: string[] + /** + * List of Qobuz proxy instances used as fallback for stream resolution. + * @remarks These endpoints expose `/api/get-music` and `/api/download-music` to resolve Qobuz tracks. + */ + qobuzInstances?: string[] + /** * Preferred audio quality. * @remarks diff --git a/src/typings/sources/monochrome.types.ts b/src/typings/sources/monochrome.types.ts index 80d7a0b7..b041f73c 100644 --- a/src/typings/sources/monochrome.types.ts +++ b/src/typings/sources/monochrome.types.ts @@ -9,6 +9,8 @@ export interface MonochromeSourceConfig { instances?: string[] /** List of streaming instances used for manifest resolution. */ streamingInstances?: string[] + /** List of Qobuz proxy instances used as fallback for stream resolution. */ + qobuzInstances?: string[] /** Preferred audio quality. */ quality?: 'HI_RES_LOSSLESS' | 'LOSSLESS' | 'HIGH' | 'LOW' } From 127a9742ce7aa86b668b2bfdbf983622115344f9 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 10 May 2026 15:36:58 -0300 Subject: [PATCH 13/58] fix: implement IPC-based worker ID with event socket hello protocol Replaced the fragile NODE_UNIQUE_ID environment variable approach with an explicit two-step identification protocol: Master sends clusterId via IPC on fork Worker confirms identity with HELLO packet (incl. PID) over event socket Changed eventSockets from Set to Map keyed by worker ID. This enables reliable mapping, stale socket cleanup on reconnect, and correct stats attribution Fixes an issue where /v4/workers showed complete stats only for worker ID 1 all workers reported workerId: 1, so their stats overwrote each other, leaving other worker IDs with only the initial { players: 0, playingPlayers: 0 } stub. --- dist/src/managers/workerManager.js | 47 +++++++++++++++++++++------ dist/src/workers/main.js | 31 ++++++++++++++++-- src/managers/workerManager.ts | 52 ++++++++++++++++++++++++------ src/workers/main.ts | 34 ++++++++++++++++--- 4 files changed, 138 insertions(+), 26 deletions(-) diff --git a/dist/src/managers/workerManager.js b/dist/src/managers/workerManager.js index bfbf9cbc..e9766029 100644 --- a/dist/src/managers/workerManager.js +++ b/dist/src/managers/workerManager.js @@ -167,7 +167,7 @@ export default class WorkerManager { this.commandSocketPath = createSocketPath('commands'); this.commandServer = null; this.commandSockets = new Map(); - this.eventSockets = new Set(); + this.eventSockets = new Map(); this.socketRotateInProgress = false; this.lastSocketRotateAt = 0; logger('info', 'Cluster', `Primary PID ${process.pid} - WorkerManager initialized. Min: ${this.minWorkers}, Max: ${this.maxWorkers} workers`); @@ -375,16 +375,38 @@ export default class WorkerManager { } logger('debug', 'Cluster', `Worker ${workerId} failure history updated: ${JSON.stringify(history)}`); } + _registerEventSocket(pid, eventSocket) { + const worker = this.workers.find((w) => w.process.pid === pid); + if (!worker) + return; + const existing = this.eventSockets.get(worker.id); + if (existing && existing !== eventSocket) { + try { + existing.destroy(); + } + catch { } + } + eventSocket._workerId = worker.id; + this.eventSockets.set(worker.id, eventSocket); + } + _removeEventSocket(eventSocket) { + const workerId = eventSocket?._workerId; + if (!workerId) + return; + if (this.eventSockets.get(workerId) === eventSocket) { + this.eventSockets.delete(workerId); + } + } _startSocketServer() { this._safeUnlinkSocketPath(this.socketPath); this.server = net.createServer((socket) => { - this.eventSockets.add(socket); + const eventSocket = socket; const frameChunks = []; let frameBytes = 0; socket.on('error', () => { // Ignore per-connection transport errors (EPIPE/ECONNRESET). }); - socket.on('close', () => this.eventSockets.delete(socket)); + socket.on('close', () => this._removeEventSocket(eventSocket)); const peekBytes = (count) => { const first = frameChunks[0]; if (first && first.length >= count) @@ -459,7 +481,12 @@ export default class WorkerManager { } try { const data = v8.deserialize(payload); - if (type === 3) { + if (type === 0) { + // Event socket hello - register socket to worker by PID + if (isPidPacket(data)) + this._registerEventSocket(data.pid, eventSocket); + } + else if (type === 3) { // playerEvent const nodelink = getGlobalNodelink(); if (nodelink) @@ -469,10 +496,11 @@ export default class WorkerManager { }); } else if (type === 4) { - // workerStats + // workerStats - prefer socket-mapped worker ID over payload workerId if (isWorkerStatsPacket(data)) { - const { workerId, ...stats } = data; - this.statsUpdateBatch.set(workerId, stats); + const { workerId: payloadWorkerId, ...stats } = data; + const resolvedWorkerId = eventSocket._workerId ?? payloadWorkerId; + this.statsUpdateBatch.set(resolvedWorkerId, stats); if (!this.statsUpdateTimer) { this.statsUpdateTimer = setTimeout(() => this._flushStatsUpdates(), 100); } @@ -620,7 +648,7 @@ export default class WorkerManager { const oldEventPath = this.socketPath; const oldCommandPath = this.commandSocketPath; logger('warn', 'Cluster', `Rotating internal sockets after ${reason} (worker ${sourceWorkerId})`); - for (const socket of this.eventSockets) { + for (const socket of this.eventSockets.values()) { try { socket.destroy(); } @@ -865,6 +893,7 @@ export default class WorkerManager { WORKER_TYPE: 'playback' }); worker.workerType = 'playback'; + worker.send({ type: 'clusterId', clusterId: worker.id }); worker.ready = false; this.workers.push(worker); this.workersById.set(worker.id, worker); @@ -1251,7 +1280,7 @@ export default class WorkerManager { catch { } } this.commandSockets.clear(); - for (const socket of this.eventSockets) { + for (const socket of this.eventSockets.values()) { try { socket.destroy(); } diff --git a/dist/src/workers/main.js b/dist/src/workers/main.js index 77e0d616..61029685 100644 --- a/dist/src/workers/main.js +++ b/dist/src/workers/main.js @@ -745,7 +745,8 @@ const sendProcessMessage = (payload, onError) => { return false; } }; -const { EVENT_SOCKET_PATH, COMMAND_SOCKET_PATH, NODE_UNIQUE_ID } = process.env; +const { EVENT_SOCKET_PATH, COMMAND_SOCKET_PATH, WORKER_CLUSTER_ID } = process.env; +let WORKER_CLUSTER_ID_OVERRIDE = ''; let eventSocket = null; let eventSocketPath = EVENT_SOCKET_PATH; let eventReconnectTimer = null; @@ -809,12 +810,32 @@ const handleSocketDisconnect = (socketType, socket) => { notifySocketDisconnected(socketType); scheduleReconnect(socketType); }; +const sendEventHello = () => { + if (!eventSocket || eventSocket.destroyed) + return false; + const payload = v8.serialize({ pid: process.pid }); + const header = Buffer.alloc(6); + header.writeUInt8(0, 0); + header.writeUInt8(0, 1); + header.writeUInt32BE(payload.length, 2); + try { + eventSocket.cork(); + const okHeader = eventSocket.write(header); + const okPayload = eventSocket.write(payload); + eventSocket.uncork(); + return okHeader && okPayload; + } + catch { + return false; + } +}; const connectEventSocket = () => { if (!eventSocketPath) return; const socket = net.createConnection(eventSocketPath, () => { eventSocket = socket; clearReconnectTimer('event'); + sendEventHello(); logger('info', 'Worker', 'Connected to Master event socket'); }); socket.on('error', () => { @@ -1244,7 +1265,7 @@ function startTimers(hibernating = false) { lastCpuUsage = process.cpuUsage(); const nodelinkLoad = elapsedMs > 0 ? (cpuUsage.user + cpuUsage.system) / 1000 / elapsedMs : 0; const mem = process.memoryUsage(); - const workerIdEnv = NODE_UNIQUE_ID; + const resolvedClusterId = WORKER_CLUSTER_ID_OVERRIDE || WORKER_CLUSTER_ID; const eluP50 = hndl.percentile(50) / 1e6; const eluP95 = hndl.percentile(95) / 1e6; const eluP99 = hndl.percentile(99) / 1e6; @@ -1258,7 +1279,7 @@ function startTimers(hibernating = false) { } } const stats = { - workerId: parseInt(workerIdEnv ?? '0', 10) + 1, + workerId: resolvedClusterId ? parseInt(resolvedClusterId, 10) : 0, isHibernating, players: localPlayers, playingPlayers: localPlayingPlayers, @@ -1772,6 +1793,10 @@ process.on('message', (msg) => { if (message.type) { ipcMessageTracker.trackReceived(message.type, message); } + if (message.type === 'clusterId') { + WORKER_CLUSTER_ID_OVERRIDE = String(message.clusterId ?? ''); + return; + } if (message.type === 'ping') { if (process.connected) { try { diff --git a/src/managers/workerManager.ts b/src/managers/workerManager.ts index 462c4f61..e285ea57 100644 --- a/src/managers/workerManager.ts +++ b/src/managers/workerManager.ts @@ -37,6 +37,10 @@ interface CommandSocket extends Socket { _workerId?: number } +interface EventSocket extends Socket { + _workerId?: number +} + /** * Runtime worker stats payload received from playback workers. * @internal @@ -350,7 +354,7 @@ export default class WorkerManager { private commandSocketPath: string private commandServer: net.Server | null private commandSockets: Map - private eventSockets: Set + private eventSockets: Map private socketRotateInProgress: boolean private lastSocketRotateAt: number @@ -409,7 +413,7 @@ export default class WorkerManager { this.commandSocketPath = createSocketPath('commands') this.commandServer = null this.commandSockets = new Map() - this.eventSockets = new Set() + this.eventSockets = new Map() this.socketRotateInProgress = false this.lastSocketRotateAt = 0 @@ -722,17 +726,38 @@ export default class WorkerManager { ) } + private _registerEventSocket(pid: number, eventSocket: EventSocket): void { + const worker = this.workers.find((w) => w.process.pid === pid) + if (!worker) return + const existing = this.eventSockets.get(worker.id) + if (existing && existing !== eventSocket) { + try { + existing.destroy() + } catch {} + } + eventSocket._workerId = worker.id + this.eventSockets.set(worker.id, eventSocket) + } + + private _removeEventSocket(eventSocket: EventSocket): void { + const workerId = eventSocket?._workerId + if (!workerId) return + if (this.eventSockets.get(workerId) === eventSocket) { + this.eventSockets.delete(workerId) + } + } + private _startSocketServer(): void { this._safeUnlinkSocketPath(this.socketPath) this.server = net.createServer((socket) => { - this.eventSockets.add(socket) + const eventSocket = socket as EventSocket const frameChunks: Buffer[] = [] let frameBytes = 0 socket.on('error', () => { // Ignore per-connection transport errors (EPIPE/ECONNRESET). }) - socket.on('close', () => this.eventSockets.delete(socket)) + socket.on('close', () => this._removeEventSocket(eventSocket)) const peekBytes = (count: number): Buffer => { const first = frameChunks[0] @@ -812,7 +837,11 @@ export default class WorkerManager { try { const data = v8.deserialize(payload) - if (type === 3) { + if (type === 0) { + // Event socket hello - register socket to worker by PID + if (isPidPacket(data)) + this._registerEventSocket(data.pid, eventSocket) + } else if (type === 3) { // playerEvent const nodelink = getGlobalNodelink() if (nodelink) @@ -821,10 +850,12 @@ export default class WorkerManager { payload: data }) } else if (type === 4) { - // workerStats + // workerStats - prefer socket-mapped worker ID over payload workerId if (isWorkerStatsPacket(data)) { - const { workerId, ...stats } = data - this.statsUpdateBatch.set(workerId, stats) + const { workerId: payloadWorkerId, ...stats } = data + const resolvedWorkerId = + eventSocket._workerId ?? payloadWorkerId + this.statsUpdateBatch.set(resolvedWorkerId, stats) if (!this.statsUpdateTimer) { this.statsUpdateTimer = setTimeout( () => this._flushStatsUpdates(), @@ -1006,7 +1037,7 @@ export default class WorkerManager { `Rotating internal sockets after ${reason} (worker ${sourceWorkerId})` ) - for (const socket of this.eventSockets) { + for (const socket of this.eventSockets.values()) { try { socket.destroy() } catch {} @@ -1289,6 +1320,7 @@ export default class WorkerManager { WORKER_TYPE: 'playback' }) as PlaybackWorker worker.workerType = 'playback' + worker.send({ type: 'clusterId', clusterId: worker.id }) worker.ready = false this.workers.push(worker) @@ -1822,7 +1854,7 @@ export default class WorkerManager { } this.commandSockets.clear() - for (const socket of this.eventSockets) { + for (const socket of this.eventSockets.values()) { try { socket.destroy() } catch {} diff --git a/src/workers/main.ts b/src/workers/main.ts index 09a944d9..96547814 100644 --- a/src/workers/main.ts +++ b/src/workers/main.ts @@ -1099,13 +1099,14 @@ const sendProcessMessage = ( } } -const { EVENT_SOCKET_PATH, COMMAND_SOCKET_PATH, NODE_UNIQUE_ID } = +const { EVENT_SOCKET_PATH, COMMAND_SOCKET_PATH, WORKER_CLUSTER_ID } = process.env as NodeJS.ProcessEnv & { EVENT_SOCKET_PATH?: string COMMAND_SOCKET_PATH?: string - NODE_UNIQUE_ID?: string + WORKER_CLUSTER_ID?: string } +let WORKER_CLUSTER_ID_OVERRIDE = '' let eventSocket: net.Socket | null = null let eventSocketPath = EVENT_SOCKET_PATH let eventReconnectTimer: NodeJS.Timeout | null = null @@ -1174,12 +1175,31 @@ const handleSocketDisconnect = ( scheduleReconnect(socketType) } +const sendEventHello = (): boolean => { + if (!eventSocket || eventSocket.destroyed) return false + const payload = v8.serialize({ pid: process.pid }) + const header = Buffer.alloc(6) + header.writeUInt8(0, 0) + header.writeUInt8(0, 1) + header.writeUInt32BE(payload.length, 2) + try { + eventSocket.cork() + const okHeader = eventSocket.write(header) + const okPayload = eventSocket.write(payload) + eventSocket.uncork() + return okHeader && okPayload + } catch { + return false + } +} + const connectEventSocket = (): void => { if (!eventSocketPath) return const socket = net.createConnection(eventSocketPath, () => { eventSocket = socket clearReconnectTimer('event') + sendEventHello() logger('info', 'Worker', 'Connected to Master event socket') }) socket.on('error', () => { @@ -1694,7 +1714,7 @@ function startTimers(hibernating = false): void { elapsedMs > 0 ? (cpuUsage.user + cpuUsage.system) / 1000 / elapsedMs : 0 const mem = process.memoryUsage() - const workerIdEnv = NODE_UNIQUE_ID + const resolvedClusterId = WORKER_CLUSTER_ID_OVERRIDE || WORKER_CLUSTER_ID const eluP50 = hndl.percentile(50) / 1e6 const eluP95 = hndl.percentile(95) / 1e6 const eluP99 = hndl.percentile(99) / 1e6 @@ -1710,7 +1730,7 @@ function startTimers(hibernating = false): void { } const stats = { - workerId: parseInt(workerIdEnv ?? '0', 10) + 1, + workerId: resolvedClusterId ? parseInt(resolvedClusterId, 10) : 0, isHibernating, players: localPlayers, playingPlayers: localPlayingPlayers, @@ -2409,6 +2429,12 @@ process.on('message', (msg: unknown) => { ipcMessageTracker.trackReceived(message.type, message) } + if (message.type === 'clusterId') { + WORKER_CLUSTER_ID_OVERRIDE = String( + (message as { clusterId?: number }).clusterId ?? '' + ) + return + } if (message.type === 'ping') { if (process.connected) { try { From a1afc01f09a231f750b5b7fa1fbeec44ae7987e9 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 10 May 2026 16:02:13 -0300 Subject: [PATCH 14/58] fix: destroy() has already been called on this instance resampler.destroy() was being called twice. --- dist/src/playback/processing/streamProcessor.js | 8 ++++++-- src/playback/processing/streamProcessor.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index 5bcf6052..0b08add6 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -943,8 +943,10 @@ class AACDecoderStream extends Transform { this.pendingChunks.length = 0; if (this.decoder) this.decoder.free?.(); - if (this.resampler) + if (this.resampler) { this.resampler.destroy?.(); + this.resampler = null; + } super._destroy(err, cb); } _downmixToStereo(interleavedPCM, channels, samplesPerChannel) { @@ -1156,8 +1158,10 @@ class AACDecoderStream extends Transform { } catch (_err) { } } - if (this.resampler) + if (this.resampler) { this.resampler.destroy?.(); + this.resampler = null; + } if (this.decoder) this.decoder.destroy?.(); callback(); diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index 80c13e66..e113fe74 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -1327,7 +1327,10 @@ class AACDecoderStream extends Transform { this.ringBuffer.dispose() this.pendingChunks.length = 0 if (this.decoder) this.decoder.free?.() - if (this.resampler) this.resampler.destroy?.() + if (this.resampler) { + this.resampler.destroy?.() + this.resampler = null + } super._destroy(err, cb) } @@ -1572,7 +1575,10 @@ class AACDecoderStream extends Transform { } catch (_err) {} } - if (this.resampler) this.resampler.destroy?.() + if (this.resampler) { + this.resampler.destroy?.() + this.resampler = null + } if (this.decoder) this.decoder.destroy?.() callback() } From ad56e0711b64db21f89267bb90d88e9890aa05f9 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sun, 10 May 2026 17:03:40 -0300 Subject: [PATCH 15/58] fix: shazam failing to resolve songs --- src/sources/shazam.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sources/shazam.ts b/src/sources/shazam.ts index a7264a42..10ee27f5 100644 --- a/src/sources/shazam.ts +++ b/src/sources/shazam.ts @@ -369,7 +369,11 @@ export default class ShazamSource { return { loadType: 'empty', data: {} } } - const { body, statusCode, error } = await http1makeRequest(url) + const { body, statusCode, error } = await http1makeRequest(url, { + headers: { + "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"" + } + }) if (error || statusCode !== 200) { return { loadType: 'empty', data: {} } } From 2138d3516388b0d9dbe490c3ea9d021ea243ebee Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Mon, 11 May 2026 10:49:55 -0300 Subject: [PATCH 16/58] fix: reduce playback memory retention Lazily allocate tape/scratch effect buffers only when those effects are used, instead of retaining them for every playback pipeline. Lowered oversized AAC and WebM demux ring buffer defaults that were retained per active stream. Added teardown cleanup for playback filters and mixers so ended streams release native buffers promptly. From my tests on the playback worker, with 19 active players, the JSArrayBufferData went from ~146MB to ~19.5MB. --- dist/package.json | 2 +- dist/src/playback/demuxers/WebmOpus.js | 2 +- dist/src/playback/filters/chorus.js | 5 ++ dist/src/playback/filters/dsp/delay.js | 5 ++ dist/src/playback/filters/dsp/float64Delay.js | 5 ++ dist/src/playback/filters/echo.js | 4 ++ dist/src/playback/filters/flanger.js | 3 ++ dist/src/playback/filters/karaoke.js | 4 ++ dist/src/playback/filters/phonograph.js | 11 +++++ dist/src/playback/filters/reverb.js | 22 +++++++++ dist/src/playback/filters/spatial.js | 4 ++ dist/src/playback/filters/timescale.js | 3 ++ .../filters/timescale/floatFifoBuffer.js | 6 +++ .../playback/filters/timescale/timeStretch.js | 4 ++ dist/src/playback/filters/vibrato.js | 4 ++ dist/src/playback/player.js | 5 ++ dist/src/playback/processing/AudioMixer.js | 10 ++++ .../src/playback/processing/FlowController.js | 6 +++ .../playback/processing/LoudnessNormalizer.js | 6 +++ .../playback/processing/ScratchTransformer.js | 36 ++++++++++++--- .../playback/processing/TapeTransformer.js | 38 +++++++++++---- .../playback/processing/VolumeTransformer.js | 15 ++++++ .../src/playback/processing/filtersManager.js | 14 ++++++ .../playback/processing/streamProcessor.js | 3 +- dist/src/sources/shazam.js | 6 ++- package.json | 2 +- src/playback/demuxers/WebmOpus.ts | 2 +- src/playback/filters/chorus.ts | 6 +++ src/playback/filters/dsp/delay.ts | 5 ++ src/playback/filters/dsp/float64Delay.ts | 5 ++ src/playback/filters/echo.ts | 5 ++ src/playback/filters/flanger.ts | 4 ++ src/playback/filters/karaoke.ts | 5 ++ src/playback/filters/phonograph.ts | 11 +++++ src/playback/filters/reverb.ts | 23 ++++++++++ src/playback/filters/spatial.ts | 5 ++ src/playback/filters/timescale.ts | 4 ++ .../filters/timescale/floatFifoBuffer.ts | 6 +++ src/playback/filters/timescale/timeStretch.ts | 5 ++ src/playback/filters/vibrato.ts | 5 ++ src/playback/player.ts | 7 +++ src/playback/processing/AudioMixer.ts | 11 +++++ src/playback/processing/FlowController.ts | 9 ++++ src/playback/processing/LoudnessNormalizer.ts | 6 +++ src/playback/processing/ScratchTransformer.ts | 44 +++++++++++++++--- src/playback/processing/TapeTransformer.ts | 46 +++++++++++++++---- src/playback/processing/VolumeTransformer.ts | 16 +++++++ src/playback/processing/filtersManager.ts | 17 +++++++ src/playback/processing/streamProcessor.ts | 3 +- src/typings/playback/player.types.ts | 1 + 50 files changed, 437 insertions(+), 39 deletions(-) diff --git a/dist/package.json b/dist/package.json index 5c3eac70..4b9b9095 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260506.1", + "version": "3.8.1-dev.20260511.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/dist/src/playback/demuxers/WebmOpus.js b/dist/src/playback/demuxers/WebmOpus.js index 92be8484..35f7fc7b 100644 --- a/dist/src/playback/demuxers/WebmOpus.js +++ b/dist/src/playback/demuxers/WebmOpus.js @@ -10,7 +10,7 @@ const parsePositiveIntEnv = (key, fallback) => { const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; }; -const BUFFER_SIZE = parsePositiveIntEnv('NODELINK_WEBM_DEMUX_RING_BYTES', 2 * 1024 * 1024); +const BUFFER_SIZE = parsePositiveIntEnv('NODELINK_WEBM_DEMUX_RING_BYTES', 512 * 1024); const TAGS = Object.freeze({ '1a45dfa3': true, 18538067: true, diff --git a/dist/src/playback/filters/chorus.js b/dist/src/playback/filters/chorus.js index 029e43f0..37919e8a 100644 --- a/dist/src/playback/filters/chorus.js +++ b/dist/src/playback/filters/chorus.js @@ -156,4 +156,9 @@ export default class Chorus extends AnimatableFilter { lfos[3].phase = (3 * Math.PI) / 2; return Buffer.alloc(0); } + destroy() { + for (const delay of this.delays) { + delay.destroy(); + } + } } diff --git a/dist/src/playback/filters/dsp/delay.js b/dist/src/playback/filters/dsp/delay.js index 535e628a..84ea55b8 100644 --- a/dist/src/playback/filters/dsp/delay.js +++ b/dist/src/playback/filters/dsp/delay.js @@ -39,4 +39,9 @@ export default class DelayLine { clear() { this.buffer.fill(0); } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this.buffer = null; + } } diff --git a/dist/src/playback/filters/dsp/float64Delay.js b/dist/src/playback/filters/dsp/float64Delay.js index f509456d..8b533f2c 100644 --- a/dist/src/playback/filters/dsp/float64Delay.js +++ b/dist/src/playback/filters/dsp/float64Delay.js @@ -61,4 +61,9 @@ export class Float64DelayLine { clear() { this.buffer.fill(0); } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this.buffer = null; + } } diff --git a/dist/src/playback/filters/echo.js b/dist/src/playback/filters/echo.js index f3544be2..6e5a6c9f 100644 --- a/dist/src/playback/filters/echo.js +++ b/dist/src/playback/filters/echo.js @@ -117,4 +117,8 @@ export default class Echo extends AnimatableFilter { this.delayLineR.clear(); return Buffer.alloc(0); } + destroy() { + this.delayLineL.destroy(); + this.delayLineR.destroy(); + } } diff --git a/dist/src/playback/filters/flanger.js b/dist/src/playback/filters/flanger.js index 68dab947..4aa48fe5 100644 --- a/dist/src/playback/filters/flanger.js +++ b/dist/src/playback/filters/flanger.js @@ -85,4 +85,7 @@ export default class Flanger extends AnimatableFilter { this.lfo.phase = 0; return Buffer.alloc(0); } + destroy() { + this.delayLine.destroy(); + } } diff --git a/dist/src/playback/filters/karaoke.js b/dist/src/playback/filters/karaoke.js index 07bd6ff2..1cc1643e 100644 --- a/dist/src/playback/filters/karaoke.js +++ b/dist/src/playback/filters/karaoke.js @@ -295,4 +295,8 @@ export default class Karaoke extends AnimatableFilter { this._prevGain = MAX_OUTPUT_GAIN; return Buffer.alloc(0); } + destroy() { + this._bufL = null; + this._bufR = null; + } } diff --git a/dist/src/playback/filters/phonograph.js b/dist/src/playback/filters/phonograph.js index 818acd4a..eb2a82f5 100644 --- a/dist/src/playback/filters/phonograph.js +++ b/dist/src/playback/filters/phonograph.js @@ -37,6 +37,11 @@ class InterpDelayLine { this.buf.fill(0); this.w = 0; } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this.buf = null; + } write(x) { this.buf[this.w] = x; this.w = (this.w + 1) % this.buf.length; @@ -337,4 +342,10 @@ export default class Phonograph extends AnimatableFilter { this.agcGain = 1; return Buffer.alloc(0); } + destroy() { + this.delay.destroy(); + this.r1.destroy(); + this.r2.destroy(); + this.r3.destroy(); + } } diff --git a/dist/src/playback/filters/reverb.js b/dist/src/playback/filters/reverb.js index 685bdcd7..acb2964d 100644 --- a/dist/src/playback/filters/reverb.js +++ b/dist/src/playback/filters/reverb.js @@ -51,6 +51,12 @@ class CombFilter { this.filterStore = 0; this.writeIndex = 0; } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this.buffer = null; + this.filterStore = 0; + } } /** * Applies a Freeverb/Schroeder-based reverb effect. @@ -265,4 +271,20 @@ export default class Reverb extends AnimatableFilter { this.wetHPAccR = 0; return Buffer.alloc(0); } + destroy() { + for (const comb of [ + ...this.combFiltersL, + ...this.combFiltersR, + ...this.customCombsL, + ...this.customCombsR + ]) { + comb.destroy(); + } + this.combFiltersL = []; + this.combFiltersR = []; + this.customCombsL = []; + this.customCombsR = []; + this.allpassBuffersL = []; + this.allpassBuffersR = []; + } } diff --git a/dist/src/playback/filters/spatial.js b/dist/src/playback/filters/spatial.js index 9f69ef60..c7d08ebe 100644 --- a/dist/src/playback/filters/spatial.js +++ b/dist/src/playback/filters/spatial.js @@ -91,4 +91,8 @@ export default class Spatial extends AnimatableFilter { this.rightDelay.clear(); return Buffer.alloc(0); } + destroy() { + this.leftDelay.destroy(); + this.rightDelay.destroy(); + } } diff --git a/dist/src/playback/filters/timescale.js b/dist/src/playback/filters/timescale.js index 091bffe8..11a455c0 100644 --- a/dist/src/playback/filters/timescale.js +++ b/dist/src/playback/filters/timescale.js @@ -217,4 +217,7 @@ export default class Timescale extends AnimatableFilter { this._reset(); return floatToInt16Buffer(combined); } + destroy() { + this._timeStretch.destroy(); + } } diff --git a/dist/src/playback/filters/timescale/floatFifoBuffer.js b/dist/src/playback/filters/timescale/floatFifoBuffer.js index a96a0c2c..adc0a42a 100644 --- a/dist/src/playback/filters/timescale/floatFifoBuffer.js +++ b/dist/src/playback/filters/timescale/floatFifoBuffer.js @@ -49,6 +49,12 @@ export default class FloatFifoBuffer { this._startFrame = 0; this._frames = 0; } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this._buffer = null; + this._frames = 0; + } /** * Calculates the starting sample index. */ diff --git a/dist/src/playback/filters/timescale/timeStretch.js b/dist/src/playback/filters/timescale/timeStretch.js index f5b9fa87..f49c9e26 100644 --- a/dist/src/playback/filters/timescale/timeStretch.js +++ b/dist/src/playback/filters/timescale/timeStretch.js @@ -66,6 +66,10 @@ export default class TimeStretch { this._inputPos = 0; this._prevOverlap = null; } + destroy() { + this._buffer.destroy(); + this._prevOverlap = null; + } /** * Processes new audio samples. * @param samples - The input Float32Array of samples. diff --git a/dist/src/playback/filters/vibrato.js b/dist/src/playback/filters/vibrato.js index 1a7c0712..5a85a387 100644 --- a/dist/src/playback/filters/vibrato.js +++ b/dist/src/playback/filters/vibrato.js @@ -93,4 +93,8 @@ export default class Vibrato extends AnimatableFilter { this.lfo.phase = 0; return Buffer.alloc(0); } + destroy() { + this.leftDelay.destroy(); + this.rightDelay.destroy(); + } } diff --git a/dist/src/playback/player.js b/dist/src/playback/player.js index 7692e671..4858d202 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -1807,6 +1807,11 @@ export class Player { this.emitEvent(GatewayEvents.PLAYER_DESTROYED, { guildId: this.guildId }); + if (this.audioMixer) { + this.audioMixer.destroy(); + this.audioMixer = null; + } + this._audioMixerInitPromise = null; this._resetTrack(); this.connStatus = 'destroyed'; this.volumePercent = this.nodelink.options?.defaultVolume ?? 100; diff --git a/dist/src/playback/processing/AudioMixer.js b/dist/src/playback/processing/AudioMixer.js index cfb6f22b..a3aa8339 100644 --- a/dist/src/playback/processing/AudioMixer.js +++ b/dist/src/playback/processing/AudioMixer.js @@ -267,4 +267,14 @@ export class AudioMixer extends Readable { this.removeLayer(id, reason); return ids.length; } + /** + * Destroys the mixer: clears all layers, removes all listeners, + * and ends the Readable stream. + */ + destroy() { + this.clearLayers('DESTROYED'); + this.removeAllListeners(); + super.destroy(); + return this; + } } diff --git a/dist/src/playback/processing/FlowController.js b/dist/src/playback/processing/FlowController.js index baa8e488..aacd60c1 100644 --- a/dist/src/playback/processing/FlowController.js +++ b/dist/src/playback/processing/FlowController.js @@ -174,4 +174,10 @@ export class FlowController extends Transform { } callback(); } + _destroy(_err, cb) { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this.pendingBuffer = null; + cb(null); + } } diff --git a/dist/src/playback/processing/LoudnessNormalizer.js b/dist/src/playback/processing/LoudnessNormalizer.js index 7d3dd9a8..b02456be 100644 --- a/dist/src/playback/processing/LoudnessNormalizer.js +++ b/dist/src/playback/processing/LoudnessNormalizer.js @@ -214,4 +214,10 @@ export class LoudnessNormalizer { this._releaseAlpha = fround(Math.exp(-1 / (releaseTime * sr))); this._energyAlpha = fround(Math.exp(-1 / (shortTermTime * sr))); } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this._channelBuffer = null; + this.filters = []; + } } diff --git a/dist/src/playback/processing/ScratchTransformer.js b/dist/src/playback/processing/ScratchTransformer.js index b2aa3af0..5f7b0435 100644 --- a/dist/src/playback/processing/ScratchTransformer.js +++ b/dist/src/playback/processing/ScratchTransformer.js @@ -19,7 +19,7 @@ export class ScratchTransformer extends Transform { * Internal circular buffer for storing PCM samples. * Storing as floats (0.0 to 1.0) simplifies resampling math. */ - inputBuffer; + inputBuffer = null; inputReadPos = 0; inputWritePos = 0; maxBufferSize; @@ -32,7 +32,6 @@ export class ScratchTransformer extends Transform { this.sampleRate = options.sampleRate ?? 48000; this.channels = options.channels ?? 2; this.maxBufferSize = this.sampleRate * this.channels * 5; - this.inputBuffer = new Float32Array(this.maxBufferSize); } /** * Triggers a scratch movement. @@ -42,6 +41,8 @@ export class ScratchTransformer extends Transform { scratchTo(durationMs, style) { const duration = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 500; this._lastEffectCompleted = false; + if (duration > 0) + this._ensureInputBuffer(); if (style === 'start' && this.inputWritePos > 0) { const latencySamples = 1024 * this.channels; this.inputReadPos = Math.max(this.channels, this.inputWritePos - latencySamples); @@ -83,6 +84,14 @@ export class ScratchTransformer extends Transform { getRate() { return this.currentRate; } + _ensureInputBuffer() { + if (!this.inputBuffer) { + this.inputBuffer = new Float32Array(this.maxBufferSize); + this.inputReadPos = 0; + this.inputWritePos = 0; + } + return this.inputBuffer; + } /** * Core math for rate modulation. Simulates the physics of a DJ's hand. * @param t - Progress of the effect (0.0 to 1.0). @@ -137,8 +146,14 @@ export class ScratchTransformer extends Transform { process(chunk) { if (chunk.length === 0) return chunk; + if (!this.state && + Math.abs(this.currentRate - 1.0) <= 0.001 && + this.inputWritePos <= this.inputReadPos + this.channels) { + return chunk; + } const incomingSamples = chunk.length / 2; const incomingFrames = incomingSamples / this.channels; + const inputBuffer = this._ensureInputBuffer(); if (this.inputWritePos + incomingSamples > this.maxBufferSize) { this._compact(); if (this.inputWritePos + incomingSamples > this.maxBufferSize) { @@ -148,7 +163,7 @@ export class ScratchTransformer extends Transform { } } for (let i = 0; i < incomingSamples; i++) { - this.inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767; + inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767; } const outI16 = new Int16Array(incomingSamples); const frameDurationMs = 1000 / this.sampleRate; @@ -174,10 +189,10 @@ export class ScratchTransformer extends Transform { const safeIPos = Math.max(this.channels, Math.min(this.inputWritePos - this.channels * 3, iPos)); const frac = (this.inputReadPos - iPos) / this.channels; for (let c = 0; c < this.channels; c++) { - const p0 = this.inputBuffer[safeIPos - this.channels + c] || 0; - const p1 = this.inputBuffer[safeIPos + c] || 0; - const p2 = this.inputBuffer[safeIPos + this.channels + c] || 0; - const p3 = this.inputBuffer[safeIPos + this.channels * 2 + c] || 0; + const p0 = inputBuffer[safeIPos - this.channels + c] || 0; + const p1 = inputBuffer[safeIPos + c] || 0; + const p2 = inputBuffer[safeIPos + this.channels + c] || 0; + const p3 = inputBuffer[safeIPos + this.channels * 2 + c] || 0; const val = 0.5 * (2 * p1 + (-p0 + p2) * frac + @@ -198,6 +213,8 @@ export class ScratchTransformer extends Transform { * This allows the "disk" to be pulled backwards immediately even at the start of a chunk. */ _compact() { + if (!this.inputBuffer) + return; const historyFrames = this.sampleRate * 1; const keepSamples = historyFrames * this.channels; const integralReadPos = Math.floor(this.inputReadPos / this.channels) * this.channels; @@ -209,4 +226,9 @@ export class ScratchTransformer extends Transform { this.inputReadPos -= copyStart; this.inputWritePos = remaining; } + _destroy(_err, cb) { + this.inputBuffer = null; + this.state = null; + cb(null); + } } diff --git a/dist/src/playback/processing/TapeTransformer.js b/dist/src/playback/processing/TapeTransformer.js index 03dde937..fd6f4f76 100644 --- a/dist/src/playback/processing/TapeTransformer.js +++ b/dist/src/playback/processing/TapeTransformer.js @@ -16,7 +16,7 @@ export class TapeTransformer extends Transform { currentRate = 1.0; tape = null; _lastRampCompleted = false; - inputBuffer; + inputBuffer = null; inputReadPos = 0; inputWritePos = 0; maxBufferSize; @@ -25,7 +25,6 @@ export class TapeTransformer extends Transform { this.sampleRate = options.sampleRate ?? 48000; this.channels = options.channels ?? 2; this.maxBufferSize = this.sampleRate * this.channels * 10; - this.inputBuffer = new Float32Array(this.maxBufferSize); } setRate(rate) { this.currentRate = Math.max(0.01, Math.min(2.0, rate)); @@ -35,6 +34,8 @@ export class TapeTransformer extends Transform { tapeTo(durationMs, type, curve = DEFAULT_CURVE) { const duration = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0; this._lastRampCompleted = false; + if (duration > 0) + this._ensureInputBuffer(); if (type === 'start' && this.inputWritePos > 0) { const latencySamples = 1024 * this.channels; this.inputReadPos = Math.max(0, this.inputWritePos - latencySamples); @@ -69,6 +70,14 @@ export class TapeTransformer extends Transform { getRate() { return this.currentRate; } + _ensureInputBuffer() { + if (!this.inputBuffer) { + this.inputBuffer = new Float32Array(this.maxBufferSize); + this.inputReadPos = 0; + this.inputWritePos = 0; + } + return this.inputBuffer; + } _getCurveValue(t, curve) { switch (curve) { case 'linear': @@ -93,8 +102,14 @@ export class TapeTransformer extends Transform { process(chunk) { if (chunk.length === 0) return chunk; + if (!this.tape && + Math.abs(this.currentRate - 1.0) <= 0.001 && + this.inputWritePos <= this.inputReadPos + this.channels) { + return chunk; + } const incomingSamples = chunk.length / 2; const incomingFrames = incomingSamples / this.channels; + const inputBuffer = this._ensureInputBuffer(); if (this.inputWritePos + incomingSamples > this.maxBufferSize) { this._compact(); if (this.inputWritePos + incomingSamples > this.maxBufferSize) { @@ -104,7 +119,7 @@ export class TapeTransformer extends Transform { } } for (let i = 0; i < incomingSamples; i++) { - this.inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767; + inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767; } const outI16 = new Int16Array(incomingSamples); const sampleDurationMs = 1000 / this.sampleRate; @@ -127,12 +142,10 @@ export class TapeTransformer extends Transform { break; const frac = (this.inputReadPos - iPos) / this.channels; for (let c = 0; c < this.channels; c++) { - const p0 = this.inputBuffer[iPos - this.channels + c] ?? - this.inputBuffer[iPos + c] ?? - 0; - const p1 = this.inputBuffer[iPos + c] ?? 0; - const p2 = this.inputBuffer[iPos + this.channels + c] ?? 0; - const p3 = this.inputBuffer[iPos + this.channels * 2 + c] ?? 0; + const p0 = inputBuffer[iPos - this.channels + c] ?? inputBuffer[iPos + c] ?? 0; + const p1 = inputBuffer[iPos + c] ?? 0; + const p2 = inputBuffer[iPos + this.channels + c] ?? 0; + const p3 = inputBuffer[iPos + this.channels * 2 + c] ?? 0; const val = 0.5 * (2 * p1 + (-p0 + p2) * frac + @@ -147,7 +160,14 @@ export class TapeTransformer extends Transform { } return Buffer.from(outI16.buffer, outI16.byteOffset, outI16.byteLength); } + _destroy(_err, cb) { + this.inputBuffer = null; + this.tape = null; + cb(null); + } _compact() { + if (!this.inputBuffer) + return; const integralReadPos = Math.floor(this.inputReadPos / this.channels) * this.channels; const fractionalReadPos = this.inputReadPos - integralReadPos; if (integralReadPos <= 0) diff --git a/dist/src/playback/processing/VolumeTransformer.js b/dist/src/playback/processing/VolumeTransformer.js index 512a038a..af3c5827 100644 --- a/dist/src/playback/processing/VolumeTransformer.js +++ b/dist/src/playback/processing/VolumeTransformer.js @@ -270,4 +270,19 @@ export class VolumeTransformer extends Transform { callback(error); } } + _destroy(_err, cb) { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ; + this.lookaheadBuffer = null; + // biome-ignore lint/suspicious/noExplicitAny: checking runtime method existence + if (this.agc && typeof this.agc.destroy === 'function') { + // biome-ignore lint/suspicious/noExplicitAny: calling dynamically-discovered destroy + ; + this.agc.destroy(); + } + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release reference + ; + this.agc = null; + cb(null); + } } diff --git a/dist/src/playback/processing/filtersManager.js b/dist/src/playback/processing/filtersManager.js index 64c54913..8d13eaac 100644 --- a/dist/src/playback/processing/filtersManager.js +++ b/dist/src/playback/processing/filtersManager.js @@ -241,4 +241,18 @@ export class FiltersManager extends Transform { } return filters; } + _destroy(_err, cb) { + for (const name in this.filterInstances) { + const instance = this.filterInstances[name]; + // biome-ignore lint/suspicious/noExplicitAny: checking runtime method existence + if (instance && typeof instance.destroy === 'function') { + // biome-ignore lint/suspicious/noExplicitAny: calling dynamically-discovered destroy + ; + instance.destroy(); + } + delete this.filterInstances[name]; + } + this.activeFilters = []; + cb(null); + } } diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index 0b08add6..aa51bc94 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -47,7 +47,7 @@ const parsePositiveIntEnv = (key, fallback) => { const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; }; -const AAC_BUFFER_SIZE = parsePositiveIntEnv('NODELINK_AAC_RING_BYTES', 2 * 1024 * 1024); +const AAC_BUFFER_SIZE = parsePositiveIntEnv('NODELINK_AAC_RING_BYTES', 512 * 1024); const AUDIO_CONSTANTS = Object.freeze({ pcmFloatFactor: 32767, maxDecodesPerTick: 5, @@ -420,6 +420,7 @@ class BaseAudioResource { pipe.destroy?.(); pipe.removeAllListeners?.(); } + this.pipes.length = 0; this.stream = null; this.pipes = null; } diff --git a/dist/src/sources/shazam.js b/dist/src/sources/shazam.js index 409a8cf5..bb1cb95a 100644 --- a/dist/src/sources/shazam.js +++ b/dist/src/sources/shazam.js @@ -125,7 +125,11 @@ export default class ShazamSource { if (!this.patterns.some((pattern) => pattern.test(url))) { return { loadType: 'empty', data: {} }; } - const { body, statusCode, error } = await http1makeRequest(url); + const { body, statusCode, error } = await http1makeRequest(url, { + headers: { + "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"" + } + }); if (error || statusCode !== 200) { return { loadType: 'empty', data: {} }; } diff --git a/package.json b/package.json index caeb6af8..0b1aa45f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260506.1", + "version": "3.8.1-dev.20260511.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/src/playback/demuxers/WebmOpus.ts b/src/playback/demuxers/WebmOpus.ts index 7cd215c0..45f62d34 100644 --- a/src/playback/demuxers/WebmOpus.ts +++ b/src/playback/demuxers/WebmOpus.ts @@ -20,7 +20,7 @@ const parsePositiveIntEnv = (key: string, fallback: number): number => { } const BUFFER_SIZE = parsePositiveIntEnv( 'NODELINK_WEBM_DEMUX_RING_BYTES', - 2 * 1024 * 1024 + 512 * 1024 ) const TAGS: Readonly> = Object.freeze({ diff --git a/src/playback/filters/chorus.ts b/src/playback/filters/chorus.ts index a8fa83cf..8ab3dfa1 100644 --- a/src/playback/filters/chorus.ts +++ b/src/playback/filters/chorus.ts @@ -179,4 +179,10 @@ export default class Chorus extends AnimatableFilter { return Buffer.alloc(0) } + + public destroy(): void { + for (const delay of this.delays) { + delay.destroy() + } + } } diff --git a/src/playback/filters/dsp/delay.ts b/src/playback/filters/dsp/delay.ts index a80f2b58..7a724afd 100644 --- a/src/playback/filters/dsp/delay.ts +++ b/src/playback/filters/dsp/delay.ts @@ -47,4 +47,9 @@ export default class DelayLine { public clear(): void { this.buffer.fill(0) } + + public destroy(): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any).buffer = null + } } diff --git a/src/playback/filters/dsp/float64Delay.ts b/src/playback/filters/dsp/float64Delay.ts index d59831b7..f40cb430 100644 --- a/src/playback/filters/dsp/float64Delay.ts +++ b/src/playback/filters/dsp/float64Delay.ts @@ -69,4 +69,9 @@ export class Float64DelayLine { public clear(): void { this.buffer.fill(0) } + + public destroy(): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any).buffer = null + } } diff --git a/src/playback/filters/echo.ts b/src/playback/filters/echo.ts index e5801b20..795c1657 100644 --- a/src/playback/filters/echo.ts +++ b/src/playback/filters/echo.ts @@ -149,4 +149,9 @@ export default class Echo extends AnimatableFilter { this.delayLineR.clear() return Buffer.alloc(0) } + + public destroy(): void { + this.delayLineL.destroy() + this.delayLineR.destroy() + } } diff --git a/src/playback/filters/flanger.ts b/src/playback/filters/flanger.ts index c0061375..f3e94eae 100644 --- a/src/playback/filters/flanger.ts +++ b/src/playback/filters/flanger.ts @@ -110,4 +110,8 @@ export default class Flanger extends AnimatableFilter { this.lfo.phase = 0 return Buffer.alloc(0) } + + public destroy(): void { + this.delayLine.destroy() + } } diff --git a/src/playback/filters/karaoke.ts b/src/playback/filters/karaoke.ts index 31436705..6466f6f4 100644 --- a/src/playback/filters/karaoke.ts +++ b/src/playback/filters/karaoke.ts @@ -377,4 +377,9 @@ export default class Karaoke extends AnimatableFilter { this._prevGain = MAX_OUTPUT_GAIN return Buffer.alloc(0) } + + public destroy(): void { + this._bufL = null + this._bufR = null + } } diff --git a/src/playback/filters/phonograph.ts b/src/playback/filters/phonograph.ts index 11b84b43..054b769d 100644 --- a/src/playback/filters/phonograph.ts +++ b/src/playback/filters/phonograph.ts @@ -44,6 +44,10 @@ class InterpDelayLine { this.buf.fill(0) this.w = 0 } + destroy() { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any).buf = null + } write(x: number) { this.buf[this.w] = x this.w = (this.w + 1) % this.buf.length @@ -403,4 +407,11 @@ export default class Phonograph extends AnimatableFilter { this.agcGain = 1 return Buffer.alloc(0) } + + public destroy(): void { + this.delay.destroy() + this.r1.destroy() + this.r2.destroy() + this.r3.destroy() + } } diff --git a/src/playback/filters/reverb.ts b/src/playback/filters/reverb.ts index 0f773428..d47d54f0 100644 --- a/src/playback/filters/reverb.ts +++ b/src/playback/filters/reverb.ts @@ -59,6 +59,12 @@ class CombFilter { this.filterStore = 0 this.writeIndex = 0 } + + public destroy(): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any).buffer = null + this.filterStore = 0 + } } /** @@ -365,4 +371,21 @@ export default class Reverb extends AnimatableFilter { return Buffer.alloc(0) } + + public destroy(): void { + for (const comb of [ + ...this.combFiltersL, + ...this.combFiltersR, + ...this.customCombsL, + ...this.customCombsR + ]) { + comb.destroy() + } + this.combFiltersL = [] + this.combFiltersR = [] + this.customCombsL = [] + this.customCombsR = [] + this.allpassBuffersL = [] + this.allpassBuffersR = [] + } } diff --git a/src/playback/filters/spatial.ts b/src/playback/filters/spatial.ts index 5a9f247f..ef5d888e 100644 --- a/src/playback/filters/spatial.ts +++ b/src/playback/filters/spatial.ts @@ -120,4 +120,9 @@ export default class Spatial extends AnimatableFilter { return Buffer.alloc(0) } + + public destroy(): void { + this.leftDelay.destroy() + this.rightDelay.destroy() + } } diff --git a/src/playback/filters/timescale.ts b/src/playback/filters/timescale.ts index 395af51e..98463e42 100644 --- a/src/playback/filters/timescale.ts +++ b/src/playback/filters/timescale.ts @@ -256,4 +256,8 @@ export default class Timescale extends AnimatableFilter { this._reset() return floatToInt16Buffer(combined) } + + public destroy(): void { + this._timeStretch.destroy() + } } diff --git a/src/playback/filters/timescale/floatFifoBuffer.ts b/src/playback/filters/timescale/floatFifoBuffer.ts index 4a1bf4e4..71127232 100644 --- a/src/playback/filters/timescale/floatFifoBuffer.ts +++ b/src/playback/filters/timescale/floatFifoBuffer.ts @@ -56,6 +56,12 @@ export default class FloatFifoBuffer { this._frames = 0 } + public destroy(): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any)._buffer = null + this._frames = 0 + } + /** * Calculates the starting sample index. */ diff --git a/src/playback/filters/timescale/timeStretch.ts b/src/playback/filters/timescale/timeStretch.ts index 5ccb0881..fe56b9c0 100644 --- a/src/playback/filters/timescale/timeStretch.ts +++ b/src/playback/filters/timescale/timeStretch.ts @@ -84,6 +84,11 @@ export default class TimeStretch { this._prevOverlap = null } + public destroy(): void { + this._buffer.destroy() + this._prevOverlap = null + } + /** * Processes new audio samples. * @param samples - The input Float32Array of samples. diff --git a/src/playback/filters/vibrato.ts b/src/playback/filters/vibrato.ts index be4656ee..eae99f3b 100644 --- a/src/playback/filters/vibrato.ts +++ b/src/playback/filters/vibrato.ts @@ -118,4 +118,9 @@ export default class Vibrato extends AnimatableFilter { this.lfo.phase = 0 return Buffer.alloc(0) } + + public destroy(): void { + this.leftDelay.destroy() + this.rightDelay.destroy() + } } diff --git a/src/playback/player.ts b/src/playback/player.ts index fc301773..35417a76 100644 --- a/src/playback/player.ts +++ b/src/playback/player.ts @@ -2632,6 +2632,13 @@ export class Player { this.emitEvent(GatewayEvents.PLAYER_DESTROYED, { guildId: this.guildId }) + + if (this.audioMixer) { + this.audioMixer.destroy() + this.audioMixer = null + } + this._audioMixerInitPromise = null + this._resetTrack() this.connStatus = 'destroyed' this.volumePercent = this.nodelink.options?.defaultVolume ?? 100 diff --git a/src/playback/processing/AudioMixer.ts b/src/playback/processing/AudioMixer.ts index ef6b0eb6..d0fdfb3d 100644 --- a/src/playback/processing/AudioMixer.ts +++ b/src/playback/processing/AudioMixer.ts @@ -332,4 +332,15 @@ export class AudioMixer extends Readable { for (const id of ids) this.removeLayer(id, reason) return ids.length } + + /** + * Destroys the mixer: clears all layers, removes all listeners, + * and ends the Readable stream. + */ + public override destroy(): this { + this.clearLayers('DESTROYED') + this.removeAllListeners() + super.destroy() + return this + } } diff --git a/src/playback/processing/FlowController.ts b/src/playback/processing/FlowController.ts index f82396bd..a109fce2 100644 --- a/src/playback/processing/FlowController.ts +++ b/src/playback/processing/FlowController.ts @@ -236,4 +236,13 @@ export class FlowController extends Transform { callback() } + + override _destroy( + _err: Error | null, + cb: (error?: Error | null) => void + ): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any).pendingBuffer = null + cb(null) + } } diff --git a/src/playback/processing/LoudnessNormalizer.ts b/src/playback/processing/LoudnessNormalizer.ts index 94fac554..0f7fb18e 100644 --- a/src/playback/processing/LoudnessNormalizer.ts +++ b/src/playback/processing/LoudnessNormalizer.ts @@ -274,4 +274,10 @@ export class LoudnessNormalizer { this._releaseAlpha = fround(Math.exp(-1 / (releaseTime * sr))) this._energyAlpha = fround(Math.exp(-1 / (shortTermTime * sr))) } + + public destroy(): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any)._channelBuffer = null + this.filters = [] + } } diff --git a/src/playback/processing/ScratchTransformer.ts b/src/playback/processing/ScratchTransformer.ts index 49397d90..35dbd0f9 100644 --- a/src/playback/processing/ScratchTransformer.ts +++ b/src/playback/processing/ScratchTransformer.ts @@ -44,7 +44,7 @@ export class ScratchTransformer extends Transform { * Internal circular buffer for storing PCM samples. * Storing as floats (0.0 to 1.0) simplifies resampling math. */ - private inputBuffer: Float32Array + private inputBuffer: Float32Array | null = null private inputReadPos = 0 private inputWritePos = 0 private readonly maxBufferSize: number @@ -59,7 +59,6 @@ export class ScratchTransformer extends Transform { this.channels = options.channels ?? 2 this.maxBufferSize = this.sampleRate * this.channels * 5 - this.inputBuffer = new Float32Array(this.maxBufferSize) } /** @@ -71,6 +70,8 @@ export class ScratchTransformer extends Transform { const duration = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 500 this._lastEffectCompleted = false + if (duration > 0) this._ensureInputBuffer() + if (style === 'start' && this.inputWritePos > 0) { const latencySamples = 1024 * this.channels this.inputReadPos = Math.max( @@ -123,6 +124,16 @@ export class ScratchTransformer extends Transform { return this.currentRate } + private _ensureInputBuffer(): Float32Array { + if (!this.inputBuffer) { + this.inputBuffer = new Float32Array(this.maxBufferSize) + this.inputReadPos = 0 + this.inputWritePos = 0 + } + + return this.inputBuffer + } + /** * Core math for rate modulation. Simulates the physics of a DJ's hand. * @param t - Progress of the effect (0.0 to 1.0). @@ -186,9 +197,17 @@ export class ScratchTransformer extends Transform { */ public process(chunk: Buffer): Buffer { if (chunk.length === 0) return chunk + if ( + !this.state && + Math.abs(this.currentRate - 1.0) <= 0.001 && + this.inputWritePos <= this.inputReadPos + this.channels + ) { + return chunk + } const incomingSamples = chunk.length / 2 const incomingFrames = incomingSamples / this.channels + const inputBuffer = this._ensureInputBuffer() if (this.inputWritePos + incomingSamples > this.maxBufferSize) { this._compact() @@ -201,7 +220,7 @@ export class ScratchTransformer extends Transform { } for (let i = 0; i < incomingSamples; i++) { - this.inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767 + inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767 } const outI16 = new Int16Array(incomingSamples) @@ -240,10 +259,10 @@ export class ScratchTransformer extends Transform { const frac = (this.inputReadPos - iPos) / this.channels for (let c = 0; c < this.channels; c++) { - const p0 = this.inputBuffer[safeIPos - this.channels + c] || 0 - const p1 = this.inputBuffer[safeIPos + c] || 0 - const p2 = this.inputBuffer[safeIPos + this.channels + c] || 0 - const p3 = this.inputBuffer[safeIPos + this.channels * 2 + c] || 0 + const p0 = inputBuffer[safeIPos - this.channels + c] || 0 + const p1 = inputBuffer[safeIPos + c] || 0 + const p2 = inputBuffer[safeIPos + this.channels + c] || 0 + const p3 = inputBuffer[safeIPos + this.channels * 2 + c] || 0 const val = 0.5 * @@ -273,6 +292,8 @@ export class ScratchTransformer extends Transform { * This allows the "disk" to be pulled backwards immediately even at the start of a chunk. */ private _compact(): void { + if (!this.inputBuffer) return + const historyFrames = this.sampleRate * 1 const keepSamples = historyFrames * this.channels @@ -288,4 +309,13 @@ export class ScratchTransformer extends Transform { this.inputReadPos -= copyStart this.inputWritePos = remaining } + + override _destroy( + _err: Error | null, + cb: (error?: Error | null) => void + ): void { + this.inputBuffer = null + this.state = null + cb(null) + } } diff --git a/src/playback/processing/TapeTransformer.ts b/src/playback/processing/TapeTransformer.ts index 39d20750..9e1b181d 100644 --- a/src/playback/processing/TapeTransformer.ts +++ b/src/playback/processing/TapeTransformer.ts @@ -30,7 +30,7 @@ export class TapeTransformer extends Transform { private tape: TapeState | null = null private _lastRampCompleted = false - private inputBuffer: Float32Array + private inputBuffer: Float32Array | null = null private inputReadPos = 0 private inputWritePos = 0 private readonly maxBufferSize: number @@ -41,7 +41,6 @@ export class TapeTransformer extends Transform { this.channels = options.channels ?? 2 this.maxBufferSize = this.sampleRate * this.channels * 10 - this.inputBuffer = new Float32Array(this.maxBufferSize) } public setRate(rate: number): void { @@ -58,6 +57,8 @@ export class TapeTransformer extends Transform { const duration = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0 this._lastRampCompleted = false + if (duration > 0) this._ensureInputBuffer() + if (type === 'start' && this.inputWritePos > 0) { const latencySamples = 1024 * this.channels this.inputReadPos = Math.max(0, this.inputWritePos - latencySamples) @@ -100,6 +101,16 @@ export class TapeTransformer extends Transform { return this.currentRate } + private _ensureInputBuffer(): Float32Array { + if (!this.inputBuffer) { + this.inputBuffer = new Float32Array(this.maxBufferSize) + this.inputReadPos = 0 + this.inputWritePos = 0 + } + + return this.inputBuffer + } + private _getCurveValue(t: number, curve: FadeCurve): number { switch (curve) { case 'linear': @@ -130,9 +141,17 @@ export class TapeTransformer extends Transform { public process(chunk: Buffer): Buffer { if (chunk.length === 0) return chunk + if ( + !this.tape && + Math.abs(this.currentRate - 1.0) <= 0.001 && + this.inputWritePos <= this.inputReadPos + this.channels + ) { + return chunk + } const incomingSamples = chunk.length / 2 const incomingFrames = incomingSamples / this.channels + const inputBuffer = this._ensureInputBuffer() if (this.inputWritePos + incomingSamples > this.maxBufferSize) { this._compact() @@ -145,7 +164,7 @@ export class TapeTransformer extends Transform { } for (let i = 0; i < incomingSamples; i++) { - this.inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767 + inputBuffer[this.inputWritePos++] = chunk.readInt16LE(i * 2) / 32767 } const outI16 = new Int16Array(incomingSamples) @@ -174,12 +193,10 @@ export class TapeTransformer extends Transform { for (let c = 0; c < this.channels; c++) { const p0 = - this.inputBuffer[iPos - this.channels + c] ?? - this.inputBuffer[iPos + c] ?? - 0 - const p1 = this.inputBuffer[iPos + c] ?? 0 - const p2 = this.inputBuffer[iPos + this.channels + c] ?? 0 - const p3 = this.inputBuffer[iPos + this.channels * 2 + c] ?? 0 + inputBuffer[iPos - this.channels + c] ?? inputBuffer[iPos + c] ?? 0 + const p1 = inputBuffer[iPos + c] ?? 0 + const p2 = inputBuffer[iPos + this.channels + c] ?? 0 + const p3 = inputBuffer[iPos + this.channels * 2 + c] ?? 0 const val = 0.5 * @@ -204,7 +221,18 @@ export class TapeTransformer extends Transform { return Buffer.from(outI16.buffer, outI16.byteOffset, outI16.byteLength) } + override _destroy( + _err: Error | null, + cb: (error?: Error | null) => void + ): void { + this.inputBuffer = null + this.tape = null + cb(null) + } + private _compact(): void { + if (!this.inputBuffer) return + const integralReadPos = Math.floor(this.inputReadPos / this.channels) * this.channels const fractionalReadPos = this.inputReadPos - integralReadPos diff --git a/src/playback/processing/VolumeTransformer.ts b/src/playback/processing/VolumeTransformer.ts index e51cbc67..39bd6d6f 100644 --- a/src/playback/processing/VolumeTransformer.ts +++ b/src/playback/processing/VolumeTransformer.ts @@ -358,4 +358,20 @@ export class VolumeTransformer extends Transform implements IVolumeTransformer { callback(error as Error) } } + + override _destroy( + _err: Error | null, + cb: (error?: Error | null) => void + ): void { + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release buffer + ;(this as any).lookaheadBuffer = null + // biome-ignore lint/suspicious/noExplicitAny: checking runtime method existence + if (this.agc && typeof (this.agc as any).destroy === 'function') { + // biome-ignore lint/suspicious/noExplicitAny: calling dynamically-discovered destroy + ;(this.agc as any).destroy() + } + // biome-ignore lint/suspicious/noExplicitAny: intentional null to release reference + ;(this as any).agc = null + cb(null) + } } diff --git a/src/playback/processing/filtersManager.ts b/src/playback/processing/filtersManager.ts index 6514f102..673a696a 100644 --- a/src/playback/processing/filtersManager.ts +++ b/src/playback/processing/filtersManager.ts @@ -289,4 +289,21 @@ export class FiltersManager extends Transform implements IFiltersManager { } return filters as FilterSettings } + + override _destroy( + _err: Error | null, + cb: (error?: Error | null) => void + ): void { + for (const name in this.filterInstances) { + const instance = this.filterInstances[name] + // biome-ignore lint/suspicious/noExplicitAny: checking runtime method existence + if (instance && typeof (instance as any).destroy === 'function') { + // biome-ignore lint/suspicious/noExplicitAny: calling dynamically-discovered destroy + ;(instance as any).destroy() + } + delete this.filterInstances[name] + } + this.activeFilters = [] + cb(null) + } } diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index e113fe74..791bfae9 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -105,7 +105,7 @@ const parsePositiveIntEnv = (key: string, fallback: number): number => { const AAC_BUFFER_SIZE: number = parsePositiveIntEnv( 'NODELINK_AAC_RING_BYTES', - 2 * 1024 * 1024 + 512 * 1024 ) const AUDIO_CONSTANTS: AudioConstants = Object.freeze({ @@ -677,6 +677,7 @@ class BaseAudioResource { pipe.removeAllListeners?.() } + this.pipes.length = 0 this.stream = null this.pipes = null } diff --git a/src/typings/playback/player.types.ts b/src/typings/playback/player.types.ts index 32bd2e8b..5d4e70fa 100644 --- a/src/typings/playback/player.types.ts +++ b/src/typings/playback/player.types.ts @@ -214,6 +214,7 @@ export interface AudioMixer { layersPCM: Map ) => Buffer hasActiveLayers: () => boolean + destroy: () => this on: ( event: 'mixStarted' | 'mixEnded' | 'mixError', listener: (data: { From ba3f366926a71a026c8842eac271b1ea6484b4cd Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sat, 16 May 2026 14:18:32 -0300 Subject: [PATCH 17/58] add: more prefixes support This should help with client compatibility. --- dist/package.json | 2 +- dist/src/api/loadTracks.js | 3 +++ dist/src/sources/deezer.js | 2 +- dist/src/sources/google-tts.js | 2 +- dist/src/sources/qobuz.js | 2 +- package.json | 2 +- src/api/loadTracks.ts | 3 +++ src/sources/deezer.ts | 2 +- src/sources/google-tts.ts | 2 +- src/sources/qobuz.ts | 2 +- 10 files changed, 14 insertions(+), 8 deletions(-) diff --git a/dist/package.json b/dist/package.json index 4b9b9095..ce9b7ead 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260511.1", + "version": "3.8.1-dev.20260515.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/dist/src/api/loadTracks.js b/dist/src/api/loadTracks.js index 3c49e4ec..91ca5aad 100644 --- a/dist/src/api/loadTracks.js +++ b/dist/src/api/loadTracks.js @@ -67,6 +67,9 @@ function parseIdentifier(identifier, defaultSearchSource) { if (groups.source === 'search') { return { kind: 'unifiedSearch', query: groups.query }; } + if (groups.source.toLowerCase() === 'isrc') { + return { kind: 'search', source: normalizeDefaultSearchSource(defaultSearchSource), query: groups.query }; + } return { kind: 'search', source: groups.source, query: groups.query }; } return { diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index 747ff1d3..32caec59 100644 --- a/dist/src/sources/deezer.js +++ b/dist/src/sources/deezer.js @@ -34,7 +34,7 @@ export default class DeezerSource { /** * Search aliases handled by this source. */ - searchTerms = ['dzsearch']; + searchTerms = ['dzsearch', 'dzisrc']; /** * Recommendation aliases handled by this source. */ diff --git a/dist/src/sources/google-tts.js b/dist/src/sources/google-tts.js index b72dc722..dd1d4ff9 100644 --- a/dist/src/sources/google-tts.js +++ b/dist/src/sources/google-tts.js @@ -37,7 +37,7 @@ export default class GoogleTTSSource { this.nodelink = nodelink; this.config = this.getConfig(); this.language = this.config.language ?? 'en-US'; - this.searchTerms = ['gtts', 'speak']; + this.searchTerms = ['gtts', 'speak', 'tts']; this.baseUrl = 'https://translate.google.com'; this.priority = 50; } diff --git a/dist/src/sources/qobuz.js b/dist/src/sources/qobuz.js index 9cc1680a..c24129f8 100644 --- a/dist/src/sources/qobuz.js +++ b/dist/src/sources/qobuz.js @@ -54,7 +54,7 @@ export default class QobuzSource { constructor(nodelink) { this.nodelink = nodelink; this.config = nodelink.options; - this.searchTerms = ['qbsearch']; + this.searchTerms = ['qbsearch', 'qbisrc']; this.recommendationTerm = ['qbrec']; this.patterns = [ /https?:\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:(?:[a-z]{2}-[a-z]{2}\/)?(track|album|playlist|artist)\/(?:.+?\/)?([a-zA-Z0-9]+)|(playlist)\/(\d+))/ diff --git a/package.json b/package.json index 0b1aa45f..4283e66c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260511.1", + "version": "3.8.1-dev.20260515.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/src/api/loadTracks.ts b/src/api/loadTracks.ts index 3c485836..f89197be 100644 --- a/src/api/loadTracks.ts +++ b/src/api/loadTracks.ts @@ -99,6 +99,9 @@ function parseIdentifier( if (groups.source === 'search') { return { kind: 'unifiedSearch', query: groups.query } } + if (groups.source.toLowerCase() === 'isrc') { + return { kind: 'search', source: normalizeDefaultSearchSource(defaultSearchSource), query: groups.query } + } return { kind: 'search', source: groups.source, query: groups.query } } diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts index 77c836cd..3e37e7dd 100644 --- a/src/sources/deezer.ts +++ b/src/sources/deezer.ts @@ -630,7 +630,7 @@ export default class DeezerSource { /** * Search aliases handled by this source. */ - public readonly searchTerms = ['dzsearch'] + public readonly searchTerms = ['dzsearch', 'dzisrc'] /** * Recommendation aliases handled by this source. diff --git a/src/sources/google-tts.ts b/src/sources/google-tts.ts index 323b7e60..8400dd58 100644 --- a/src/sources/google-tts.ts +++ b/src/sources/google-tts.ts @@ -153,7 +153,7 @@ export default class GoogleTTSSource { this.nodelink = nodelink this.config = this.getConfig() this.language = this.config.language ?? 'en-US' - this.searchTerms = ['gtts', 'speak'] + this.searchTerms = ['gtts', 'speak', 'tts'] this.baseUrl = 'https://translate.google.com' this.priority = 50 } diff --git a/src/sources/qobuz.ts b/src/sources/qobuz.ts index cdea91f8..7c2389e3 100644 --- a/src/sources/qobuz.ts +++ b/src/sources/qobuz.ts @@ -87,7 +87,7 @@ export default class QobuzSource { ) { this.nodelink = nodelink this.config = nodelink.options - this.searchTerms = ['qbsearch'] + this.searchTerms = ['qbsearch', 'qbisrc'] this.recommendationTerm = ['qbrec'] this.patterns = [ /https?:\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:(?:[a-z]{2}-[a-z]{2}\/)?(track|album|playlist|artist)\/(?:.+?\/)?([a-zA-Z0-9]+)|(playlist)\/(\d+))/ From 799e2c0bdabb2ee746ddc230f2b80c8ed5540aa6 Mon Sep 17 00:00:00 2001 From: Ramkrishna <144999854+ramsquishna@users.noreply.github.com> Date: Sun, 17 May 2026 17:44:48 +0530 Subject: [PATCH 18/58] spelling mistake (#208) * fix: dynamicly spelling to dynamically Signed-off-by: Ramkrishna <144999854+ramsquishna@users.noreply.github.com> * fix: exaustive spelling to exhaustive Signed-off-by: Ramkrishna <144999854+ramsquishna@users.noreply.github.com> --------- Signed-off-by: Ramkrishna <144999854+ramsquishna@users.noreply.github.com> --- src/index.ts | 2 +- src/sources/monochrome.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 00c10fce..6ec70b6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2705,7 +2705,7 @@ process.on('unhandledRejection', (reason, promise) => { if (clusterEnabled && cluster.isPrimary) { if (config.sources?.youtube?.getOAuthToken) { - // dynamicly import OAuth (if enabled) + // dynamically import OAuth (if enabled) const OAuth = ( await import('./sources/youtube/OAuth.ts').catch((e) => { logger( diff --git a/src/sources/monochrome.ts b/src/sources/monochrome.ts index 8f2f4f4f..554c1fd5 100644 --- a/src/sources/monochrome.ts +++ b/src/sources/monochrome.ts @@ -474,7 +474,7 @@ class MonochromeSource implements SourceInstance { } } - // 3. Collection resolution (Album/Playlist) with exaustive pagination + // 3. Collection resolution (Album/Playlist) with exhaustive pagination const collectionMatch = url.match(/(album|playlist)\/([a-f0-9-]+|\d+)/) if (collectionMatch) { const type = collectionMatch[1] || '' From bfbe8cf29fbd4e4922140c1a6b6d5e68409a7290 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:18:36 -0400 Subject: [PATCH 19/58] update: flatten config schema and enforce strict unknown typing --- src/typings/config/config.types.ts | 1498 ++++++++++++++++++++-------- 1 file changed, 1103 insertions(+), 395 deletions(-) diff --git a/src/typings/config/config.types.ts b/src/typings/config/config.types.ts index 58112d7e..bed688e3 100644 --- a/src/typings/config/config.types.ts +++ b/src/typings/config/config.types.ts @@ -1,518 +1,670 @@ /** - * Nodelink configuration type based on config.default.js - * @public + * Selection of the internal engine used for networking and protocol handling. */ -export type NodelinkConfig = typeof import('../../../config.default.js').default +export type ServerEngine = 'default' | 'bun' /** - * Server configuration options + * Represents any valid JSON value. * @public */ -export interface ServerConfig { - /** - * Port number for the server to listen on - * @remarks Must be between 1 and 65535 - */ - port: number +export type JsonValue = + | string + | number + | boolean + | null + | { [key: string]: JsonValue } + | JsonValue[] - /** - * Host address to bind the server to - * @remarks Use "0.0.0.0" to listen on all interfaces - */ - host: string +/** + * Threshold configuration for a specific rolling time window. + */ +export interface LimitRule { + /** The maximum numerical threshold allowed within the window. */ + count: number - /** - * Authentication password for client connections - */ - password: string + /** The duration of the rolling sliding time window in milliseconds. */ + windowMs: number +} - /** - * Whether to use Bun's native server instead of Node.js HTTP server - * @defaultValue false - * @experimental - */ - useBunServer?: boolean +/** + * Technical parameters for a smooth audio ramp, volume fade, or filter transition. + */ +export interface AudioTransition { + /** The total duration of the transition effect in milliseconds. */ + duration: number + + /** The identifier of the mathematical curve applied to the transition. */ + curve: string + + /** The technical logic type applied during the transition. */ + type: string } /** - * Cluster configuration for multi-process deployment - * @public + * Connection and authentication details for an external proxy server. */ -export interface ClusterConfig { - /** - * Whether cluster mode is enabled - * @defaultValue false - */ - enabled: boolean +export interface ProxyEndpoint { + /** The full destination URL of the proxy server. */ + url: string - /** - * Number of worker processes to spawn - * @remarks Set to 0 for automatic (CPU count) - */ - workers: number + /** The username required for proxies using authentication. */ + username?: string - /** - * Runtime node flags for playback/source worker processes - */ - runtime?: { - /** - * Max old space size (MB) for playback workers (0 disables override) - */ - workerMaxOldSpaceMb?: number - /** - * Enables --expose-gc for playback workers - */ - workerExposeGc?: boolean - /** - * Extra Node.js argv for playback workers - */ - workerExecArgv?: string[] | string - /** - * Max old space size (MB) for source workers (0 disables override) - */ - sourceWorkerMaxOldSpaceMb?: number - /** - * Enables --expose-gc for source workers - */ - sourceWorkerExposeGc?: boolean - /** - * Extra Node.js argv for source workers - */ - sourceWorkerExecArgv?: string[] | string - } + /** The secret password associated with the username for proxy authentication. */ + password?: string +} - /** - * Specialized worker configuration for audio sources - */ - specializedSourceWorker?: { - /** - * Whether specialized source worker is enabled - * @defaultValue false - */ - enabled: boolean - } +/** + * A standard boolean toggle for enabling or disabling internal system components. + */ +export interface FeatureToggle { + /** Set to 'true' to activate the component or 'false' to completely bypass it. */ + enabled: boolean } /** - * Audio source configuration base - * @public + * Shared runtime fields consumed by several source implementations. */ export interface SourceConfigBase { - /** - * Whether this audio source is enabled - */ + /** Master switch for the source plugin. */ enabled: boolean + + /** Shared network/proxy options read by source internals. */ + network?: { + proxy?: ProxyEndpoint + } + + /** Global search options read by source internals. */ + search?: { + maxResults?: number + defaultSource?: string | string[] + unifiedSources?: string[] + resolveExternalLinks?: boolean + fetchChannelInfo?: boolean + } + + /** Shared playback options read by source internals. */ + playback?: { + maxPlaylistLength?: number + trackStuckThresholdMs?: number + playerUpdateInterval?: number + eventTimeoutMs?: number + audio?: Record + sponsorblock?: Record + } + + /** Allow additional source-specific properties. */ + [key: string]: unknown } /** - * YouTube source configuration - * @public + * Configuration for the YouTube and YouTube Music provider. */ -export interface YouTubeSourceConfig extends SourceConfigBase { - /** - * Playlist load limit - */ - playlistLoadLimit: number +export interface YouTubeSourceConfig { + /** Master switch for the YouTube source plugin. */ + enabled: boolean + + /** List of specific YouTube 'itag' numbers allowed for audio selection. */ + allowItag: number[] + + /** Hard-code a specific 'itag' for all audio streams. */ + targetItag: number | null + + /** If true, utilize OAuth2 refresh tokens for private content access. */ + getOAuthToken: boolean + + /** The 'hl' (Host Language) parameter for metadata localization. */ + hl: string + + /** The 'gl' (Geographic Location) parameter for regional content availability. */ + gl: string + + /** Pool of proxy endpoints used exclusively for YouTube traffic. */ + proxies: ProxyEndpoint[] + + /** Ordered list of alternative sources tried if YouTube resolution fails. */ + fallbackSources: string[] + + /** Configuration for InnerTube clients. */ + clients: { + /** Ordered priority of clients used for search. */ + search: string[] + + /** Ordered priority of clients used for audio streaming. */ + playback: string[] + + /** Ordered priority of clients used for metadata resolution. */ + resolve: string[] + + /** Granular settings for individual client groups. */ + settings: { + /** Settings for the 'YouTube on TV' client. */ + TV: { + /** OAuth2 refresh tokens for session rotation. */ + refreshToken: string[] + } + } + } + + /** Configuration for signature deciphering. */ + cipher: { + /** Base URL of the deciphering service. */ + url: string + + /** Optional private access token. */ + token: string | null + } + + /** Initial visitor data for InnerTube requests. */ + visitorData?: string /** - * Album load limit + * Allow additional unknown configuration keys. */ - albumLoadLimit: number + [key: string]: unknown } /** - * Spotify source configuration - * @public + * Configuration for the Spotify metadata resolution provider. */ -export interface SpotifySourceConfig extends SourceConfigBase { - /** - * Client ID for official API access. - */ - clientId?: string +export interface SpotifySourceConfig { + /** Enables the Spotify resolution plugin. */ + enabled: boolean - /** - * Client secret for official API access. - */ - clientSecret?: string + /** Official Spotify Application Client ID. */ + clientId: string - /** - * Optional external URL for anonymous token generation. - */ - externalAuthUrl?: string + /** Official Spotify Application Client Secret. */ + clientSecret: string - /** - * Spotify sp_dc cookie for mobile token generation. - */ - sp_dc?: string + /** URL of an external service for automated token generation. */ + externalAuthUrl: string - /** - * Market code for search and resolution. - * @defaultValue "US" - */ - market?: string + /** Default market code (ISO 3166-1 alpha-2) for track availability. */ + market: string - /** - * Playlist load limit. - */ + /** Maximum pages to load from a playlist (0 = no limit). */ playlistLoadLimit: number - /** - * Album load limit. - */ - albumLoadLimit: number + /** Simultaneous requests allowed when fetching playlist pages. */ + playlistPageLoadConcurrency: number - /** - * Maximum concurrent requests during playlist loading. - */ - playlistPageLoadConcurrency?: number + /** Maximum pages to load from an album. */ + albumLoadLimit: number - /** - * Maximum concurrent requests during album loading. - */ - albumPageLoadConcurrency?: number + /** Simultaneous requests allowed when fetching album pages. */ + albumPageLoadConcurrency: number - /** - * Whether to allow explicit tracks in search/recommendations. - */ - allowExplicit?: boolean + /** If false, attempts to resolve a non-explicit version of the track. */ + allowExplicit: boolean - /** - * Whether Spotify playlist local files should be included. - */ + /** If true, keeps 'Local Files' as placeholder tracks. */ allowLocalFiles: boolean + + /** The 'sp_dc' cookie for mobile token generation. */ + sp_dc: string } /** - * Audius source configuration - * @public + * Configuration for the Apple Music resolution provider. */ -export interface AudiusSourceConfig extends SourceConfigBase { - /** - * App name - */ - appName: string +export interface AppleMusicSourceConfig { + /** Enables the Apple Music resolution plugin. */ + enabled: boolean - /** - * API key - */ - apiKey: string + /** Developer Media API Token. */ + mediaApiToken: string - /** - * API secret - */ - apiSecret: string + /** Target storefront market code (ISO 3166-1 alpha-2). */ + market: string - /** - * Playlist load limit - */ + /** Hard limit for tracks from a playlist. */ playlistLoadLimit: number - /** - * Album load limit - */ + /** Hard limit for tracks from an album. */ albumLoadLimit: number + + /** Simultaneous connections for playlist fetching. */ + playlistPageLoadConcurrency: number + + /** Simultaneous connections for album fetching. */ + albumPageLoadConcurrency: number + + /** Whether to allow tracks marked as explicit. */ + allowExplicit: boolean } /** - * Yandex Music source configuration - * @public + * Configuration for the VK Music provider. */ -export interface YandexMusicSourceConfig extends SourceConfigBase { - /** - * Access token - */ - accessToken: string +export interface VKMusicSourceConfig extends SourceConfigBase { + /** Enables the VK Music source plugin. */ + enabled: boolean - /** - * Playlist load limit - */ - playlistLoadLimit: number + /** API User Token for authenticated requests. */ + userToken: string - /** - * Album load limit - */ - albumLoadLimit: number + /** Full session cookie string for browser emulation. */ + userCookie: string - /** - * Artist load limit - */ - artistLoadLimit: number + /** Dedicated proxy for VK-related requests. */ + proxy: ProxyEndpoint - /** - * Whether to include unavailable tracks - */ - allowUnavailable: boolean + /** Runtime-compatible network proxy wrapper. */ + network: { + proxy: ProxyEndpoint + } +} - /** - * Whether to allow explicit content - */ - allowExplicit: boolean +/** + * Configuration for the Deezer media provider. + */ +export interface DeezerSourceConfig { + /** Enables the Deezer source plugin. */ + enabled: boolean - /** - * Proxy settings for the request. - */ - proxy?: import('../utils.types.ts').HttpProxyConfig + /** User 'arl' cookie for authenticated requests. */ + arl: string + + /** Optional manual decryption key for streams. */ + decryptionKey: string } /** - * VK Music source configuration - * @public + * Configuration for the Tidal high-fidelity music provider. */ -export interface VKMusicSourceConfig extends SourceConfigBase { - /** - * Access token for the user - */ - userToken?: string +export interface TidalSourceConfig { + /** Enables the Tidal source plugin. */ + enabled: boolean - /** - * User cookie for authentication fallback - */ - userCookie?: string + /** Personal API Access Token. */ + token: string - /** - * Proxy settings for the request. - */ - proxy?: import('../utils.types.ts').HttpProxyConfig + /** ISO 3166-1 alpha-2 country code for availability. */ + countryCode: string + + /** Maximum pages loaded per playlist resolution. */ + playlistLoadLimit: number + + /** Number of simultaneous page downloads. */ + playlistPageLoadConcurrency: number + + /** Array of private Hi-Fi API endpoint URLs. */ + hifiApis: string[] + + /** Ordered list of preferred audio quality tiers. */ + hifiQualities: string[] } /** - * Bilibili source configuration - * @public + * Configuration for the JioSaavn Indian music provider. */ -export interface BilibiliSourceConfig extends SourceConfigBase { - /** - * SESSDATA cookie for authentication - */ - sessdata?: string +export interface JioSaavnSourceConfig extends SourceConfigBase { + /** Enables the JioSaavn source plugin. */ + enabled: boolean - /** - * Proxy settings for the request. - */ - proxy?: import('../utils.types.ts').HttpProxyConfig + /** Maximum tracks to load from a playlist. */ + playlistLoadLimit: number + + /** Maximum tracks to load for a single artist. */ + artistLoadLimit: number + + /** Proxy settings for regional access. */ + proxy: ProxyEndpoint + + /** Runtime-compatible network proxy wrapper. */ + network: { + proxy: ProxyEndpoint + } + + /** Optional custom decryption key for streams. */ + secretKey: string } /** - * Eternalbox source configuration - * @public + * Configuration for the Gaana Indian music provider. */ -export interface EternalboxSourceConfig extends SourceConfigBase { - /** - * Base URL for the Eternalbox mirror - * @defaultValue 'https://eternalboxmirror.xyz' - */ - baseUrl: string +export interface GaanaSourceConfig extends SourceConfigBase { + /** Enables the Gaana source plugin. */ + enabled: boolean - /** - * Whether to enable the eternal (infinite) stream loop - * @defaultValue true - */ - eternalStream: boolean + /** Target stream quality profile ('high', 'medium', 'low'). */ + streamQuality: string - /** - * Whether to signal the stream as infinite to the client - * @defaultValue true - */ - infiniteStream: boolean + /** Maximum tracks from a Gaana playlist. */ + playlistLoadLimit: number - /** - * Whether to enrich the track with Spotify metadata if available - * @defaultValue false - */ - enrichSpotify: boolean + /** Maximum tracks from a Gaana album. */ + albumLoadLimit: number - /** - * Maximum search results to return - * @defaultValue 10 - */ + /** Maximum tracks for a single Gaana artist. */ + artistLoadLimit: number + + /** Proxy configuration for gaana requests. */ + proxy: ProxyEndpoint + + /** Runtime-compatible network proxy wrapper. */ + network: { + proxy: ProxyEndpoint + } +} + +/** + * Configuration for the Yandex Music (Russia) provider. + */ +export interface YandexMusicSourceConfig extends SourceConfigBase { + /** Master switch for the Yandex Music source plugin. */ + enabled: boolean + + /** User Access Token retrieved from an authenticated Yandex session. */ + accessToken: string + + /** If true, includes tracks even if marked as unavailable in the region. */ + allowUnavailable: boolean + + /** Whether to include explicit content in results. */ + allowExplicit: boolean + + /** Track limit for artist-based loading. */ + artistLoadLimit: number + + /** Track limit for album-based loading. */ + albumLoadLimit: number + + /** Track limit for playlist-based loading. */ + playlistLoadLimit: number + + /** Proxy configuration for regional Yandex access. */ + proxy: ProxyEndpoint + + /** Runtime-compatible network proxy wrapper. */ + network: { + proxy: ProxyEndpoint + } +} + +/** + * Configuration for the EternalBox loop and remix engine. + */ +export interface EternalBoxSourceConfig { + /** Enables the EternalBox infinite looping plugin. */ + enabled: boolean + + /** The base URL for the EternalBox mirror API. */ + baseUrl: string + + /** Number of search results analyzed to find the best matching track. */ searchResults: number - /** - * Maximum size of the internal audio cache in bytes - * @defaultValue 20MB - */ - cacheMaxBytes: number + /** If true, fetches additional audio feature metrics from Spotify. */ + enrichSpotify: boolean - /** - * Whether to include full audio analysis in pluginInfo - * @defaultValue true - */ + /** If true, includes full segment-by-segment audio analysis. */ includeAnalysis: boolean - /** - * Whether to include a summary of audio analysis in pluginInfo - * @defaultValue true - */ + /** If true, includes only a summarized version of the audio analysis. */ includeAnalysisSummary: boolean - /** - * Algorithm parameter: Maximum branches per beat - */ + /** Master switch for the automated eternal stream handler. */ + eternalStream: boolean + + /** Maximum memory (in bytes) for the in-memory analysis cache. */ + cacheMaxBytes: number + + /** Maximum simultaneous alternative branch paths per segment. */ maxBranches: number - /** - * Algorithm parameter: Maximum branch similarity threshold - */ + /** Maximum similarity value for a valid branch. */ maxBranchThreshold: number - /** - * Algorithm parameter: Minimum branch similarity threshold - */ + /** Initial discovery threshold for branch discovery. */ branchThresholdStart: number - /** - * Algorithm parameter: Branch threshold iteration step - */ + /** Step size when expanding the similarity search. */ branchThresholdStep: number - /** - * Algorithm parameter: Target divisor for branch count - */ + /** Divisor factor to normalize branch scores. */ branchTargetDivisor: number - /** - * Algorithm parameter: Whether to force a branch at the end - */ + /** If true, forces a branch connection at the end of the track. */ addLastEdge: boolean - /** - * Algorithm parameter: Only branch backwards - */ + /** If true, only allows branches that jump backwards in time. */ justBackwards: boolean - /** - * Algorithm parameter: Only branch to distant beats - */ + /** If true, filters out very short jump distances. */ justLongBranches: boolean - /** - * Algorithm parameter: Remove sequential (duplicate) branches - */ + /** If true, prevents repetitive A-B-A branch loops. */ removeSequentialBranches: boolean - /** - * Algorithm parameter: Filter segments for smoother branching - */ + /** If true, uses segment filtering for higher rhythmic accuracy. */ useFilteredSegments: boolean - /** - * Weight for timbre similarity - */ + /** Minimum baseline chance for a random jump. */ + minRandomBranchChance: number + + /** Maximum potential chance for a random jump. */ + maxRandomBranchChance: number + + /** Random variance delta applied per cycle. */ + randomBranchChanceDelta: number + + /** Importance of timbre/texture matching. */ timbreWeight: number - /** - * Weight for pitch similarity - */ + /** Importance of musical pitch/chroma matching. */ pitchWeight: number - /** - * Weight for loudness start similarity - */ + /** Importance of the segment start volume envelope. */ loudStartWeight: number - /** - * Weight for loudness max similarity - */ + /** Importance of the segment peak volume level. */ loudMaxWeight: number - /** - * Weight for duration similarity - */ + /** Importance of the segment duration/length. */ durationWeight: number - /** - * Weight for confidence similarity - */ + /** Importance of the algorithm's confidence score. */ confidenceWeight: number - /** - * Minimum random branch probability - */ - minRandomBranchChance: number - - /** - * Maximum random branch probability - */ - maxRandomBranchChance: number - - /** - * Random branch probability increment per beat - */ - randomBranchChanceDelta: number + /** Enables persistent infinite streaming mode. */ + infiniteStream: boolean - /** - * Maximum reconnect attempts for the stream - */ + /** Maximum reconnection attempts before giving up. */ maxReconnects: number - /** - * Delay between reconnect attempts in ms - */ + /** Delay in milliseconds between retry attempts. */ reconnectDelayMs: number } +/** @deprecated use EternalBoxSourceConfig */ +export type EternalboxSourceConfig = EternalBoxSourceConfig + /** - * JioSaavn source configuration - * @public + * Configuration for the Amazon Music provider. */ -export interface JioSaavnSourceConfig extends SourceConfigBase { - /** - * Playlist load limit - */ +export interface AmazonMusicSourceConfig extends SourceConfigBase { + enabled: boolean playlistLoadLimit: number + albumLoadLimit: number +} - /** - * Artist load limit - */ - artistLoadLimit: number +/** + * Configuration for the Bilibili provider. + */ +export interface BilibiliSourceConfig extends SourceConfigBase { + enabled: boolean + sessdata: string + network: { + proxy: ProxyEndpoint + } } /** - * Apple Music source configuration - * @public + * Configuration for the Google Drive provider. */ -export interface AppleMusicSourceConfig extends SourceConfigBase { - /** - * Media API token for authentication - */ - mediaApiToken?: string +export interface GoogleDriveSourceConfig extends SourceConfigBase { + enabled: boolean + /** Raw cookie header used to access private or restricted Drive files/folders. */ + cookies: string +} - /** - * Market/country code - */ - market?: string +/** + * Configuration for the Songlink metadata bridge. + */ +export interface SonglinkSourceConfig { + /** Master switch for the Songlink plugin. */ + enabled: boolean - /** - * Playlist load limit - */ - playlistLoadLimit: number + /** Your private API Key for the Odesli platform. */ + apiKey: string - /** - * Album load limit - */ - albumLoadLimit: number + /** ISO 3166-1 alpha-2 country code for resolution market. */ + userCountry: string - /** - * Whether to allow explicit content - */ - allowExplicit: boolean + /** If true, returns only the original song if only one match is found. */ + songIfSingle: boolean + + /** Use the official Odesli REST API. */ + useApi: boolean + + /** Use web scraping as a fallback if the API fails or is disabled. */ + useScrapeFallback: boolean + + /** Ordered list of platforms to prefer during link resolution. */ + preferredPlatforms: string[] + + /** If true, falls back to any available platform if preferred ones fail. */ + fallbackToAny: boolean } /** - * Amazon Music source configuration - * @public + * Direct HTTP/HTTPS stream resolver configuration. */ -export interface AmazonMusicSourceConfig extends SourceConfigBase { - /** - * Playlist load limit - */ +export interface HttpSourceConfig { + /** Enables the direct URL resolution plugin. */ + enabled: boolean + + /** Custom 'User-Agent' string sent with every outgoing request. */ + userAgent: string +} + +/** + * Configuration for the Flowery Text-to-Speech provider. + */ +export interface FlowerySourceConfig { + /** Enables the Flowery TTS plugin. */ + enabled: boolean + + /** Identifier of the default voice used for synthesis. */ + voice: string + + /** If true, automatically translates input text before synthesis. */ + translate: boolean + + /** Duration of trailing silence (ms) added to the end of the clip. */ + silence: number + + /** Playback speed multiplier (e.g., 1.0 = normal). */ + speed: number + + /** If true, ignores user-provided synthesis overrides. */ + enforceConfig: boolean +} + +/** + * Configuration for the LazyPy Text-to-Speech aggregator. + */ +export interface LazyPySourceConfig { + /** Enables the LazyPy TTS plugin. */ + enabled: boolean + + /** Identifier of the underlying synthesis service. */ + service: string + + /** Identifier of the default voice for the selected service. */ + voice: string + + /** Hard character limit per synthesis request. */ + maxTextLength: number + + /** If true, forces server-side configuration over user requests. */ + enforceConfig: boolean +} + +/** + * Configuration for the local Piper neural Text-to-Speech engine. + */ +export interface PiperSourceConfig { + /** Enables the local Piper neural TTS plugin. */ + enabled: boolean + + /** The HTTP/WebSocket URL of the local Piper server instance. */ + url: string + + /** Identifier of the neural voice model file (ONNX). */ + voice: string + + /** Speaker index for models containing multiple different voices. */ + speaker: number + + /** Optional speaker identifier for backends that expect string IDs. */ + speaker_id?: string | number + + /** Tempo/Speed multiplier (e.g., 1.0 is normal). */ + length_scale: number + + /** Phoneme-level noise variation for more 'human' expression. */ + noise_scale: number + + /** Duration-level noise variation for rhythmic naturalness. */ + noise_w_scale: number +} + +/** + * Configuration for the Audius decentralized music provider. + */ +export interface AudiusSourceConfig { + /** Enables the Audius source plugin. */ + enabled: boolean + + /** Unique application name for Audius API identification. */ + appName: string + + /** Public API service key. */ + apiKey: string + + /** Private API service secret. */ + apiSecret: string + + /** Maximum tracks loaded from an Audius playlist. */ playlistLoadLimit: number - /** - * Album load limit - */ + /** Maximum tracks loaded from an Audius album. */ albumLoadLimit: number } +/** + * Configuration for the Qobuz high-resolution music provider. + */ +export interface QobuzSourceConfig { + /** Master switch for the Qobuz source plugin. */ + enabled: boolean + + /** Personal User Token retrieved from an authenticated Qobuz session. */ + userToken: string + + /** Preferred audio format/quality identifier. */ + formatId: string + + /** Whether to include tracks marked with explicit content. */ + allowExplicit: boolean +} + +/** + * Configuration for the Monochrome decentralized music API. + */ /** * Monochrome source configuration * @public @@ -522,13 +674,13 @@ export interface MonochromeSourceConfig extends SourceConfigBase { * List of API instances to use for metadata and search. * @remarks These instances handle track info, search queries, and collection metadata. */ - instances?: string[] + instances: string[] /** * List of streaming instances used for manifest resolution. * @remarks These instances are specifically used to resolve playable stream URIs. */ - streamingInstances?: string[] + streamingInstances: string[] /** * List of Qobuz proxy instances used as fallback for stream resolution. @@ -544,7 +696,7 @@ export interface MonochromeSourceConfig extends SourceConfigBase { * - `HIGH`: High quality compressed (AAC 320kbps). * - `LOW`: Low quality compressed (AAC 96kbps). */ - quality?: 'HI_RES_LOSSLESS' | 'LOSSLESS' | 'HIGH' | 'LOW' + quality: 'HI_RES_LOSSLESS' | 'LOSSLESS' | 'HIGH' | 'LOW' | string } /** @@ -559,44 +711,600 @@ export interface VoiceReceiveConfig { enabled: boolean /** - * Audio format for received voice data - * @remarks - * - `pcm`: Raw PCM audio data - * - `opus`: Opus-encoded audio data + * The format to receive audio in + * @defaultValue 'pcm' */ format: 'pcm' | 'opus' } /** - * Rate limiting configuration - * @public + * Configuration for the Deezer media provider. */ -export interface RateLimitConfig { - /** - * Duration of the rate limit window in milliseconds - */ - duration: number +export interface DeezerSourceConfig { + /** Master switch for the Deezer source plugin. */ + enabled: boolean - /** - * Maximum number of requests allowed within the window - */ - maxRequests: number + /** User 'arl' cookie for authenticated Deezer requests. */ + arl: string + + /** Optional manual decryption key for Deezer audio streams. */ + decryptionKey: string } /** - * Metrics collection configuration - * @public + * Configuration for the Pandora music provider. */ -export interface MetricsConfig { - /** - * Whether metrics collection is enabled - * @defaultValue false - */ +export interface PandoraSourceConfig { + /** Master switch for the Pandora source plugin. */ enabled: boolean - /** - * Interval for collecting metrics in milliseconds - * @defaultValue 5000 - */ - interval?: number + /** Manual session/CSRF token for bypassing regional gatekeeping. */ + csrfToken: string + + /** URL of a remote microservice for token rotation. */ + remoteTokenUrl: string +} + +/** + * Registry of all available content provider configurations. + */ +export interface SourcesRegistry { + /** YouTube and YouTube Music. */ + youtube: YouTubeSourceConfig + + /** Spotify metadata resolution. */ + spotify: SpotifySourceConfig + + /** Apple Music resolution. */ + applemusic: AppleMusicSourceConfig + + /** VK Music resolution. */ + vkmusic: VKMusicSourceConfig + + /** Deezer resolution. */ + deezer: DeezerSourceConfig + + /** Tidal resolution. */ + tidal: TidalSourceConfig + + /** JioSaavn resolution. */ + jiosaavn: JioSaavnSourceConfig + + /** Gaana resolution. */ + gaana: GaanaSourceConfig + + /** Yandex Music resolution. */ + yandexmusic: YandexMusicSourceConfig + + /** EternalBox loop engine. */ + eternalbox: EternalBoxSourceConfig + + /** Songlink bridge. */ + songlink: SonglinkSourceConfig + + /** Direct HTTP/HTTPS. */ + http: HttpSourceConfig + + /** Flowery TTS. */ + flowery: FlowerySourceConfig + + /** LazyPy TTS aggregator. */ + lazypytts: LazyPySourceConfig + + /** Local Piper neural TTS. */ + pipertts: PiperSourceConfig + + /** Audius decentralized music. */ + audius: AudiusSourceConfig + + /** Qobuz high-resolution. */ + qobuz: QobuzSourceConfig + + /** Monochrome API. */ + monochrome: MonochromeSourceConfig + + /** Pandora resolution. */ + pandora: PandoraSourceConfig + + /** Amazon Music resolution. */ + amazonmusic: AmazonMusicSourceConfig + + /** BlueSky post-audio. */ + bluesky: FeatureToggle & { + /** Optional source-local override for search result limit. */ + maxSearchResults?: number + } + + /** MENA region resolution. */ + anghami: FeatureToggle & { + /** Session cookies for anghami. */ + cookies: string + } + + /** RSS feed extractor. */ + rss: FeatureToggle + + /** Mixcloud resolution. */ + mixcloud: FeatureToggle + + /** Audiomack resolution. */ + audiomack: FeatureToggle + + /** Bandcamp resolution. */ + bandcamp: FeatureToggle + + /** SoundCloud resolution. */ + soundcloud: FeatureToggle & { + /** Optional SoundCloud API Client ID. */ + clientId: string + } + + /** Local filesystem. */ + local: FeatureToggle & { + /** Local root path for music scanning. */ + basePath: string + } + + /** Vimeo video-audio. */ + vimeo: FeatureToggle + + /** iHeartRadio resolution. */ + iheartradio: FeatureToggle + + /** Telegram file resolution. */ + telegram: FeatureToggle + + /** Shazam recognition. */ + shazam: FeatureToggle & { + /** Filter explicit content in Shazam results. */ + allowExplicit: boolean + } + + /** Bilibili video-audio. */ + bilibili: BilibiliSourceConfig + + /** Genius metadata. */ + genius: FeatureToggle + + /** Pinterest video-audio. */ + pinterest: FeatureToggle + + /** Google Text-to-Speech. */ + 'google-tts': FeatureToggle & { + /** Google TTS language tag. */ + language: string + } + + /** Instagram post-audio. */ + instagram: FeatureToggle + + /** Kwai video-audio. */ + kwai: FeatureToggle + + /** Twitch stream/clip. */ + twitch: FeatureToggle + + /** NicoVideo resolution. */ + nicovideo: FeatureToggle + + /** Reddit video-audio. */ + reddit: FeatureToggle + + /** Tumblr post-audio. */ + tumblr: FeatureToggle + + /** Twitter (X) post-audio. */ + twitter: FeatureToggle + + /** Last.fm enrichment. */ + lastfm: FeatureToggle & { + /** Last.fm API Key. */ + apiKey: string + } + + /** NetEase resolution. */ + netease: FeatureToggle + + /** Letras.mus.br lyrics. */ + letrasmus: FeatureToggle + + /** Google Drive files. */ + googledrive: GoogleDriveSourceConfig + + /** TikTok video-audio. */ + tiktok: FeatureToggle + + /** Allow dynamic indexing of sources. */ + [key: string]: FeatureToggle | SourceConfigBase | undefined +} + +/** + * Infrastructure settings for the core server. + */ +export interface ServerSection { + /** Bind host address. */ + host: string + + /** API/WebSocket port. */ + port: number + + /** Master auth password. */ + password?: string + + /** Enable experimental Bun server engine. */ + useBunServer: boolean +} + +/** + * System-wide security and mitigations. + */ +export interface SecuritySection { + /** Master auth password. */ + password?: string + + /** Trust X-Forwarded-For headers. */ + trustProxy: boolean + + /** Anti-flooding protection. */ + dosProtection: { + enabled: boolean + thresholds: { + /** Max requests in window. */ + burstRequests: number + /** Sliding window size (ms). */ + timeWindowMs: number + } + mitigation: { + /** Artificial response delay (ms). */ + delayMs: number + /** access block duration (ms). */ + blockDurationMs: number + } + ignore: { + userIds: string[] + guildIds: string[] + ips: string[] + } + } + + /** fair-use API throttling. */ + rateLimit: { + enabled: boolean + maxEntries: number + global: { + maxRequests: number + timeWindowMs: number + } + perIp: { + maxRequests: number + timeWindowMs: number + } + perUserId: { + maxRequests: number + timeWindowMs: number + } + perGuildId: { + maxRequests: number + timeWindowMs: number + } + /** Paths that bypass rate limiting. */ + ignorePaths: string[] + ignore: { + userIds: string[] + guildIds: string[] + ips: string[] + } + } + + /** Allow additional security-specific properties. */ + [key: string]: unknown +} + +/** + * Multi-process cluster and scaling settings. + */ +export interface ClusterSection { + /** Active cluster mode. */ + enabled: boolean + + /** Dedicated playback workers count (0 = auto). */ + workers: number + + /** Standby workers pool size. */ + minWorkers: number + + /** Runtime flags for worker processes. */ + runtime: { + workerMaxOldSpaceMb: number + workerExposeGc: boolean + workerExecArgv: string[] + sourceWorkerMaxOldSpaceMb: number + sourceWorkerExposeGc: boolean + sourceWorkerExecArgv: string[] + } + + /** Offloads heavy metadata tasks to dedicated processes. */ + specializedSourceWorker: { + enabled: boolean + count: number + microWorkers: number + tasksPerWorker: number + silentLogs: boolean + } + + /** Heavy command timeout (ms). */ + commandTimeout: number + + /** Player control command timeout (ms). */ + fastCommandTimeout: number + + /** Hierarchical timeout aliases for migration compatibility. */ + timeouts?: { + heavyMs: number + fastMs: number + } + + /** IPC retry attempts. */ + maxRetries: number + + /** Process suspension logic. */ + hibernation: { + enabled: boolean + timeoutMs: number + } + + /** Load balancer orchestration. */ + scaling: { + maxPlayersPerWorker: number + targetUtilization: number + scaleUpThreshold: number + scaleDownThreshold: number + checkIntervalMs: number + idleWorkerTimeoutMs: number + queueLengthScaleUpFactor: number + lagPenaltyLimit: number + cpuPenaltyLimit: number + } + + /** cluster control API. */ + endpoint: { + patchEnabled: boolean + allowExternalPatch: boolean + code: string + } +} + +/** + * System-wide logging and debugging engine. + */ +export interface LoggingSection { + /** log level (e.g. 'info', 'debug'). */ + level: string + + /** Disk logging settings. */ + file: { + enabled: boolean + path: string + rotation: string + ttlDays: number + } + + /** Granular debug flags. */ + debug: { + all: boolean + request: boolean + session: boolean + player: boolean + filters: boolean + sources: boolean + lyrics: boolean + youtube: boolean + 'youtube-cipher': boolean + sabr: boolean + potoken: boolean + } +} + +/** + * Connectivity health and multi-IP rotation. + */ +export interface NetworkSection { + /** Shared outbound proxy for sources and internals. */ + proxy: { + enabled: boolean + strategy: string + retries: number + timeout: number + shuffleOnStart: boolean + list: ProxyEndpoint[] + } + + /** Background connection probing. */ + connection: { + logAllChecks: boolean + interval: number + timeout: number + thresholds: { + bad: number + average: number + } + } + + /** Outbound IP rotation. */ + routePlanner: { + strategy: string + bannedIpCooldown: number + ipBlocks: Array + } +} + +/** + * Configuration for audio filters. + */ +export interface FilterConfig { + /** + * Toggles for individual audio filters. + */ + enabled: { + /** tremolo filter. */ + tremolo: boolean + /** vibrato filter. */ + vibrato: boolean + /** lowpass filter. */ + lowpass: boolean + /** highpass filter. */ + highpass: boolean + /** rotation filter. */ + rotation: boolean + /** karaoke filter. */ + karaoke: boolean + /** distortion filter. */ + distortion: boolean + /** channelMix filter. */ + channelMix: boolean + /** equalizer filter. */ + equalizer: boolean + /** chorus filter. */ + chorus: boolean + /** compressor filter. */ + compressor: boolean + /** echo filter. */ + echo: boolean + /** phaser filter. */ + phaser: boolean + /** timescale filter. */ + timescale: boolean + } +} + +/** + * The consolidated NodeLink configuration schema. + */ +export interface NodelinkConfig { + server: ServerSection + cluster: ClusterSection + logging: LoggingSection + connection: NetworkSection['connection'] + rateLimit: SecuritySection['rateLimit'] + dosProtection: SecuritySection['dosProtection'] + trustProxy: boolean + network: NetworkSection + search: { + maxResults: number + defaultSource: string | string[] + unifiedSources: string[] + resolveExternalLinks: boolean + fetchChannelInfo: boolean + } + playback: { + maxPlaylistLength: number + playerUpdateInterval: number + statsUpdateInterval: number + trackStuckThresholdMs: number + eventTimeoutMs: number + zombieThresholdMs: number + sponsorblock: { + enabled: boolean + api: string + categories: string[] + actionTypes: string[] + skipMarginMs: number + } + filters: FilterConfig + audio: { + quality: string + encryption: string + resamplingQuality: string + loudnessNormalizer: boolean + lookaheadMs: number + gateThresholdLUFS: number + fading: { + enabled: boolean + trackStart: AudioTransition + trackEnd: AudioTransition + trackStop: AudioTransition + seek: AudioTransition + pause: AudioTransition + resume: AudioTransition + ducking: { + enabled: boolean + duration: number + targetVolume: number + curve: string + } + } + crossfade: { + enabled: boolean + duration: number + curve: string + mode: 'preload' | 'stream' + minBufferMs: number + bufferMs: number + } + } + voiceReceive: VoiceReceiveConfig + mix: { + enabled: boolean + defaultVolume: number + maxLayersMix: number + autoCleanup: boolean + } + } + api: { + enableTrackStreamEndpoint: boolean + enableLoadStreamEndpoint: boolean + metrics: { + enabled: boolean + authorization: { + type: 'Bearer' | 'Basic' + username: string + password: string + } + } + } + experimental: { + enableHoloTracks: boolean + } + + /** provider-specific configurations. */ + sources: SourcesRegistry + + /** Lyrics extraction settings. */ + lyrics: { + fallbackSource: string + [source: string]: unknown + } + + /** track meaning providers. */ + meanings: { + [key: string]: FeatureToggle | undefined + } + + /** scraper metrics endpoint. */ + metrics: { + enabled: boolean + authorization: { + type: 'Bearer' | 'Basic' | string + username: string + password: string + } + } + + /** Multi-audio layering mixer. */ + mix: { + enabled: boolean + defaultVolume: number + maxLayersMix: number + autoCleanup: boolean + } + + /** Registry of addons to load. */ + plugins: Array<{ + name: string + source: string + path?: string + }> + + /** Passthrough addon settings. */ + pluginConfig: Record> } From f06155c552d8d239e192f2e953540b3e3f4e7b2e Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:19:55 -0400 Subject: [PATCH 20/58] update: sync default configuration with new schema --- config.default.ts | 790 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 790 insertions(+) create mode 100644 config.default.ts diff --git a/config.default.ts b/config.default.ts new file mode 100644 index 00000000..204060a8 --- /dev/null +++ b/config.default.ts @@ -0,0 +1,790 @@ +import type { NodelinkConfig } from './src/typings/config/config.types.ts' + +export const config: NodelinkConfig = { + server: { + host: '0.0.0.0', + port: 3000, + password: 'youshallnotpass', + useBunServer: false + }, + + cluster: { + enabled: true, + workers: 0, + minWorkers: 1, + runtime: { + workerMaxOldSpaceMb: 0, + workerExposeGc: false, + workerExecArgv: [], + sourceWorkerMaxOldSpaceMb: 0, + sourceWorkerExposeGc: false, + sourceWorkerExecArgv: [] + }, + specializedSourceWorker: { + enabled: true, + count: 1, + microWorkers: 2, + tasksPerWorker: 32, + silentLogs: true + }, + timeouts: { + heavyMs: 6000, + fastMs: 4000 + }, + commandTimeout: 6000, + fastCommandTimeout: 4000, + maxRetries: 2, + hibernation: { + enabled: true, + timeoutMs: 1200000 + }, + scaling: { + maxPlayersPerWorker: 20, + targetUtilization: 0.7, + scaleUpThreshold: 0.75, + scaleDownThreshold: 0.3, + checkIntervalMs: 5000, + idleWorkerTimeoutMs: 60000, + queueLengthScaleUpFactor: 5, + lagPenaltyLimit: 60, + cpuPenaltyLimit: 0.85 + }, + endpoint: { + patchEnabled: true, + allowExternalPatch: false, + code: 'CAPYBARA' + } + }, + + logging: { + level: 'debug', + file: { + enabled: false, + path: 'logs', + rotation: 'daily', + ttlDays: 7 + }, + debug: { + all: false, + request: true, + session: true, + player: true, + filters: true, + sources: true, + lyrics: true, + youtube: true, + 'youtube-cipher': true, + sabr: false, + potoken: false + } + }, + + connection: { + logAllChecks: false, + interval: 300000, + timeout: 10000, + thresholds: { + bad: 1, + average: 5 + } + }, + + rateLimit: { + enabled: true, + maxEntries: 10000, + global: { + maxRequests: 1000, + timeWindowMs: 60000 + }, + perIp: { + maxRequests: 100, + timeWindowMs: 10000 + }, + perUserId: { + maxRequests: 50, + timeWindowMs: 5000 + }, + perGuildId: { + maxRequests: 20, + timeWindowMs: 5000 + }, + ignorePaths: [], + ignore: { + userIds: [], + guildIds: [], + ips: [] + } + }, + + dosProtection: { + enabled: true, + thresholds: { + burstRequests: 50, + timeWindowMs: 10000 + }, + mitigation: { + delayMs: 500, + blockDurationMs: 300000 + }, + ignore: { + userIds: [], + guildIds: [], + ips: [] + } + }, + + trustProxy: false, + + network: { + proxy: { + enabled: false, + strategy: 'RoundRobin', + retries: 3, + timeout: 10000, + shuffleOnStart: true, + list: [] + }, + connection: { + logAllChecks: false, + interval: 300000, + timeout: 10000, + thresholds: { + bad: 1, + average: 5 + } + }, + routePlanner: { + strategy: 'RotateOnBan', + bannedIpCooldown: 600000, + ipBlocks: [] + } + }, + + search: { + maxResults: 10, + defaultSource: ['youtube', 'soundcloud'], + unifiedSources: ['youtube', 'soundcloud'], + resolveExternalLinks: false, + fetchChannelInfo: false + }, + + playback: { + maxPlaylistLength: 100, + playerUpdateInterval: 2000, + statsUpdateInterval: 30000, + trackStuckThresholdMs: 10000, + eventTimeoutMs: 15000, + zombieThresholdMs: 60000, + sponsorblock: { + enabled: false, + api: 'https://sponsor.ajay.app', + categories: [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'preview', + 'music_offtopic', + 'filler' + ], + actionTypes: ['skip'], + skipMarginMs: 150 + }, + filters: { + enabled: { + tremolo: true, + vibrato: true, + lowpass: true, + highpass: true, + rotation: true, + karaoke: true, + distortion: true, + channelMix: true, + equalizer: true, + chorus: true, + compressor: true, + echo: true, + phaser: true, + timescale: true + } + }, + audio: { + quality: 'high', + encryption: 'aead_xchacha20_poly1305_rtpsize', + resamplingQuality: 'best', + loudnessNormalizer: false, + lookaheadMs: 5, + gateThresholdLUFS: -60, + fading: { + enabled: false, + trackStart: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + trackEnd: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + trackStop: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + seek: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + pause: { + duration: 0, + curve: 'sinusoidal', + type: 'tape' + }, + resume: { + duration: 0, + curve: 'sinusoidal', + type: 'tape' + }, + ducking: { + enabled: false, + duration: 0, + targetVolume: 0.3, + curve: 'linear' + } + }, + crossfade: { + enabled: false, + duration: 0, + curve: 'sinusoidal', + mode: 'preload', + minBufferMs: 250, + bufferMs: 0 + } + }, + voiceReceive: { + enabled: false, + format: 'opus' + }, + mix: { + enabled: true, + defaultVolume: 0.8, + maxLayersMix: 5, + autoCleanup: true + } + }, + + api: { + enableTrackStreamEndpoint: false, + enableLoadStreamEndpoint: false, + metrics: { + enabled: true, + authorization: { + type: 'Bearer', + username: 'admin', + password: '' + } + } + }, + + experimental: { + enableHoloTracks: false + }, + + sources: { + youtube: { + enabled: true, + allowItag: [], + targetItag: null, + getOAuthToken: false, + hl: 'en', + gl: 'US', + proxies: [], + fallbackSources: [ + 'soundcloud', + 'deezer', + 'jiosaavn', + 'qobuz', + 'gaana', + 'vkmusic', + 'yandexmusic', + 'audiomack', + 'bandcamp', + 'audius', + 'mixcloud', + 'bilibili', + 'bluesky', + 'nicovideo' + ], + clients: { + search: ['Android'], + playback: [ + 'AndroidVR', + 'TV', + 'TVCast', + 'WebEmbedded', + 'WebParentTools', + 'Web', + 'IOS' + ], + resolve: [ + 'AndroidVR', + 'TV', + 'TVCast', + 'WebEmbedded', + 'WebParentTools', + 'IOS', + 'Web' + ], + settings: { + TV: { + refreshToken: [''] + } + } + }, + cipher: { + url: 'https://cipher.kikkia.dev/api', + token: null + } + }, + + spotify: { + enabled: true, + clientId: '', + clientSecret: '', + externalAuthUrl: 'http://get.1lucas1apk.fun/spotify/gettoken', + market: 'US', + playlistLoadLimit: 1, + playlistPageLoadConcurrency: 10, + albumLoadLimit: 1, + albumPageLoadConcurrency: 5, + allowExplicit: true, + allowLocalFiles: false, + sp_dc: '' + }, + + applemusic: { + enabled: true, + mediaApiToken: 'token_here', + market: 'US', + playlistLoadLimit: 0, + albumLoadLimit: 0, + playlistPageLoadConcurrency: 5, + albumPageLoadConcurrency: 5, + allowExplicit: true + }, + + vkmusic: { + enabled: true, + userToken: '', + userCookie: '', + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + } + }, + + deezer: { + enabled: true, + arl: '', + decryptionKey: '' + }, + + tidal: { + enabled: true, + token: 'token_here', + countryCode: 'US', + playlistLoadLimit: 2, + playlistPageLoadConcurrency: 5, + hifiApis: [''], + hifiQualities: ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'] + }, + + jiosaavn: { + enabled: true, + playlistLoadLimit: 50, + artistLoadLimit: 20, + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + }, + secretKey: '38346591' + }, + + gaana: { + enabled: true, + streamQuality: 'high', + playlistLoadLimit: 100, + albumLoadLimit: 100, + artistLoadLimit: 100, + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + } + }, + + yandexmusic: { + enabled: true, + accessToken: '', + allowUnavailable: false, + allowExplicit: true, + artistLoadLimit: 1, + albumLoadLimit: 1, + playlistLoadLimit: 1, + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + } + }, + + eternalbox: { + enabled: true, + baseUrl: 'https://eternalboxmirror.xyz', + searchResults: 30, + enrichSpotify: true, + includeAnalysis: true, + includeAnalysisSummary: true, + eternalStream: true, + cacheMaxBytes: 20971520, + maxBranches: 4, + maxBranchThreshold: 75, + branchThresholdStart: 10, + branchThresholdStep: 5, + branchTargetDivisor: 6, + addLastEdge: true, + justBackwards: false, + justLongBranches: false, + removeSequentialBranches: true, + useFilteredSegments: true, + minRandomBranchChance: 0.18, + maxRandomBranchChance: 0.5, + randomBranchChanceDelta: 0.09, + timbreWeight: 1, + pitchWeight: 10, + loudStartWeight: 1, + loudMaxWeight: 1, + durationWeight: 100, + confidenceWeight: 1, + infiniteStream: true, + maxReconnects: 0, + reconnectDelayMs: 1000 + }, + + songlink: { + enabled: true, + apiKey: '', + userCountry: 'US', + songIfSingle: true, + useApi: true, + useScrapeFallback: true, + preferredPlatforms: [ + 'spotify', + 'appleMusic', + 'youtubeMusic', + 'youtube', + 'deezer', + 'tidal', + 'amazonMusic', + 'soundcloud', + 'bandcamp', + 'audius', + 'audiomack', + 'pandora', + 'itunes', + 'amazonStore' + ], + fallbackToAny: true + }, + + http: { + enabled: true, + userAgent: '' + }, + + flowery: { + enabled: true, + voice: 'Salli', + translate: false, + silence: 0, + speed: 1.0, + enforceConfig: false + }, + + lazypytts: { + enabled: true, + service: 'Cerence', + voice: 'Luciana', + maxTextLength: 3000, + enforceConfig: false + }, + + pipertts: { + enabled: false, + url: 'http://localhost:5000', + voice: 'en_US-lessac-medium', + speaker: 0, + speaker_id: '', + length_scale: 1.0, + noise_scale: 0.667, + noise_w_scale: 0.8 + }, + + audius: { + enabled: true, + appName: '', + apiKey: '', + apiSecret: '', + playlistLoadLimit: 100, + albumLoadLimit: 100 + }, + + qobuz: { + enabled: true, + userToken: '', + formatId: '5', + allowExplicit: true + }, + + monochrome: { + enabled: true, + instances: [], + streamingInstances: [], + quality: 'HI_RES_LOSSLESS' + }, + + pandora: { + enabled: true, + csrfToken: '', + remoteTokenUrl: 'https://get.1lucas1apk.fun/pandora/gettoken' + }, + + amazonmusic: { + enabled: true, + playlistLoadLimit: 0, + albumLoadLimit: 0 + }, + + bluesky: { + enabled: true, + maxSearchResults: 10 + }, + + anghami: { + enabled: false, + cookies: '' + }, + + rss: { + enabled: true + }, + + mixcloud: { + enabled: true + }, + + audiomack: { + enabled: true + }, + + bandcamp: { + enabled: true + }, + + soundcloud: { + enabled: true, + clientId: '' + }, + + local: { + enabled: true, + basePath: './local-music/' + }, + + vimeo: { + enabled: true + }, + + iheartradio: { + enabled: true + }, + + telegram: { + enabled: true + }, + + shazam: { + enabled: true, + allowExplicit: true + }, + + bilibili: { + enabled: true, + sessdata: '', + network: { + proxy: { + url: '', + username: '', + password: '' + } + } + }, + + genius: { + enabled: true + }, + + pinterest: { + enabled: true + }, + + 'google-tts': { + enabled: true, + language: 'en-US' + }, + + instagram: { + enabled: true + }, + + kwai: { + enabled: true + }, + + twitch: { + enabled: true + }, + + nicovideo: { + enabled: true + }, + + reddit: { + enabled: true + }, + + tumblr: { + enabled: true + }, + + twitter: { + enabled: true + }, + + lastfm: { + enabled: true, + apiKey: '' + }, + + netease: { + enabled: true + }, + + letrasmus: { + enabled: true + }, + + googledrive: { + enabled: true, + cookies: '' + }, + + tiktok: { + enabled: true + } + }, + + lyrics: { + fallbackSource: 'genius', + youtube: { + enabled: true + }, + genius: { + enabled: true + }, + musixmatch: { + enabled: true + }, + deezer: { + enabled: true + }, + lrclib: { + enabled: true + }, + letrasmus: { + enabled: true + }, + bilibili: { + enabled: true + }, + yandexmusic: { + enabled: true + }, + monochrome: { + enabled: true + } + }, + + meanings: { + letrasmus: { + enabled: true + }, + wikipedia: { + enabled: true + } + }, + + metrics: { + enabled: true, + authorization: { + type: 'Bearer', + username: 'admin', + password: '' + } + }, + + mix: { + enabled: true, + defaultVolume: 0.8, + maxLayersMix: 5, + autoCleanup: true + }, + + plugins: [], + pluginConfig: {} +} + +export default config From 372be74bb85886e4e894e5aa9a53ec3d282ebd36 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:20:12 -0400 Subject: [PATCH 21/58] update: sync runtime validation schema with new config structure --- src/managers/configValidationManager.ts | 1738 ++++------------------- 1 file changed, 240 insertions(+), 1498 deletions(-) diff --git a/src/managers/configValidationManager.ts b/src/managers/configValidationManager.ts index 2ffb97ed..acb5367d 100644 --- a/src/managers/configValidationManager.ts +++ b/src/managers/configValidationManager.ts @@ -1,1521 +1,263 @@ import type { NodelinkConfig } from '../typings/config/config.types.ts' +import { logger } from '../utils.ts' +import { validator } from '../validators.ts' -type ValidationRule = { - path: string - expected: string - value: unknown - validate: (v: unknown) => boolean -} - -type ValidationWarning = { - path: string - message: string -} - -const KNOWN_PLACEHOLDERS = new Set([ - 'your_token_here', - 'changeme', - 'change_me', - 'your-token-here', - 'insert_token_here', - 'placeholder', - 'PLACEHOLDER', - 'YOUR_TOKEN', - 'YOUR_API_KEY', - 'YOUR_CLIENT_ID', - 'YOUR_CLIENT_SECRET' -]) - -const VALID_AUDIO_QUALITIES = new Set(['high', 'medium', 'low', 'lowest']) -const VALID_RESAMPLING_QUALITIES = new Set([ - 'best', - 'medium', - 'fastest', - 'zero', - 'linear' -]) -const VALID_FADING_CURVES = new Set([ - 'linear', - 'exponential', - 'sinusoidal', - 'start', - 'wash', - 'stop', - 'random', - 'baby' -]) -const VALID_FADING_TYPES = new Set(['volume', 'tape', 'both', 'scratch']) -const VALID_VOICE_FORMATS = new Set(['opus', 'pcm_s16le']) -const VALID_ROUTE_STRATEGIES = new Set([ - 'RotateOnBan', - 'RoundRobin', - 'LoadBalance' -]) -const VALID_METRICS_AUTH_TYPES = new Set(['Bearer', 'Basic']) - +/** + * Validates the NodeLink configuration object using a schema-based approach. + * Supports the new hierarchical configuration structure. + */ export default class ConfigValidationManager { - private warnings: ValidationWarning[] = [] - private options: NodelinkConfig - - constructor(options: NodelinkConfig) { - this.options = options - } - - validate(): void { - this.warnings = [] - - const allRules: ValidationRule[] = [ - ...this.validateServer(), - ...this.validateCluster(), - ...this.validateAudio(), - ...this.validatePlayback(), - ...this.validateSources(), - ...this.validateSearch(), - ...this.validateRoutePlanner(), - ...this.validateRateLimit(), - ...this.validateDosProtection(), - ...this.validateLogging(), - ...this.validateConnection(), - ...this.validateVoiceReceive(), - ...this.validateMix(), - ...this.validateMetrics(), - ...this.validateFilters() - ] - - const errors: string[] = [] - for (const rule of allRules) { - if (!rule.validate(rule.value)) { - errors.push( - `Configuration error:\n` + - `- Property: ${rule.path}\n` + - `- Received: ${JSON.stringify(rule.value)}\n` + - `- Expected: ${rule.expected}` - ) - } - } - - if (this.warnings.length > 0) { - const warningLines = this.warnings - .map((w) => ` ⚠ ${w.path}: ${w.message}`) - .join('\n') - console.warn(`\n[NodeLink] Configuration warnings:\n${warningLines}\n`) - } - - if (errors.length > 0) { - throw new Error(`Configuration errors:\n\n${errors.join('\n\n')}`) - } - } - - private validateServer(): ValidationRule[] { - const server = this.options.server - - return [ - this.nonEmptyStringRule('server.host', server?.host), - this.intRangeRule('server.port', server?.port, 1, 65535), - this.nonEmptyStringRule('server.password', server?.password), - this.booleanRule('server.useBunServer', server?.useBunServer) - ] - } - - private validateCluster(): ValidationRule[] { - const cluster = this.options.cluster - if (!cluster) return [] - - const workers = cluster.workers - - const rules: ValidationRule[] = [ - this.booleanRule('cluster.enabled', cluster.enabled) - ] - - if (typeof cluster.enabled !== 'boolean') return rules - - if (cluster.enabled === false) return rules - - rules.push( - this.nonNegativeIntRule('cluster.workers', workers), - this.nonNegativeIntRule('cluster.minWorkers', cluster.minWorkers), - { - path: 'cluster.minWorkers', - expected: - workers === 0 - ? 'integer (auto-scaled workers, unknown value allowed)' - : `integer <= cluster.workers (${workers})`, - value: cluster.minWorkers, - validate: (v: unknown) => - typeof v === 'number' && - Number.isInteger(v) && - Number.isInteger(workers) && - (workers === 0 || v <= workers) - }, - this.positiveIntRule('cluster.commandTimeout', cluster.commandTimeout), - this.positiveIntRule( - 'cluster.fastCommandTimeout', - cluster.fastCommandTimeout - ), - this.nonNegativeIntRule('cluster.maxRetries', cluster.maxRetries) - ) - - if (cluster.hibernation) { - rules.push( - this.booleanRule( - 'cluster.hibernation.enabled', - cluster.hibernation.enabled - ), - this.positiveIntRule( - 'cluster.hibernation.timeoutMs', - cluster.hibernation.timeoutMs - ) - ) - } - - const ssw = cluster.specializedSourceWorker - if (ssw) { - rules.push( - this.booleanRule( - 'cluster.specializedSourceWorker.enabled', - ssw.enabled - ), - this.positiveIntRule( - 'cluster.specializedSourceWorker.count', - ssw.count - ), - this.positiveIntRule( - 'cluster.specializedSourceWorker.microWorkers', - ssw.microWorkers - ), - this.positiveIntRule( - 'cluster.specializedSourceWorker.tasksPerWorker', - ssw.tasksPerWorker - ), - this.booleanRule( - 'cluster.specializedSourceWorker.silentLogs', - ssw.silentLogs - ) - ) - } - - const runtime = cluster.runtime - if (runtime) { - rules.push( - this.nonNegativeIntRule( - 'cluster.runtime.workerMaxOldSpaceMb', - runtime.workerMaxOldSpaceMb - ), - this.nonNegativeIntRule( - 'cluster.runtime.sourceWorkerMaxOldSpaceMb', - runtime.sourceWorkerMaxOldSpaceMb - ), - this.booleanRule( - 'cluster.runtime.workerExposeGc', - runtime.workerExposeGc - ), - this.booleanRule( - 'cluster.runtime.sourceWorkerExposeGc', - runtime.sourceWorkerExposeGc - ), - this.stringArrayRule( - 'cluster.runtime.workerExecArgv', - runtime.workerExecArgv - ), - this.stringArrayRule( - 'cluster.runtime.sourceWorkerExecArgv', - runtime.sourceWorkerExecArgv - ) - ) - } - - const scaling = cluster.scaling - if (scaling) { - rules.push( - this.positiveIntRule( - 'cluster.scaling.maxPlayersPerWorker', - scaling.maxPlayersPerWorker - ), - this.positiveIntRule( - 'cluster.scaling.checkIntervalMs', - scaling.checkIntervalMs - ), - this.positiveIntRule( - 'cluster.scaling.idleWorkerTimeoutMs', - scaling.idleWorkerTimeoutMs - ), - this.positiveIntRule( - 'cluster.scaling.queueLengthScaleUpFactor', - scaling.queueLengthScaleUpFactor - ), - this.positiveIntRule( - 'cluster.scaling.lagPenaltyLimit', - scaling.lagPenaltyLimit - ), - { - path: 'cluster.scaling.scaleUpThreshold', - expected: 'number between 0 and 1 (exclusive)', - value: scaling.scaleUpThreshold, - validate: (v: unknown) => typeof v === 'number' && v > 0 && v < 1 - }, - { - path: 'cluster.scaling.cpuPenaltyLimit', - expected: 'number between 0 and 1 (exclusive)', - value: scaling.cpuPenaltyLimit, - validate: (v: unknown) => typeof v === 'number' && v > 0 && v < 1 - }, - this.floatMustBeBelow( - 'cluster.scaling.scaleDownThreshold', - scaling.scaleDownThreshold, - scaling.scaleUpThreshold, - 'cluster.scaling.scaleUpThreshold' - ), - { - path: 'cluster.scaling.targetUtilization', - expected: `number between scaleDownThreshold (${scaling.scaleDownThreshold}) and scaleUpThreshold (${scaling.scaleUpThreshold})`, - value: scaling.targetUtilization, - validate: (v: unknown) => - typeof scaling.scaleDownThreshold === 'number' && - typeof scaling.scaleUpThreshold === 'number' && - typeof v === 'number' && - v >= scaling.scaleDownThreshold && - v <= scaling.scaleUpThreshold + private config: NodelinkConfig + + constructor(config: NodelinkConfig) { + this.config = config + } + + /** + * Performs a full validation of the configuration. + * @throws Error if validation fails. + */ + public validate(): void { + const schema = { + server: { + type: 'object', + props: { + host: { type: 'string', default: '0.0.0.0' }, + port: { + type: 'number', + integer: true, + min: 1, + max: 65535, + default: 3000 + }, + engine: { + type: 'string', + enum: ['default', 'bun'], + default: 'default', + optional: true + }, + password: { type: 'string', min: 1, optional: true }, + useBunServer: { type: 'boolean', default: false, optional: true } } - ) - } - - const endpoint = cluster.endpoint - if (endpoint) { - rules.push( - this.booleanRule( - 'cluster.endpoint.patchEnabled', - endpoint.patchEnabled - ), - this.booleanRule( - 'cluster.endpoint.allowExternalPatch', - endpoint.allowExternalPatch - ), - this.nonEmptyStringRule('cluster.endpoint.code', endpoint.code) - ) - } - - return rules - } - - private validateAudio(): ValidationRule[] { - const audio = this.options.audio - - const rules: ValidationRule[] = [ - this.booleanRule('audio.loudnessNormalizer', audio?.loudnessNormalizer), - this.nonNegativeIntRule('audio.lookaheadMs', audio?.lookaheadMs), - { - path: 'audio.gateThresholdLUFS', - expected: 'number <= 0', - value: audio?.gateThresholdLUFS, - validate: (v: unknown) => typeof v === 'number' && v <= 0 }, - this.enumRule('audio.quality', audio?.quality, VALID_AUDIO_QUALITIES), - this.enumRule( - 'audio.resamplingQuality', - audio?.resamplingQuality, - VALID_RESAMPLING_QUALITIES - ) - ] - - const fading = audio?.fading - if (fading) { - rules.push(this.booleanRule('audio.fading.enabled', fading.enabled)) - - const fadingEvents = [ - 'trackStart', - 'trackEnd', - 'trackStop', - 'seek', - 'pause', - 'resume' - ] as const - for (const event of fadingEvents) { - const ev = fading[event] - if (!ev) continue - rules.push( - this.nonNegativeIntRule( - `audio.fading.${event}.duration`, - ev.duration - ), - this.enumRule( - `audio.fading.${event}.curve`, - ev.curve, - VALID_FADING_CURVES - ), - this.enumRule( - `audio.fading.${event}.type`, - ev.type, - VALID_FADING_TYPES - ) - ) - } - - const ducking = fading.ducking - if (ducking) { - rules.push( - this.booleanRule('audio.fading.ducking.enabled', ducking.enabled), - this.nonNegativeIntRule( - 'audio.fading.ducking.duration', - ducking.duration - ), - this.enumRule( - 'audio.fading.ducking.curve', - ducking.curve, - VALID_FADING_CURVES - ), - { - path: 'audio.fading.ducking.targetVolume', - expected: 'number between 0 and 1 (inclusive)', - value: ducking.targetVolume, - validate: (v: unknown) => typeof v === 'number' && v >= 0 && v <= 1 + cluster: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + workers: { type: 'number', integer: true, min: 0 }, + minWorkers: { type: 'number', integer: true, min: 0, default: 1 }, + timeouts: { + type: 'object', + optional: true, + props: { + heavyMs: { + type: 'number', + integer: true, + min: 100, + default: 6000 + }, + fastMs: { type: 'number', integer: true, min: 100, default: 4000 } + } } - ) - } - } - - return rules - } - - private validatePlayback(): ValidationRule[] { - const trackStuck = this.options.trackStuckThresholdMs - - return [ - this.intRangeRule( - 'playerUpdateInterval', - this.options.playerUpdateInterval, - 250, - 60000 - ), - this.positiveIntRule( - 'statsUpdateInterval', - this.options.statsUpdateInterval - ), - this.positiveIntRule('eventTimeoutMs', this.options.eventTimeoutMs), - this.booleanRule('enableHoloTracks', this.options.enableHoloTracks), - this.booleanRule( - 'enableTrackStreamEndpoint', - this.options.enableTrackStreamEndpoint - ), - this.booleanRule( - 'enableLoadStreamEndpoint', - this.options.enableLoadStreamEndpoint - ), - this.booleanRule( - 'resolveExternalLinks', - this.options.resolveExternalLinks - ), - this.booleanRule('fetchChannelInfo', this.options.fetchChannelInfo), - { - path: 'trackStuckThresholdMs', - expected: 'integer >= 1000 (milliseconds)', - value: trackStuck, - validate: (v: unknown) => - typeof v === 'number' && Number.isInteger(v) && v >= 1000 - }, - this.intMustExceed( - 'zombieThresholdMs', - this.options.zombieThresholdMs, - trackStuck, - 'trackStuckThresholdMs' - ) - ] - } - - private validateSources(): ValidationRule[] { - const sources = this.options.sources - if (!sources) return [] - - const rules: ValidationRule[] = [] - - const allSources = [ - 'youtube', - 'soundcloud', - 'spotify', - 'tidal', - 'applemusic', - 'audius', - 'jiosaavn', - 'eternalbox', - 'pipertts', - 'pandora', - 'yandexmusic', - 'monochrome', - 'gaana', - 'flowery', - 'lazypytts', - 'qobuz', - 'iheartradio', - 'vkmusic', - 'amazonmusic', - 'bluesky', - 'anghami', - 'rss', - 'songlink', - 'mixcloud', - 'audiomack', - 'deezer', - 'bandcamp', - 'local', - 'http', - 'vimeo', - 'telegram', - 'shazam', - 'bilibili', - 'genius', - 'pinterest', - 'google-tts', - 'instagram', - 'kwai', - 'twitch', - 'nicovideo', - 'reddit', - 'tumblr', - 'twitter', - 'lastfm', - 'letrasmus' - ] - - for (const name of allSources) { - const sourceConfig = (sources as Record)[name] as - | Record - | undefined - if (sourceConfig !== undefined) { - rules.push( - this.booleanRule(`sources.${name}.enabled`, sourceConfig.enabled) - ) - } - } - - rules.push( - ...this.validateSourceSpotify(sources.spotify), - ...this.validateSourceAppleMusic(sources.applemusic), - ...this.validateSourceTidal(sources.tidal), - ...this.validateSourceAudius(sources.audius), - ...this.validateSourceJiosaavn(sources.jiosaavn), - ...this.validateSourceEternalbox(sources.eternalbox), - ...this.validateSourceYoutube(sources.youtube), - ...this.validateSourcePipertts(sources.pipertts), - ...this.validateSourcePandora(sources.pandora), - ...this.validateSourceQobuz(sources.qobuz), - ...this.validateSourceLastfm(sources.lastfm), - ...this.validateSourceBilibili(sources.bilibili), - ...this.validateSourceYandexMusic(sources.yandexmusic), - ...this.validateSourceGaana(sources.gaana), - ...this.validateSourceFlowery(sources.flowery), - ...this.validateSourceLazypytts(sources.lazypytts), - ...this.validateSourceMonochrome(sources.monochrome) - ) - - return rules - } - - private validateSourceSpotify(spotify: unknown): ValidationRule[] { - if ( - typeof spotify !== 'object' || - spotify === null || - !(spotify as Record).enabled - ) - return [] - - const s = spotify as Record - - const rules: ValidationRule[] = [ - this.nonNegativeIntRule( - 'sources.spotify.playlistLoadLimit', - s.playlistLoadLimit - ), - this.nonNegativeIntRule( - 'sources.spotify.albumLoadLimit', - s.albumLoadLimit - ), - this.positiveIntRule( - 'sources.spotify.playlistPageLoadConcurrency', - s.playlistPageLoadConcurrency - ), - this.positiveIntRule( - 'sources.spotify.albumPageLoadConcurrency', - s.albumPageLoadConcurrency - ), - this.booleanRule('sources.spotify.allowLocalFiles', s.allowLocalFiles), - { - path: 'sources.spotify.clientId', - expected: 'non-empty string when sources.spotify.clientSecret is set', - value: s.clientId, - validate: (v: unknown) => - !s.clientSecret || (typeof v === 'string' && v.trim().length > 0) + } }, - { - path: 'sources.spotify.clientSecret', - expected: 'non-empty string when sources.spotify.clientId is set', - value: s.clientSecret, - validate: (v: unknown) => - !s.clientId || (typeof v === 'string' && v.trim().length > 0) + logging: { + type: 'object', + props: { + level: { type: 'string', default: 'info' }, + file: { + type: 'object', + props: { + enabled: { type: 'boolean', default: false }, + path: { type: 'string', default: 'logs' } + } + } + } }, - this.placeholderWarningRule('sources.spotify.clientId', s.clientId), - this.placeholderWarningRule( - 'sources.spotify.clientSecret', - s.clientSecret - ) - ] - - if (s.externalAuthUrl) { - rules.push( - this.urlRule('sources.spotify.externalAuthUrl', s.externalAuthUrl) - ) - } - - return rules - } - - private validateSourceAppleMusic(applemusic: unknown): ValidationRule[] { - if ( - typeof applemusic !== 'object' || - applemusic === null || - !(applemusic as Record).enabled - ) - return [] - - const a = applemusic as Record - - return [ - this.nonNegativeIntRule( - 'sources.applemusic.playlistLoadLimit', - a.playlistLoadLimit - ), - this.nonNegativeIntRule( - 'sources.applemusic.albumLoadLimit', - a.albumLoadLimit - ), - this.positiveIntRule( - 'sources.applemusic.playlistPageLoadConcurrency', - a.playlistPageLoadConcurrency - ), - this.positiveIntRule( - 'sources.applemusic.albumPageLoadConcurrency', - a.albumPageLoadConcurrency - ), - this.placeholderWarningRule( - 'sources.applemusic.mediaApiToken', - a.mediaApiToken, - ['token_here'] - ) - ] - } - - private validateSourceTidal(tidal: unknown): ValidationRule[] { - if ( - typeof tidal !== 'object' || - tidal === null || - !(tidal as Record).enabled - ) - return [] - - const t = tidal as Record - - const rules: ValidationRule[] = [ - this.nonNegativeIntRule( - 'sources.tidal.playlistLoadLimit', - t.playlistLoadLimit - ), - this.positiveIntRule( - 'sources.tidal.playlistPageLoadConcurrency', - t.playlistPageLoadConcurrency - ) - ] - - if (t.token !== undefined) { - rules.push( - { - path: 'sources.tidal.token', - expected: 'string (non-whitespace if provided)', - value: t.token, - validate: (v: unknown) => - typeof v === 'string' && (v === '' || v.trim().length > 0) - }, - this.placeholderWarningRule('sources.tidal.token', t.token, [ - 'token_here' - ]) - ) - } - - return rules - } - - private validateSourceAudius(audius: unknown): ValidationRule[] { - if ( - typeof audius !== 'object' || - audius === null || - !(audius as Record).enabled - ) - return [] - - const au = audius as Record - - return [ - { - path: 'sources.audius.appName', - expected: 'string', - value: au.appName, - validate: (v: unknown) => v === undefined || typeof v === 'string' + connection: { + type: 'object', + props: { + interval: { type: 'number', integer: true, min: 1000 }, + timeout: { type: 'number', integer: true, min: 1000 } + } }, - { - path: 'sources.audius.apiKey', - expected: 'string', - value: au.apiKey, - validate: (v: unknown) => v === undefined || typeof v === 'string' + rateLimit: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + global: { type: 'object' }, + perIp: { type: 'object' } + } }, - { - path: 'sources.audius.apiSecret', - expected: 'string', - value: au.apiSecret, - validate: (v: unknown) => v === undefined || typeof v === 'string' + dosProtection: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + thresholds: { type: 'object' }, + mitigation: { type: 'object' } + } }, - this.nonNegativeIntRule( - 'sources.audius.playlistLoadLimit', - au.playlistLoadLimit - ), - this.nonNegativeIntRule( - 'sources.audius.albumLoadLimit', - au.albumLoadLimit - ), - this.placeholderWarningRule('sources.audius.apiKey', au.apiKey), - this.placeholderWarningRule('sources.audius.apiSecret', au.apiSecret) - ] - } - - private validateSourceJiosaavn(jiosaavn: unknown): ValidationRule[] { - if ( - typeof jiosaavn !== 'object' || - jiosaavn === null || - !(jiosaavn as Record).enabled - ) - return [] - - const j = jiosaavn as Record - - return [ - this.nonNegativeIntRule( - 'sources.jiosaavn.playlistLoadLimit', - j.playlistLoadLimit - ), - this.nonNegativeIntRule( - 'sources.jiosaavn.artistLoadLimit', - j.artistLoadLimit - ) - ] - } - - private validateSourceEternalbox(eternalbox: unknown): ValidationRule[] { - if ( - typeof eternalbox !== 'object' || - eternalbox === null || - !(eternalbox as Record).enabled - ) - return [] - - const e = eternalbox as Record - - return [ - this.urlRule('sources.eternalbox.baseUrl', e.baseUrl), - this.positiveIntRule('sources.eternalbox.searchResults', e.searchResults), - this.positiveIntRule('sources.eternalbox.maxBranches', e.maxBranches), - this.nonNegativeIntRule( - 'sources.eternalbox.cacheMaxBytes', - e.cacheMaxBytes - ), - this.booleanRule('sources.eternalbox.enrichSpotify', e.enrichSpotify), - this.booleanRule('sources.eternalbox.includeAnalysis', e.includeAnalysis), - this.booleanRule('sources.eternalbox.eternalStream', e.eternalStream), - this.booleanRule('sources.eternalbox.infiniteStream', e.infiniteStream), - this.nonNegativeIntRule( - 'sources.eternalbox.maxReconnects', - e.maxReconnects - ), - this.positiveIntRule( - 'sources.eternalbox.reconnectDelayMs', - e.reconnectDelayMs - ), - { - path: 'sources.eternalbox.minRandomBranchChance', - expected: 'number between 0 and 1 (inclusive)', - value: e.minRandomBranchChance, - validate: (v: unknown) => typeof v === 'number' && v >= 0 && v <= 1 + trustProxy: { type: 'boolean', default: false }, + network: { + type: 'object', + props: { + proxy: { + type: 'object', + props: { + enabled: { type: 'boolean', default: false }, + strategy: { + type: 'string', + enum: [ + 'RoundRobin', + 'LeastConnections', + 'LowestLatency', + 'WeightedHealth' + ] + } + } + }, + connection: { + type: 'object', + props: { + interval: { type: 'number', integer: true, min: 1000 }, + timeout: { type: 'number', integer: true, min: 1000 } + } + } + } }, - { - path: 'sources.eternalbox.maxRandomBranchChance', - expected: 'number between 0 and 1 (inclusive)', - value: e.maxRandomBranchChance, - validate: (v: unknown) => typeof v === 'number' && v >= 0 && v <= 1 + search: { + type: 'object', + props: { + maxResults: { + type: 'number', + integer: true, + min: 1, + max: 100, + default: 10 + }, + defaultSource: { + type: 'multi', + rules: [{ type: 'array', items: 'string' }, { type: 'string' }] + }, + unifiedSources: { type: 'array', items: 'string', optional: true }, + resolveExternalLinks: { type: 'boolean', default: false }, + fetchChannelInfo: { type: 'boolean', default: false } + } }, - this.floatMustNotExceed( - 'sources.eternalbox.minRandomBranchChance', - e.minRandomBranchChance, - e.maxRandomBranchChance, - 'sources.eternalbox.maxRandomBranchChance' - ) - ] - } - - private validateSourceYoutube(youtube: unknown): ValidationRule[] { - if (typeof youtube !== 'object' || youtube === null) return [] - const y = youtube as Record - if (!y.enabled || !y.cipher) return [] - const cipher = y.cipher as Record - if (cipher.url === undefined) return [] - - return [this.urlRule('sources.youtube.cipher.url', cipher.url)] - } - - private validateSourcePipertts(pipertts: unknown): ValidationRule[] { - if ( - typeof pipertts !== 'object' || - pipertts === null || - !(pipertts as Record).enabled - ) - return [] - - const p = pipertts as Record - - return [this.urlRule('sources.pipertts.url', p.url)] - } - - private validateSourcePandora(pandora: unknown): ValidationRule[] { - if (typeof pandora !== 'object' || pandora === null) return [] - const pa = pandora as Record - if (!pa.enabled || !pa.remoteTokenUrl) return [] - - return [this.urlRule('sources.pandora.remoteTokenUrl', pa.remoteTokenUrl)] - } - - private validateSourceQobuz(qobuz: unknown): ValidationRule[] { - if ( - typeof qobuz !== 'object' || - qobuz === null || - !(qobuz as Record).enabled - ) - return [] - - const q = qobuz as Record - - return [this.placeholderWarningRule('sources.qobuz.userToken', q.userToken)] - } - - private validateSourceLastfm(lastfm: unknown): ValidationRule[] { - if ( - typeof lastfm !== 'object' || - lastfm === null || - !(lastfm as Record).enabled - ) - return [] - - const l = lastfm as Record - - return [this.placeholderWarningRule('sources.lastfm.apiKey', l.apiKey)] - } - - private validateSourceBilibili(bilibili: unknown): ValidationRule[] { - if ( - typeof bilibili !== 'object' || - bilibili === null || - !(bilibili as Record).enabled - ) - return [] - - const b = bilibili as Record - - return [ - this.placeholderWarningRule('sources.bilibili.sessdata', b.sessdata) - ] - } - - private validateSourceYandexMusic(yandexmusic: unknown): ValidationRule[] { - if ( - typeof yandexmusic !== 'object' || - yandexmusic === null || - !(yandexmusic as Record).enabled - ) - return [] - - const ym = yandexmusic as Record - - return [ - this.nonNegativeIntRule( - 'sources.yandexmusic.artistLoadLimit', - ym.artistLoadLimit - ), - this.nonNegativeIntRule( - 'sources.yandexmusic.albumLoadLimit', - ym.albumLoadLimit - ), - this.nonNegativeIntRule( - 'sources.yandexmusic.playlistLoadLimit', - ym.playlistLoadLimit - ), - this.placeholderWarningRule( - 'sources.yandexmusic.accessToken', - ym.accessToken - ) - ] - } - - private validateSourceGaana(gaana: unknown): ValidationRule[] { - if ( - typeof gaana !== 'object' || - gaana === null || - !(gaana as Record).enabled - ) - return [] - - const g = gaana as Record - - return [ - this.nonNegativeIntRule( - 'sources.gaana.playlistLoadLimit', - g.playlistLoadLimit - ), - this.nonNegativeIntRule('sources.gaana.albumLoadLimit', g.albumLoadLimit), - this.nonNegativeIntRule( - 'sources.gaana.artistLoadLimit', - g.artistLoadLimit - ) - ] - } - - private validateSourceFlowery(flowery: unknown): ValidationRule[] { - if ( - typeof flowery !== 'object' || - flowery === null || - !(flowery as Record).enabled - ) - return [] - - const fl = flowery as Record - - return [ - this.positiveNumberRule('sources.flowery.speed', fl.speed), - this.nonNegativeIntRule('sources.flowery.silence', fl.silence), - this.booleanRule('sources.flowery.translate', fl.translate), - this.booleanRule('sources.flowery.enforceConfig', fl.enforceConfig) - ] - } - - private validateSourceLazypytts(lazypytts: unknown): ValidationRule[] { - if ( - typeof lazypytts !== 'object' || - lazypytts === null || - !(lazypytts as Record).enabled - ) - return [] - - const lp = lazypytts as Record - - return [ - this.positiveIntRule('sources.lazypytts.maxTextLength', lp.maxTextLength), - this.booleanRule('sources.lazypytts.enforceConfig', lp.enforceConfig) - ] - } - - private validateSourceMonochrome(monochrome: unknown): ValidationRule[] { - if ( - typeof monochrome !== 'object' || - monochrome === null || - !(monochrome as Record).enabled - ) - return [] - - const m = monochrome as Record - - return [ - this.stringArrayRule('sources.monochrome.instances', m.instances), - this.stringArrayRule( - 'sources.monochrome.streamingInstances', - m.streamingInstances - ), - { - path: 'sources.monochrome.quality', - expected: 'one of [HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW]', - value: m.quality, - validate: (v: unknown) => - ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'].includes(v as string) - } - ] - } - - private validateSearch(): ValidationRule[] { - const rules: ValidationRule[] = [ - this.intRangeRule( - 'maxSearchResults', - this.options.maxSearchResults, - 1, - 100 - ), - this.intRangeRule( - 'maxAlbumPlaylistLength', - this.options.maxAlbumPlaylistLength, - 1, - 500 - ), - { - path: 'defaultSearchSource', - expected: - 'string or non-empty string[] of enabled source names in config.sources', - value: this.options.defaultSearchSource, - validate: (v: unknown) => { - const sources = this.options.sources - if (!sources) return false - const sourcesRecord = sources as Record - if (typeof v === 'string') - return ( - (sourcesRecord[v] as Record)?.enabled === true - ) - if (Array.isArray(v)) { - if (v.length === 0) return false - return v.every( - (name: unknown) => - typeof name === 'string' && - (sourcesRecord[name] as Record)?.enabled === - true - ) - } - return false + playback: { + type: 'object', + props: { + maxPlaylistLength: { + type: 'number', + integer: true, + min: 0, + default: 100 + }, + playerUpdateInterval: { + type: 'number', + integer: true, + min: 100, + default: 2000, + optional: true + }, + statsUpdateInterval: { + type: 'number', + integer: true, + min: 100, + default: 30000, + optional: true + }, + trackStuckThresholdMs: { + type: 'number', + integer: true, + min: 100, + default: 10000, + optional: true + }, + eventTimeoutMs: { + type: 'number', + integer: true, + min: 100, + default: 3000, + optional: true + }, + zombieThresholdMs: { + type: 'number', + integer: true, + min: 100, + default: 300000, + optional: true + }, + sponsorblock: { + type: 'object', + optional: true, + props: { + enabled: { type: 'boolean', default: false }, + api: { type: 'string', optional: true }, + categories: { type: 'array', items: 'string', optional: true }, + actionTypes: { type: 'array', items: 'string', optional: true } + } + }, + filters: { type: 'object', optional: true }, + audio: { + type: 'object', + optional: true, + props: { + quality: { + type: 'string', + enum: ['high', 'medium', 'low', 'lowest'] + }, + encryption: { type: 'string', optional: true }, + resamplingQuality: { type: 'string', optional: true }, + loudnessNormalizer: { type: 'boolean', optional: true }, + fading: { type: 'object', optional: true }, + crossfade: { type: 'object', optional: true } + } + }, + voiceReceive: { type: 'object', optional: true }, + mix: { type: 'object', optional: true } } - } - ] - - const unified = this.options.unifiedSearchSources - if (unified !== undefined) { - rules.push({ - path: 'unifiedSearchSources', - expected: - 'non-empty string[] of enabled source names in config.sources', - value: unified, - validate: (v: unknown) => { - const sources = this.options.sources - if (!sources || !Array.isArray(v) || v.length === 0) return false - const sourcesRecord = sources as Record - return v.every( - (name: unknown) => - typeof name === 'string' && - (sourcesRecord[name] as Record)?.enabled === true - ) + }, + api: { + type: 'object', + props: { + enableTrackStreamEndpoint: { type: 'boolean', default: false }, + enableLoadStreamEndpoint: { type: 'boolean', default: false }, + metrics: { type: 'object', optional: true } } - }) - } - - return rules - } - - private validateRoutePlanner(): ValidationRule[] { - const routePlanner = this.options.routePlanner - if (!routePlanner) return [] - - const rules: ValidationRule[] = [ - this.enumRule( - 'routePlanner.strategy', - routePlanner.strategy, - VALID_ROUTE_STRATEGIES - ) - ] - - if (routePlanner.bannedIpCooldown !== undefined) { - rules.push( - this.positiveIntRule( - 'routePlanner.bannedIpCooldown', - routePlanner.bannedIpCooldown - ) - ) - } - - return rules - } - - private validateRateLimit(): ValidationRule[] { - const rateLimit = this.options.rateLimit - if (!rateLimit || rateLimit.enabled === false) return [] - - const rules: ValidationRule[] = [ - this.booleanRule('rateLimit.enabled', rateLimit.enabled) - ] - - type RateLimitSection = { maxRequests: number; timeWindowMs: number } - - const sections = ['global', 'perIp', 'perUserId', 'perGuildId'] as const - let prevSection: string | null = null - let prevCfg: RateLimitSection | null = null - - for (const section of sections) { - const cfg = rateLimit[section] as RateLimitSection | undefined - if (!cfg) { - continue - } - - rules.push( - this.positiveIntRule( - `rateLimit.${section}.maxRequests`, - cfg.maxRequests - ), - this.positiveIntRule( - `rateLimit.${section}.timeWindowMs`, - cfg.timeWindowMs - ) - ) - - if (prevSection !== null && prevCfg !== null) { - const capturedPrevSection = prevSection - const capturedPrevCfg = prevCfg - rules.push( - this.intMustNotExceed( - `rateLimit.${section}.maxRequests`, - cfg.maxRequests, - capturedPrevCfg.maxRequests, - `rateLimit.${capturedPrevSection}.maxRequests` - ), - this.intMustNotExceed( - `rateLimit.${section}.timeWindowMs`, - cfg.timeWindowMs, - capturedPrevCfg.timeWindowMs, - `rateLimit.${capturedPrevSection}.timeWindowMs` - ) - ) - } - - prevSection = section - prevCfg = cfg - } - - return rules - } - - private validateDosProtection(): ValidationRule[] { - const dos = this.options.dosProtection - if (!dos) return [] - - const rules: ValidationRule[] = [ - this.booleanRule('dosProtection.enabled', dos.enabled) - ] - - if (dos.thresholds) { - rules.push( - this.positiveIntRule( - 'dosProtection.thresholds.burstRequests', - dos.thresholds.burstRequests - ), - this.positiveIntRule( - 'dosProtection.thresholds.timeWindowMs', - dos.thresholds.timeWindowMs - ) - ) - } - - if (dos.mitigation) { - rules.push( - this.nonNegativeIntRule( - 'dosProtection.mitigation.delayMs', - dos.mitigation.delayMs - ), - this.positiveIntRule( - 'dosProtection.mitigation.blockDurationMs', - dos.mitigation.blockDurationMs - ) - ) - } - - return rules - } - - private validateLogging(): ValidationRule[] { - const logging = this.options.logging - if (!logging) return [] - - const rules: ValidationRule[] = [] - - if (logging.file) { - rules.push( - this.booleanRule('logging.file.enabled', logging.file.enabled), - this.positiveIntRule('logging.file.ttlDays', logging.file.ttlDays) - ) - - if (logging.file.enabled) { - rules.push( - this.nonEmptyStringRule('logging.file.path', logging.file.path) - ) - } - } - - if (logging.debug) { - const debugFields = [ - 'all', - 'request', - 'session', - 'player', - 'filters', - 'sources', - 'lyrics', - 'youtube', - 'youtube-cipher', - 'sabr', - 'potoken' - ] as const - for (const field of debugFields) { - if (logging.debug[field] !== undefined) { - rules.push( - this.booleanRule(`logging.debug.${field}`, logging.debug[field]) - ) + }, + experimental: { + type: 'object', + props: { + enableHoloTracks: { type: 'boolean', default: false } } - } - } - - return rules - } - - private validateConnection(): ValidationRule[] { - const connection = this.options.connection - if (!connection) return [] - - const rules: ValidationRule[] = [ - this.booleanRule('connection.logAllChecks', connection.logAllChecks), - this.positiveIntRule('connection.interval', connection.interval), - this.positiveIntRule('connection.timeout', connection.timeout) - ] - - if (connection.thresholds) { - rules.push( - this.positiveNumberRule( - 'connection.thresholds.bad', - connection.thresholds.bad - ), - this.positiveNumberRule( - 'connection.thresholds.average', - connection.thresholds.average - ), - this.floatMustBeBelow( - 'connection.thresholds.bad', - connection.thresholds.bad, - connection.thresholds.average, - 'connection.thresholds.average' + }, + sources: { type: 'object' }, + lyrics: { type: 'object' }, + meanings: { type: 'object' }, + metrics: { type: 'object' }, + mix: { type: 'object' }, + plugins: { type: 'array', items: 'object', optional: true }, + pluginConfig: { type: 'object', optional: true } + } + + const check = validator.compile(schema) + const result = check(this.config) + + if (result !== true) { + const errors = Array.isArray(result) ? result : [] + for (const error of errors) { + logger( + 'error', + 'Config', + `Validation error at ${error.field}: ${error.message}` ) - ) - } - - return rules - } - - private validateVoiceReceive(): ValidationRule[] { - const voiceReceive = this.options.voiceReceive - if (!voiceReceive) return [] - - return [ - this.booleanRule('voiceReceive.enabled', voiceReceive.enabled), - this.enumRule( - 'voiceReceive.format', - voiceReceive.format, - VALID_VOICE_FORMATS - ) - ] - } - - private validateMix(): ValidationRule[] { - const mix = this.options.mix - if (!mix) return [] - - return [ - this.booleanRule('mix.enabled', mix.enabled), - this.positiveIntRule('mix.maxLayersMix', mix.maxLayersMix), - this.booleanRule('mix.autoCleanup', mix.autoCleanup), - { - path: 'mix.defaultVolume', - expected: 'number between 0 and 1 (inclusive)', - value: mix.defaultVolume, - validate: (v: unknown) => typeof v === 'number' && v >= 0 && v <= 1 } - ] - } - - private validateMetrics(): ValidationRule[] { - const metrics = this.options.metrics - if (!metrics) return [] - - const rules: ValidationRule[] = [ - this.booleanRule('metrics.enabled', metrics.enabled) - ] - - if (metrics.authorization) { - rules.push( - this.enumRule( - 'metrics.authorization.type', - metrics.authorization.type, - VALID_METRICS_AUTH_TYPES - ) + throw new Error( + `Configuration validation failed: ${errors.map((e) => e.message).join(', ')}` ) } - return rules - } - - private validateFilters(): ValidationRule[] { - const filters = this.options.filters - if (filters === undefined) return [] - - if (filters === null || typeof filters !== 'object') { - return [ - { - path: 'filters', - expected: 'object with an enabled key', - value: filters, - validate: () => false - } - ] - } - - if (!('enabled' in filters)) { - return [ - { - path: 'filters.enabled', - expected: 'object of filter flags', - value: undefined, - validate: () => false - } - ] - } - - if (filters.enabled === null || typeof filters.enabled !== 'object') { - return [ - { - path: 'filters.enabled', - expected: 'object of filter flags', - value: filters.enabled, - validate: () => false - } - ] - } - - if (Object.keys(filters.enabled).length === 0) return [] - - const filterFields = [ - 'tremolo', - 'vibrato', - 'lowpass', - 'highpass', - 'rotation', - 'karaoke', - 'distortion', - 'channelMix', - 'equalizer', - 'chorus', - 'compressor', - 'echo', - 'phaser', - 'timescale' - ] as const - - return filterFields - .filter((f) => filters.enabled[f] !== undefined) - .map((f) => this.booleanRule(`filters.enabled.${f}`, filters.enabled[f])) - } - - private placeholderWarningRule( - path: string, - value: unknown, - except: string[] = [] - ): ValidationRule { - return { - path, - expected: 'non-placeholder value', - value, - validate: (v: unknown) => { - if ( - typeof v === 'string' && - KNOWN_PLACEHOLDERS.has(v) && - !except.includes(v) - ) { - this.warnings.push({ - path, - message: `Value "${v}" looks like an unfilled placeholder. The source may fail to authenticate at runtime.` - }) - } - return true - } - } - } - - private nonNegativeIntRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'integer >= 0', - value, - validate: (v: unknown) => - typeof v === 'number' && Number.isInteger(v) && v >= 0 - } - } - - private positiveIntRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'integer > 0', - value, - validate: (v: unknown) => - typeof v === 'number' && Number.isInteger(v) && v > 0 - } - } - - private intRangeRule( - path: string, - value: unknown, - min: number, - max: number - ): ValidationRule { - return { - path, - expected: `integer between ${min} and ${max}`, - value, - validate: (v: unknown) => - typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max - } - } - - private booleanRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'boolean', - value, - validate: (v: unknown) => typeof v === 'boolean' - } - } - - private nonEmptyStringRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'non-empty string', - value, - validate: (v: unknown) => typeof v === 'string' && v.trim().length > 0 - } - } - - private enumRule( - path: string, - value: unknown, - allowed: Set - ): ValidationRule { - const label = [...allowed].join(', ') - return { - path, - expected: `one of [${label}]`, - value, - validate: (v: unknown) => allowed.has(v as string) - } - } - - private urlRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'valid http or https URL (e.g. https://example.com)', - value, - validate: (v: unknown) => { - if (typeof v !== 'string' || v.trim().length === 0) return false - if (v !== v.trim()) { - this.warnings.push({ - path, - message: `URL value has leading or trailing whitespace. This may cause issues at runtime.` - }) - } - try { - const url = new URL(v.trim()) - return url.protocol === 'http:' || url.protocol === 'https:' - } catch { - return false - } - } - } - } - - private intMustExceed( - path: string, - value: unknown, - dependency: unknown, - dependencyPath: string - ): ValidationRule { - return { - path, - expected: `integer > ${dependencyPath} (${dependency})`, - value, - validate: (v: unknown) => - typeof v === 'number' && - Number.isInteger(v) && - typeof dependency === 'number' && - Number.isInteger(dependency) && - v > dependency - } - } - - private intMustNotExceed( - path: string, - value: unknown, - dependency: unknown, - dependencyPath: string - ): ValidationRule { - return { - path, - expected: `integer <= ${dependencyPath} (${dependency})`, - value, - validate: (v: unknown) => - typeof v === 'number' && - Number.isInteger(v) && - typeof dependency === 'number' && - Number.isInteger(dependency) && - v <= dependency - } - } - - private floatMustNotExceed( - path: string, - value: unknown, - dependency: unknown, - dependencyPath: string - ): ValidationRule { - return { - path, - expected: `number <= ${dependencyPath} (${dependency})`, - value, - validate: (v: unknown) => - typeof v === 'number' && - typeof dependency === 'number' && - v <= dependency - } - } - - private floatMustBeBelow( - path: string, - value: unknown, - dependency: unknown, - dependencyPath: string - ): ValidationRule { - return { - path, - expected: `number < ${dependencyPath} (${dependency})`, - value, - validate: (v: unknown) => - typeof v === 'number' && - typeof dependency === 'number' && - v < dependency - } - } - - private positiveNumberRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'number > 0', - value, - validate: (v: unknown) => typeof v === 'number' && v > 0 - } - } - - private stringArrayRule(path: string, value: unknown): ValidationRule { - return { - path, - expected: 'array of strings', - value, - validate: (v: unknown) => - Array.isArray(v) && v.every((item) => typeof item === 'string') - } + logger('info', 'Config', 'Configuration validated successfully.') } } From 44bc6f9b4c9a5a698f2a07bea22abb551ef2cee1 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:20:47 -0400 Subject: [PATCH 22/58] update: restore robust YouTube visitorData and proxy management logic --- src/sources/youtube/CipherManager.ts | 7 +- src/sources/youtube/YouTube.ts | 21 +++--- src/sources/youtube/clients/Android.ts | 4 +- src/sources/youtube/clients/AndroidVR.ts | 4 +- src/sources/youtube/clients/Web.ts | 4 +- src/sources/youtube/clients/WebEmbedded.ts | 4 +- src/sources/youtube/common.ts | 84 ++++++++++++++++++---- 7 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/sources/youtube/CipherManager.ts b/src/sources/youtube/CipherManager.ts index 74d22f12..e25d4665 100644 --- a/src/sources/youtube/CipherManager.ts +++ b/src/sources/youtube/CipherManager.ts @@ -115,8 +115,11 @@ export default class CipherManager implements ICipherManager { this.nodelink = nodelink this.config = { ...(( - (nodelink.options.sources as Record | undefined) - ?.youtube as { cipher?: YouTubeCipherConfig } | undefined + ( + nodelink.options.sources as unknown as + | Record + | undefined + )?.youtube as { cipher?: YouTubeCipherConfig } | undefined )?.cipher ?? {}) } if (this.config.url) { diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index 6ff0ccff..0d798b55 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -235,9 +235,8 @@ export default class YouTubeSource { */ constructor(nodelink: WorkerNodeLink) { this.nodelink = nodelink - this.config = ( - nodelink.options as Record | undefined> - ).sources?.youtube as YouTubeSourceConfig + this.config = nodelink.options.sources + .youtube as unknown as YouTubeSourceConfig this.proxyManager = new YouTubeProxyManager(this.config.proxies || []) this.additionalsSourceName = ['ytmusic'] this.searchTerms = ['ytsearch', 'ytmsearch'] @@ -480,11 +479,11 @@ export default class YouTubeSource { ) } } - } catch (e) { + } catch (_e) { logger( 'error', 'YouTube', - `Error fetching visitor data: ${(e as Error).message}` + `Error fetching visitor data: \${(e as Error).message}` ) logger( 'warn', @@ -1060,7 +1059,8 @@ export default class YouTubeSource { playerBody as any, { fetchChannelInfo: options.fetchChannelInfo ?? false, - resolveExternalLinks: options.resolveExternalLinks ?? false + resolveExternalLinks: options.resolveExternalLinks ?? false, + search: {} } ) @@ -1459,10 +1459,11 @@ export default class YouTubeSource { ? this.config.fallbackSources : [] - const opts = this.nodelink.options as Record - const defaultSources = Array.isArray(opts.defaultSearchSource) - ? (opts.defaultSearchSource as string[]) - : [opts.defaultSearchSource as string] + const opts = this.nodelink.options as unknown as Record + const searchConfig = opts.search as Record | undefined + const defaultSources = Array.isArray(searchConfig?.defaultSource) + ? (searchConfig.defaultSource as string[]) + : [searchConfig?.defaultSource as string] const fallbackOrder = [ ...configuredFallbackSources, diff --git a/src/sources/youtube/clients/Android.ts b/src/sources/youtube/clients/Android.ts index 52e46f9a..c5d42eeb 100644 --- a/src/sources/youtube/clients/Android.ts +++ b/src/sources/youtube/clients/Android.ts @@ -216,7 +216,7 @@ export default class Android extends BaseClient { } const maxResults = - (this.config.maxSearchResults as number | undefined) || 10 + (this.config.search.maxResults as number | undefined) || 10 let count = 0 const filteredItems = items.filter((item) => { const isValid = @@ -244,7 +244,7 @@ export default class Android extends BaseClient { sourceName, null, null, - this.config.enableHoloTracks as boolean | undefined + this.config.experimental.enableHoloTracks as boolean | undefined ) if (track) { tracks.push(track) diff --git a/src/sources/youtube/clients/AndroidVR.ts b/src/sources/youtube/clients/AndroidVR.ts index e774ad50..d7d542e0 100644 --- a/src/sources/youtube/clients/AndroidVR.ts +++ b/src/sources/youtube/clients/AndroidVR.ts @@ -178,7 +178,7 @@ export default class AndroidVR extends BaseClient { } const maxResults = - (this.config.maxSearchResults as number | undefined) || 10 + (this.config.search.maxResults as number | undefined) || 10 if (videos.length > maxResults) { let count = 0 videos = videos.filter((video) => { @@ -197,7 +197,7 @@ export default class AndroidVR extends BaseClient { sourceName, null, null, - this.config.enableHoloTracks as boolean | undefined + this.config.experimental.enableHoloTracks as boolean | undefined ) if (track) { tracks.push(track) diff --git a/src/sources/youtube/clients/Web.ts b/src/sources/youtube/clients/Web.ts index 908b8ec2..acefb78c 100644 --- a/src/sources/youtube/clients/Web.ts +++ b/src/sources/youtube/clients/Web.ts @@ -174,7 +174,7 @@ export default class Web extends BaseClient { } const maxResults = - (this.config.maxSearchResults as number | undefined) || 10 + (this.config.search.maxResults as number | undefined) || 10 if (videos.length > maxResults) { let count = 0 videos = videos.filter((video) => { @@ -193,7 +193,7 @@ export default class Web extends BaseClient { sourceName, null, null, - this.config.enableHoloTracks as boolean | undefined + this.config.experimental.enableHoloTracks as boolean | undefined ) if (track) { tracks.push(track) diff --git a/src/sources/youtube/clients/WebEmbedded.ts b/src/sources/youtube/clients/WebEmbedded.ts index 020cfe68..30ee1496 100644 --- a/src/sources/youtube/clients/WebEmbedded.ts +++ b/src/sources/youtube/clients/WebEmbedded.ts @@ -158,7 +158,7 @@ export default class WebEmbedded extends BaseClient { } const maxResults = - (this.config.maxSearchResults as number | undefined) || 10 + (this.config.search.maxResults as number | undefined) || 10 if (videos.length > maxResults) { let count = 0 videos = videos.filter((video) => { @@ -177,7 +177,7 @@ export default class WebEmbedded extends BaseClient { sourceName, null, null, - this.config.enableHoloTracks as boolean | undefined + this.config.experimental.enableHoloTracks as boolean | undefined ) if (track) { tracks.push(track) diff --git a/src/sources/youtube/common.ts b/src/sources/youtube/common.ts index 5341c677..6e957f4a 100644 --- a/src/sources/youtube/common.ts +++ b/src/sources/youtube/common.ts @@ -1282,6 +1282,11 @@ export interface TrackBuildOptions { fetchChannelInfo?: boolean maxAlbumPlaylistLength?: number enableHoloTracks?: boolean + search: { + maxResults?: number + resolveExternalLinks?: boolean + fetchChannelInfo?: boolean + } } /** @@ -1310,7 +1315,7 @@ export async function buildTrack( sourceNameOverride: string | null = null, fullApiResponse: Record | null = null, enableHolo = false, - config: TrackBuildOptions = {}, + config: TrackBuildOptions = { search: {} }, makeRequestFn: MakeRequestFn | null = null ): Promise { if (!itemData) { @@ -1683,7 +1688,7 @@ export async function buildHoloTrack( itemData: YouTubeRenderer | null, itemType: string, fullApiResponse: Record | null = null, - config: TrackBuildOptions = {}, + config: TrackBuildOptions = { search: {} }, makeRequestFn: MakeRequestFn | null = null ): Promise { const duration = formatDuration(trackInfo.length) @@ -1872,7 +1877,7 @@ export async function buildHoloTrack( accessibilityLabel || `${trackInfo.title} by ${(channelData.name as string | undefined) || trackInfo.author}` - if (config.fetchChannelInfo && channelData.id && makeRequestFn) { + if (config.search.fetchChannelInfo && channelData.id && makeRequestFn) { try { const channelInfo = await fetchChannelInfo( channelData.id as string, @@ -1906,7 +1911,7 @@ export async function buildHoloTrack( let externalLinks = extractExternalLinks(description) - if (config.resolveExternalLinks && externalLinks && makeRequestFn) { + if (config.search.resolveExternalLinks && externalLinks && makeRequestFn) { try { externalLinks = await resolveExternalLinks(externalLinks, makeRequestFn) } catch (e: unknown) { @@ -2078,7 +2083,7 @@ export async function fetchEncryptedHostFlags( */ export abstract class BaseClient { nodelink: WorkerNodeLink - config: Record + config: WorkerNodeLink['options'] name: string oauth: IOAuth | null @@ -2375,10 +2380,11 @@ export abstract class BaseClient { sourceName, null, playerResponse as Record, - !!this.config.enableHoloTracks, + !!this.config.experimental.enableHoloTracks, { - resolveExternalLinks: !!this.config.resolveExternalLinks, - fetchChannelInfo: !!this.config.fetchChannelInfo + resolveExternalLinks: !!this.config.search.resolveExternalLinks, + fetchChannelInfo: !!this.config.search.fetchChannelInfo, + search: {} } ) @@ -2495,7 +2501,7 @@ export abstract class BaseClient { const tracks: YouTubeTrackData[] = [] let selectedTrack = 0 const maxLength = - (this.config.maxAlbumPlaylistLength as number | undefined) || 100 + (this.config.playback?.maxPlaylistLength as number | undefined) || 100 for (let i = 0; i < Math.min(playlistContent.length, maxLength); i++) { const item = playlistContent[i] as YouTubeRenderer @@ -2505,10 +2511,11 @@ export abstract class BaseClient { sourceName || 'youtube', null, null, - !!this.config.enableHoloTracks, + !!this.config.experimental.enableHoloTracks, { fetchChannelInfo: false, - resolveExternalLinks: false + resolveExternalLinks: false, + search: {} } ) if (track) { @@ -2644,7 +2651,7 @@ export abstract class BaseClient { const tracks: YouTubeTrackData[] = [] const maxLength = - (this.config.maxAlbumPlaylistLength as number | undefined) || 100 + (this.config.playback?.maxPlaylistLength as number | undefined) || 100 const shelfContents = shelf.contents as unknown[] for (let i = 0; i < Math.min(shelfContents.length, maxLength); i++) { @@ -2655,10 +2662,11 @@ export abstract class BaseClient { sourceName || 'ytmusic', sourceName, browseResponse, - !!this.config.enableHoloTracks, + !!this.config.experimental.enableHoloTracks, { fetchChannelInfo: false, - resolveExternalLinks: false + resolveExternalLinks: false, + search: {} } ) if (track) { @@ -2768,7 +2776,7 @@ export abstract class BaseClient { targetItags = [Number(targetItag)] } else { const qualityPriority = this._getQualityPriority() - const audioConfig = this.config.audio as + const audioConfig = this.config.playback?.audio as | Record | undefined const audioQuality = @@ -3307,4 +3315,50 @@ export abstract class BaseClient { itag ) } + + /** + * Fetches the initial visitor data by making a GET request to YouTube. + * This is used to initialize the session context. + * + * @returns The extracted visitor data string, or null if it could not be found. + */ + async getVisitorData(): Promise { + try { + const response = await makeRequest('https://www.youtube.com', { + method: 'GET', + headers: { + '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' + }, + proxy: this.getProxy() + }) + + if (response.statusCode !== 200 || !response.body) { + return null + } + + const body = response.body as string + const match = body.match(/ytcfg\.set\((\{.*?\})\);/) + if (match?.[1]) { + try { + const ytcfg = JSON.parse(match[1]) + if (ytcfg.VISITOR_DATA) { + return ytcfg.VISITOR_DATA as string + } + } catch { + // Fallback to regex if JSON parse fails + } + } + + const visitorDataMatch = body.match(/"visitorData":"([^"]+)"/) + return visitorDataMatch?.[1] || null + } catch (err) { + logger( + 'debug', + `youtube-${this.name}`, + `Failed to fetch visitor data: ${err instanceof Error ? err.message : String(err)}` + ) + return null + } + } } From f6f7cedd3f4f74126a0d4dd85840d65cea1d6e8c Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:22:34 -0400 Subject: [PATCH 23/58] update: restore robust YouTube visitorData and proxy management logic --- config.default.js | 671 ------- dist/package.json | 4 +- dist/src/api/index.js | 2 +- dist/src/api/info.js | 2 +- dist/src/api/loadStream.js | 6 +- dist/src/api/loadTracks.js | 2 +- dist/src/api/sessions.id.players.id.mix.js | 2 +- dist/src/api/trackstream.js | 2 +- dist/src/index.js | 71 +- dist/src/managers/configValidationManager.js | 1023 ++-------- dist/src/managers/connectionManager.js | 8 +- dist/src/managers/credentialManager.js | 319 +--- dist/src/managers/dosProtectionManager.js | 2 +- dist/src/managers/pluginManager.js | 66 +- dist/src/managers/rateLimitManager.js | 2 +- dist/src/managers/routePlannerManager.js | 2 +- dist/src/managers/sourceManager.js | 37 +- dist/src/managers/statsManager.js | 9 +- dist/src/managers/trackCacheManager.js | 216 +-- dist/src/playback/hls/HLSHandler.js | 2 +- dist/src/playback/hls/SegmentFetcher.js | 2 +- dist/src/playback/player.js | 42 +- .../playback/processing/streamProcessor.js | 14 +- dist/src/sources/applemusic.js | 2 +- dist/src/sources/audius.js | 2 +- dist/src/sources/bandcamp.js | 2 +- dist/src/sources/bilibili.js | 36 +- dist/src/sources/bluesky.js | 6 +- dist/src/sources/deezer.js | 4 +- dist/src/sources/eternalbox.js | 2 +- dist/src/sources/gaana.js | 6 +- dist/src/sources/iheartradio.js | 8 +- dist/src/sources/jiosaavn.js | 4 +- dist/src/sources/lastfm.js | 2 +- dist/src/sources/letrasmus.js | 8 +- dist/src/sources/mixcloud.js | 6 +- dist/src/sources/monochrome.js | 2 +- dist/src/sources/netease.js | 2 +- dist/src/sources/pandora.js | 4 +- dist/src/sources/qobuz.js | 4 +- dist/src/sources/shazam.js | 2 +- dist/src/sources/songlink.js | 2 +- dist/src/sources/soundcloud.js | 14 +- dist/src/sources/spotify.js | 4 +- dist/src/sources/tidal.js | 2 +- dist/src/sources/twitter.js | 2 +- dist/src/sources/vkmusic.js | 16 +- dist/src/sources/yandexmusic.js | 6 +- dist/src/sources/youtube/YouTube.js | 1664 +++-------------- dist/src/sources/youtube/clients/Android.js | 4 +- dist/src/sources/youtube/clients/AndroidVR.js | 4 +- dist/src/sources/youtube/clients/Web.js | 4 +- .../sources/youtube/clients/WebEmbedded.js | 4 +- dist/src/sources/youtube/common.js | 20 +- dist/src/utils.js | 2 +- dist/src/workers/main.js | 12 +- package.json | 4 +- src/api/index.ts | 2 +- src/api/info.ts | 2 +- src/api/loadStream.ts | 8 +- src/api/loadTracks.ts | 2 +- src/api/sessions.id.players.id.mix.ts | 2 +- src/api/trackstream.ts | 2 +- src/index.ts | 126 +- src/lyrics/monochrome.ts | 9 +- src/managers/connectionManager.ts | 9 +- src/managers/credentialManager.ts | 398 +--- src/managers/dosProtectionManager.ts | 14 +- src/managers/lyricsManager.ts | 6 +- src/managers/pluginManager.ts | 80 +- src/managers/rateLimitManager.ts | 14 +- src/managers/routePlannerManager.ts | 48 +- src/managers/sourceManager.ts | 113 +- src/managers/statsManager.ts | 20 +- src/managers/trackCacheManager.ts | 283 +-- src/playback/hls/HLSHandler.ts | 2 +- src/playback/hls/SegmentFetcher.ts | 2 +- src/playback/player.ts | 45 +- src/playback/processing/streamProcessor.ts | 14 +- src/sources/applemusic.ts | 2 +- src/sources/audius.ts | 2 +- src/sources/bandcamp.ts | 7 +- src/sources/bilibili.ts | 36 +- src/sources/bluesky.ts | 18 +- src/sources/deezer.ts | 10 +- src/sources/eternalbox.ts | 2 +- src/sources/gaana.ts | 11 +- src/sources/googledrive.ts | 8 +- src/sources/iheartradio.ts | 12 +- src/sources/jiosaavn.ts | 4 +- src/sources/lastfm.ts | 2 +- src/sources/lazypytts.ts | 2 +- src/sources/letrasmus.ts | 12 +- src/sources/mixcloud.ts | 6 +- src/sources/monochrome.ts | 4 +- src/sources/netease.ts | 2 +- src/sources/pandora.ts | 4 +- src/sources/qobuz.ts | 4 +- src/sources/shazam.ts | 5 +- src/sources/songlink.ts | 3 +- src/sources/soundcloud.ts | 14 +- src/sources/spotify.ts | 4 +- src/sources/tidal.ts | 2 +- src/sources/twitter.ts | 5 +- src/sources/vkmusic.ts | 16 +- src/sources/yandexmusic.ts | 8 +- src/typings/index.types.ts | 3 + src/typings/meanings/meaning.types.ts | 6 +- src/typings/playback/hls.types.ts | 16 + src/typings/playback/player.types.ts | 43 +- src/typings/sources/source.types.ts | 16 +- src/typings/sources/youtube.types.ts | 32 + src/typings/utils.types.ts | 4 + src/utils.ts | 9 +- src/workers/main.ts | 72 +- src/workers/source.ts | 56 +- 116 files changed, 1457 insertions(+), 4578 deletions(-) delete mode 100644 config.default.js diff --git a/config.default.js b/config.default.js deleted file mode 100644 index da53ad9f..00000000 --- a/config.default.js +++ /dev/null @@ -1,671 +0,0 @@ -export default { - server: { - host: '0.0.0.0', - port: 3000, - password: 'youshallnotpass', - useBunServer: false // set to true to use Bun.serve websocket (experimental) - }, - cluster: { - enabled: true, // active cluster (or use env CLUSTER_ENABLED) - workers: 0, // 0 => uses os.cpus().length, or specify a number (1 = 2 processes total: master + 1 worker) - minWorkers: 1, // Minimum workers to keep alive (improves availability during bursts) - runtime: { - workerMaxOldSpaceMb: 0, // 0 disables override; set >0 to pass --max-old-space-size to playback workers - workerExposeGc: false, // If true, adds --expose-gc to playback workers - workerExecArgv: [], // Extra Node.js args for playback workers (e.g. ['--trace-gc']) - sourceWorkerMaxOldSpaceMb: 0, // 0 disables override; set >0 to pass --max-old-space-size to source workers - sourceWorkerExposeGc: false, // If true, adds --expose-gc to source workers - sourceWorkerExecArgv: [] // Extra Node.js args for source workers - }, - specializedSourceWorker: { - enabled: true, // If true, source loading (search, lyrics, etc.) is delegated to dedicated workers to prevent voice worker lag - count: 1, // Number of separate process clusters for source operations - microWorkers: 2, // Number of worker threads per process cluster - tasksPerWorker: 32, // Number of parallel tasks each micro-worker can handle before queuing - silentLogs: true // If true, micro-workers will only log warnings and errors - }, - commandTimeout: 6000, // Timeout for heavy operations like loadTracks (6s) - fastCommandTimeout: 4000, // Timeout for player commands like play/pause (4s) - maxRetries: 2, // Number of retry attempts on timeout or worker failure - hibernation: { - enabled: true, - timeoutMs: 1200000 - }, - scaling: { - //scaling configurations - maxPlayersPerWorker: 20, // Reference capacity for utilization calculation - targetUtilization: 0.7, // Target utilization for scaling up/down - scaleUpThreshold: 0.75, // Utilization threshold to scale up - scaleDownThreshold: 0.3, // Utilization threshold to scale down - checkIntervalMs: 5000, // Interval to check for scaling needs - idleWorkerTimeoutMs: 60000, // Time in ms an idle worker should wait before being removed - queueLengthScaleUpFactor: 5, // How many commands in queue per active worker trigger scale up - lagPenaltyLimit: 60, // Event loop lag threshold (ms) to penalize worker cost - cpuPenaltyLimit: 0.85 // CPU usage threshold (85% of a core) to force scale up - }, - endpoint: { - patchEnabled: true, - allowExternalPatch: false, - code: 'CAPYBARA' - } - }, - logging: { - level: 'debug', - file: { - enabled: false, - path: 'logs', - rotation: 'daily', - ttlDays: 7 - }, - debug: { - all: false, - request: true, - session: true, - player: true, - filters: true, - sources: true, - lyrics: true, - youtube: true, - 'youtube-cipher': true, - sabr: false, - potoken: false - } - }, - connection: { - logAllChecks: false, - interval: 300000, // 5 minutes - timeout: 10000, // 10 seconds - thresholds: { - bad: 1, // Mbps - average: 5 // Mbps - } - }, - maxSearchResults: 10, - maxAlbumPlaylistLength: 100, - playerUpdateInterval: 2000, - statsUpdateInterval: 30000, - trackStuckThresholdMs: 10000, - eventTimeoutMs: 15000, - zombieThresholdMs: 60000, - enableHoloTracks: false, - enableTrackStreamEndpoint: false, - enableLoadStreamEndpoint: false, - resolveExternalLinks: false, - fetchChannelInfo: false, - sponsorblock: { - enabled: false, - api: 'https://sponsor.ajay.app', - categories: [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'preview', - 'music_offtopic', - 'filler' - ], - actionTypes: ['skip'], - skipMarginMs: 150 - }, - filters: { - enabled: { - tremolo: true, - vibrato: true, - lowpass: true, - highpass: true, - rotation: true, - karaoke: true, - distortion: true, - channelMix: true, - equalizer: true, - chorus: true, - compressor: true, - echo: true, - phaser: true, - timescale: true - } - }, - defaultSearchSource: ['youtube', 'soundcloud'], - unifiedSearchSources: ['youtube', 'soundcloud'], - sources: { - vkmusic: { - enabled: true, - userToken: '', // (optional) get from vk in browser devtools -> reqs POST /?act=web_token HTTP/2 - headers -> response -> access_token - userCookie: '', // (required without userToken) get from vk in browser devtools -> reqs POST /?act=web_token HTTP/2 - headers -> request -> cookie (copy full cookie header) - proxy: { - url: '', - username: '', - password: '' - } - }, - amazonmusic: { - enabled: true - }, - bluesky: { - enabled: true - }, - anghami: { - enabled: false, - cookies: '' // Optional: Useful for accessing restricted or private content - }, - rss: { - enabled: true - }, - songlink: { - enabled: true, - apiKey: '', - userCountry: 'US', - songIfSingle: true, - useApi: true, - useScrapeFallback: true, - preferredPlatforms: [ - 'spotify', - 'appleMusic', - 'youtubeMusic', - 'youtube', - 'deezer', - 'tidal', - 'amazonMusic', - 'soundcloud', - 'bandcamp', - 'audius', - 'audiomack', - 'pandora', - 'itunes', - 'amazonStore' - ], - fallbackToAny: true - }, - mixcloud: { - enabled: true - }, - audiomack: { - enabled: true - }, - deezer: { - // arl: '', - // decryptionKey: '', - enabled: true - }, - bandcamp: { - enabled: true - }, - soundcloud: { - enabled: true - // clientId: "" - }, - local: { - enabled: true, - basePath: './local-music/' - }, - http: { - enabled: true, - userAgent: '' // Optional: defaults to NodeLink/ (https://github.com/PerformanC/NodeLink) - }, - eternalbox: { - enabled: true, - baseUrl: 'https://eternalboxmirror.xyz', - searchResults: 30, - enrichSpotify: true, - includeAnalysis: true, - includeAnalysisSummary: true, - eternalStream: true, - cacheMaxBytes: 20 * 1024 * 1024, - maxBranches: 4, - maxBranchThreshold: 75, - branchThresholdStart: 10, - branchThresholdStep: 5, - branchTargetDivisor: 6, - addLastEdge: true, - justBackwards: false, - justLongBranches: false, - removeSequentialBranches: true, - useFilteredSegments: true, - minRandomBranchChance: 0.18, - maxRandomBranchChance: 0.5, - randomBranchChanceDelta: 0.09, - timbreWeight: 1, - pitchWeight: 10, - loudStartWeight: 1, - loudMaxWeight: 1, - durationWeight: 100, - confidenceWeight: 1, - infiniteStream: true, - maxReconnects: 0, - reconnectDelayMs: 1000 - }, - 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 - }, - iheartradio: { - enabled: true - }, - telegram: { - enabled: true - }, - shazam: { - enabled: true, - allowExplicit: true - }, - bilibili: { - enabled: true, - sessdata: '' // Optional, improves access to some videos (premium and 4k+) - }, - genius: { - enabled: true - }, - pinterest: { - enabled: true - }, - flowery: { - enabled: true, - voice: 'Salli', - translate: false, - silence: 0, - speed: 1.0, - enforceConfig: false - }, - lazypytts: { - enabled: true, - service: 'Cerence', - voice: 'Luciana', - maxTextLength: 3000, - enforceConfig: false - }, - jiosaavn: { - enabled: true, - playlistLoadLimit: 50, - artistLoadLimit: 20, - proxy: { - url: '', - username: '', - password: '' - } - // "secretKey": "38346591" // Optional, defaults to standard key - }, - gaana: { - enabled: true, - streamQuality: 'high', - playlistLoadLimit: 100, - albumLoadLimit: 100, - artistLoadLimit: 100, - proxy: { - url: '', // The HTTP/HTTPS proxy to use - username: '', // Optional username - password: '' // Optional password - } - }, - 'google-tts': { - enabled: true, - language: 'en-US' - }, - // Piper TTS Configuration - // This source uses an external Piper TTS HTTP server. - // You can find the Piper HTTP server repository here: - // https://github.com/OHF-Voice/piper1-gpl/tree/main?tab=readme-ov-file - pipertts: { - enabled: false, // Disabled by default. Enable it to use Piper TTS. - url: 'http://localhost:5000' // URL of your Piper TTS server - // Optional settings (defaults from Piper): - // voice: 'en_US-lessac-medium', - // speaker: 0, - // length_scale: 1.0, - // noise_scale: 0.667, - // noise_w_scale: 0.8 - }, - youtube: { - enabled: true, - allowItag: [], // additional itags for audio streams, e.g., [140, 141] - targetItag: null, // force a specific itag for audio streams, overriding the quality option - getOAuthToken: false, - hl: 'en', - gl: 'US', - proxies: [ - /* { - url: "http://proxy1:port", - username: "username", - password: "password" - }, - { - url: "http://proxy2:port" - } */ - ], - fallbackSources: [ - 'soundcloud', - 'deezer', - 'jiosaavn', - 'qobuz', - 'gaana', - 'vkmusic', - 'yandexmusic', - 'audiomack', - 'bandcamp', - 'audius', - 'mixcloud', - 'bilibili', - 'bluesky', - 'nicovideo' - ], // Internal fallback chain when YouTube stream URL fails - clients: { - search: ['Android'], // Clients used for searching tracks - playback: [ - 'AndroidVR', - 'TV', - 'TVCast', - 'WebEmbedded', - 'WebParentTools', - 'Web', - 'IOS' - ], // Clients used for playback/streaming - resolve: [ - 'AndroidVR', - 'TV', - 'TVCast', - 'WebEmbedded', - 'WebParentTools', - 'IOS', - 'Web' - ], // Clients used for resolving detailed track information (channel, external links, etc.) - settings: { - TV: { - refreshToken: [''] // You can use a string "token" or an array ["token1", "token2"] for rotation/fallback - } - } - }, - cipher: { - url: 'https://cipher.kikkia.dev/api', - token: null - } - }, - instagram: { - enabled: true - }, - kwai: { - enabled: true - }, - twitch: { - enabled: true - }, - spotify: { - enabled: true, - clientId: '', - clientSecret: '', - externalAuthUrl: 'http://get.1lucas1apk.fun/spotify/gettoken', // URL to external token provider (e.g. http://localhost:8080/api/token - use https://github.com/topi314/spotify-tokener or https://github.com/1Lucas1apk/gettoken) - market: 'US', - playlistLoadLimit: 1, // 0 means no limit (loads all tracks), 1 = 100 tracks, 2 = 100 and so on! - playlistPageLoadConcurrency: 10, // How many pages to load simultaneously - albumLoadLimit: 1, // 0 means no limit (loads all tracks), 1 = 50 tracks, 2 = 100 tracks, etc. - albumPageLoadConcurrency: 5, // How many pages to load simultaneously - allowExplicit: true, // If true plays the explicit version of the song, If false plays the Non-Explicit version of the song. Normal songs are not affected. - allowLocalFiles: false, // If true, Spotify playlist local files are kept as placeholder tracks so they can still be searched and played through fallback sources. - sp_dc: '' // fot getting mobile token (optional) get from spotify in browser devtools -> Application -> Cookies -> sp_dc (required for canvas) - }, - applemusic: { - enabled: true, - mediaApiToken: 'token_here', //manually | or "token_here" to get a token automatically - market: 'US', - playlistLoadLimit: 0, - albumLoadLimit: 0, - playlistPageLoadConcurrency: 5, - albumPageLoadConcurrency: 5, - allowExplicit: true - }, - audius: { - enabled: true, - appName: '', - apiKey: '', // go to https://audius.co/settings and create an app and paste the app name and api stuff into here. - apiSecret: '', - playlistLoadLimit: 100, - albumLoadLimit: 100 - }, - tidal: { - enabled: true, - 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 - hifiApis: [''], // optional, but required for direflct streaming, artist resolving host: https://github.com/binimum/hifi-api/ - hifiQualities: ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'] //tried sequentially until one works (only used if hifiApis is set) - }, - pandora: { - enabled: true, - // Optional, setting this manually can help unblocking countries (since pandora is US only.). May need to be updated periodically. - // fetching manually: use a vpn connected to US, go on pandora.com, open devtools, Network tab, first request to appear and copy the 2nd csrfToken= value. - // csrfToken: '', - remoteTokenUrl: 'https://get.1lucas1apk.fun/pandora/gettoken' // URL to a remote provider that returns { success: true, authToken: "...", csrfToken: "...", expires_in_seconds: ... } //https://github.com/1Lucas1apk/gettoken - }, - nicovideo: { - enabled: true - }, - reddit: { - enabled: true - }, - tumblr: { - enabled: true - }, - twitter: { - enabled: true - }, - qobuz: { - enabled: true, - userToken: '', // (optional) get from play.qobuz.com in browser devtools -> Application -> Local Storage -> localuser -> token - formatId: '5', // 5 = MP3 320kbps, 6 = FLAC (requires Studio subscription), 27 = Hi-Res FLAC - allowExplicit: true - }, - lastfm: { - enabled: true, - apiKey: '' // You can get the api key from: https://www.last.fm/api/account/create - }, - netease: { - enabled: true - }, - letrasmus: { - enabled: true - }, - yandexmusic: { - enabled: true, - accessToken: '', - allowUnavailable: false, - allowExplicit: true, - artistLoadLimit: 1, // 0 = no limit, 1 = 10 tracks, 2 = 20 tracks, etc. - albumLoadLimit: 1, // 0 = no limit, 1 = 50 tracks, 2 = 100 tracks, etc. - playlistLoadLimit: 1, // 0 = no limit, 1 = 100 tracks, 2 = 200 tracks, etc. - proxy: { - url: '', - username: '', - password: '' - } - }, - monochrome: { - enabled: true, - instances: [], // (optional) list of API instances - streamingInstances: [], // (optional) list of streaming instances - qobuzInstances: [], // (optional) list of Qobuz API instances - quality: 'HIGH' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW - }, - googledrive: { - enabled: true - }, - tiktok: { - enabled: true - } - }, - lyrics: { - fallbackSource: 'genius', - youtube: { - enabled: true - }, - genius: { - enabled: true - }, - musixmatch: { - enabled: true - // signatureSecret: '' - }, - deezer: { - enabled: true - }, - lrclib: { - enabled: true - }, - letrasmus: { - enabled: true - }, - bilibili: { - enabled: true - }, - yandexmusic: { - enabled: true - }, - monochrome: { - enabled: true - } - }, - meanings: { - letrasmus: { - enabled: true - }, - wikipedia: { - enabled: true - } - }, - audio: { - quality: 'high', // high, medium, low, lowest - encryption: 'aead_xchacha20_poly1305_rtpsize', // aead_aes256_gcm_rtpsize or aead_xchacha20_poly1305_rtpsize - resamplingQuality: 'best', // best, medium, fastest, zero order holder, linear - loudnessNormalizer: false, // Enable/disable AGC globally - lookaheadMs: 5, // Limiter lookahead buffer in milliseconds - gateThresholdLUFS: -60, // Silence threshold for AGC gate - fading: { - enabled: false, // Master switch for all fades - // type meanings: - // volume = only amplitude fades, tape = pitch/speed ramps, both = simultaneous fade and ramp, scratch = physical vinyl simulation - // curve meanings: - // linear = constant rate, exponential = slow start then faster, sinusoidal = smooth s-curve, start/wash/stop/random/baby = scratch specific movements - trackStart: { - // Effect when a new track begins - duration: 0, // ms - curve: 'linear', - type: 'volume' // volume, tape, both - }, - trackEnd: { - // Effect triggered automatically before track finishes - duration: 0, - curve: 'linear', - type: 'volume' - }, - trackStop: { - // Effect when manually stopping or skipping - duration: 0, - curve: 'linear', - type: 'volume' - }, - seek: { - // Effect applied after a seek operation - duration: 0, - curve: 'linear', - type: 'volume' - }, - pause: { - // Effect applied when pausing playback - duration: 0, - curve: 'sinusoidal', - type: 'tape' - }, - resume: { - // Effect applied when resuming from pause - duration: 0, - curve: 'sinusoidal', - type: 'tape' - }, - ducking: { - // Partial fade out for overlay events (e.g., TTS, notifications) - enabled: false, - duration: 0, // ms - targetVolume: 0.3, // Volume multiplier (0.3 = 30%) - curve: 'linear' - } - }, - crossfade: { - enabled: false, - duration: 0, // Crossfade duration in milliseconds - curve: 'sinusoidal', // linear | sine | sinusoidal - mode: 'preload', // preload or stream - minBufferMs: 250, // Minimum buffered PCM before crossfade starts - bufferMs: 0 // 0 = auto (use duration) - } - }, - voiceReceive: { - enabled: false, - format: 'opus' // pcm_s16le, opus - }, - routePlanner: { - strategy: 'RotateOnBan', // RotateOnBan, RoundRobin, LoadBalance - bannedIpCooldown: 600000, // 10 minutes - ipBlocks: [] - }, - rateLimit: { - enabled: true, - global: { - maxRequests: 1000, - timeWindowMs: 60000 // 1 minute - }, - perIp: { - maxRequests: 100, - timeWindowMs: 10000 // 10 seconds - }, - perUserId: { - maxRequests: 50, - timeWindowMs: 5000 // 5 seconds - }, - perGuildId: { - maxRequests: 20, - timeWindowMs: 5000 // 5 seconds - }, - ignorePaths: [], - ignore: { - userIds: [], - guildIds: [], - ips: [] - } - }, - dosProtection: { - enabled: true, - thresholds: { - burstRequests: 50, - timeWindowMs: 10000 // 10 seconds - }, - mitigation: { - delayMs: 500, - blockDurationMs: 300000 // 5 minutes - }, - ignore: { - userIds: [], - guildIds: [], - ips: [] - } - }, - metrics: { - enabled: true, - authorization: { - type: 'Bearer', // Bearer or Basic. - username: 'admin', - password: '' // If empty, uses server.password - } - }, - mix: { - enabled: true, - defaultVolume: 0.8, - maxLayersMix: 5, - autoCleanup: true - }, - plugins: [ - /* { - name: 'nodelink-sample-plugin', - source: 'local' - } */ - ], - pluginConfig: {} -} diff --git a/dist/package.json b/dist/package.json index ce9b7ead..2a296d65 100644 --- a/dist/package.json +++ b/dist/package.json @@ -30,12 +30,12 @@ "proxy-agent": "^8.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.13", + "@biomejs/biome": "^2.4.15", "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@types/bun": "^1.3.13", "@types/jsdom": "^28.0.1", - "@types/node": "^25.6.0", + "@types/node": "^25.6.2", "dotenv": "^17.4.2", "husky": "9.1.7", "tsx": "^4.21.0", diff --git a/dist/src/api/index.js b/dist/src/api/index.js index ced90178..3545d879 100644 --- a/dist/src/api/index.js +++ b/dist/src/api/index.js @@ -171,7 +171,7 @@ async function requestHandler(nodelink, req, res) { parsedUrl.pathname === `/${PATH_VERSION}/profiler/ui` || parsedUrl.pathname === `/${PATH_VERSION}/profiler/file`; if (isMetricsEndpoint) { - const metricsConfig = nodelink.options.metrics || {}; + const metricsConfig = nodelink.options.api.metrics || {}; if (!metricsConfig.enabled) { logger('warn', 'Metrics', `Metrics endpoint disabled - ${clientAddress} attempted to access ${parsedUrl.pathname}`); res.writeHead(404, { 'Content-Type': 'text/plain' }); diff --git a/dist/src/api/info.js b/dist/src/api/info.js index be708869..04f5f557 100644 --- a/dist/src/api/info.js +++ b/dist/src/api/info.js @@ -137,7 +137,7 @@ async function buildInfoResponse(nodelink) { }, isNodelink: true, sourceManagers: await getSourceManagers(nodelink), - filters: getEnabledFilterNames(nodelink.options.filters?.enabled), + filters: getEnabledFilterNames(nodelink.options.playback.filters?.enabled), plugins: getPlugins(nodelink.pluginManager) }; } diff --git a/dist/src/api/loadStream.js b/dist/src/api/loadStream.js index d373aacf..f4e03502 100644 --- a/dist/src/api/loadStream.js +++ b/dist/src/api/loadStream.js @@ -224,7 +224,7 @@ async function handler(nodelink, req, res, _sendResponse, parsedUrl) { sendErrorResponse(req, res, 500, 'Internal Server Error', 'Load stream runtime contract is incomplete.', parsedUrl.pathname, true); return; } - if (!runtime.options.enableLoadStreamEndpoint) { + if (!runtime.options.api.enableLoadStreamEndpoint) { sendErrorResponse(req, res, 404, 'Not Found', 'The requested route was not found.', parsedUrl.pathname); return; } @@ -285,7 +285,7 @@ async function handler(nodelink, req, res, _sendResponse, parsedUrl) { if (urlResult.url && !isHls && !isLocal && !isSabr) { const resource = (await createSeekeableAudioResource(input.guildId || 'api-stream', urlResult.url, input.position, undefined, streamProcessorRuntime, input.filters, { streamInfo: urlResult, - loudnessNormalizer: runtime.options.audio?.loudnessNormalizer + loudnessNormalizer: runtime.options.playback.audio?.loudnessNormalizer }, input.volume / 100, null, true)); if ('exception' in resource) { sendErrorResponse(req, res, 500, 'Internal Server Error', resource.exception.message, parsedUrl.pathname); @@ -308,7 +308,7 @@ async function handler(nodelink, req, res, _sendResponse, parsedUrl) { } fetchedStream = fetched.stream; const resource = createAudioResource(input.guildId || 'api-stream', fetched.stream, fetched.type ?? - (typeof urlResult.format === 'string' ? urlResult.format : 'unknown'), streamProcessorRuntime, input.filters, input.volume / 100, null, true, runtime.options.audio?.loudnessNormalizer); + (typeof urlResult.format === 'string' ? urlResult.format : 'unknown'), streamProcessorRuntime, input.filters, input.volume / 100, null, true, runtime.options.playback.audio?.loudnessNormalizer); pcmStream = resource.stream; } pcmStream.on('error', (error) => { diff --git a/dist/src/api/loadTracks.js b/dist/src/api/loadTracks.js index 91ca5aad..7076b934 100644 --- a/dist/src/api/loadTracks.js +++ b/dist/src/api/loadTracks.js @@ -129,7 +129,7 @@ async function handler(nodelink, req, res, sendResponse, parsedUrl) { return sendErrorResponse(req, res, 400, 'missing identifier parameter', IDENTIFIER_REQUIRED_MESSAGE, parsedUrl.pathname, true); } logger('debug', 'Tracks', `Loading tracks with identifier: "${identifier}"`); - const target = parseIdentifier(identifier, nodelink.options.defaultSearchSource); + const target = parseIdentifier(identifier, nodelink.options.search.defaultSource); const workerRequest = buildWorkerRequest(target); const runtime = nodelink; try { diff --git a/dist/src/api/sessions.id.players.id.mix.js b/dist/src/api/sessions.id.players.id.mix.js index ba679a77..c65db2e0 100644 --- a/dist/src/api/sessions.id.players.id.mix.js +++ b/dist/src/api/sessions.id.players.id.mix.js @@ -130,7 +130,7 @@ async function handleCreateMix(req, res, pathParams, runtime, sendResponse, pars sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid parameters', parsedUrl.pathname, true); return; } - const mixConfig = runtime.options.mix ?? { + const mixConfig = runtime.options.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5, diff --git a/dist/src/api/trackstream.js b/dist/src/api/trackstream.js index b408bc08..10f8e5e5 100644 --- a/dist/src/api/trackstream.js +++ b/dist/src/api/trackstream.js @@ -57,7 +57,7 @@ function getTrackStreamRuntime(nodelink) { * @returns Promise that resolves once the response has been written. */ async function handler(nodelink, req, res, sendResponse, parsedUrl) { - if (!nodelink.options.enableTrackStreamEndpoint) { + if (!nodelink.options.api.enableTrackStreamEndpoint) { sendErrorResponse(req, res, 404, 'Not Found', 'The requested route was not found.', parsedUrl.pathname); return; } diff --git a/dist/src/index.js b/dist/src/index.js index ac64df35..e0d8ff9b 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -12,6 +12,7 @@ import http from 'node:http'; import { resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import WebSocketServer from '@performanc/pwsl-server'; +import { migrateConfig } from "./modules/config/configMigration.js"; import RoutePlannerManager from "./managers/routePlannerManager.js"; import SessionManager from "./managers/sessionManager.js"; import StatsManager from "./managers/statsManager.js"; @@ -154,28 +155,31 @@ const getTrackCacheManagerClass = async () => { return trackCacheManagerClassPromise; }; let config; -const resolveRootConfigUrl = (fileName) => pathToFileURL(resolvePath(process.cwd(), fileName)).href; -try { - config = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.js')))) - .default; -} -catch (e) { - const error = e; - if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ENOENT') { - try { - config = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.js')))) - .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.'); - } - catch (e2) { - console.error('[ERROR] Config: Failed to load config.default.js. Please make sure it exists.'); - throw e2; - } +const loadConfig = async () => { + const resolveRootConfigUrl = (fileName) => pathToFileURL(resolvePath(process.cwd(), fileName)).href; + try { + const imported = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.ts')))).default; + console.log('[INFO] Config: Loaded configuration from config.ts'); + return migrateConfig(imported); } - else { + catch (e) { + const error = e; + // Handle various error formats for missing modules across different runtimes (Node, Bun, ts-node, tsx) + if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ENOENT' || error.message?.includes('Cannot find module')) { + try { + const imported = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.ts')))).default; + console.log('[INFO] Config: Loaded fallback configuration from config.default.ts'); + return migrateConfig(imported); + } + catch (e2) { + console.error('[ERROR] Config: Failed to load configuration (config.ts or config.default.ts).'); + throw e2; + } + } throw e; } -} +}; +config = await loadConfig(); // Apply environment variable overrides after config is loaded applyEnvOverrides(config); const clusterEnabled = @@ -316,6 +320,7 @@ class NodelinkServer extends EventEmitter { lyrics; meanings; _sourceInitPromise; + proxyManager; routePlanner; credentialManager; trackCacheManager; @@ -397,8 +402,8 @@ class NodelinkServer extends EventEmitter { }; this.voiceSockets = new Map(); this.voiceRelay = createVoiceRelay({ - enabled: options.voiceReceive?.enabled || false, - format: options.voiceReceive?.format || 'pcm', + enabled: options.playback.voiceReceive?.enabled || false, + format: options.playback.voiceReceive?.format || 'pcm', sendFrame: (frame) => this.handleVoiceFrame(frame), logger }); @@ -1109,7 +1114,7 @@ class NodelinkServer extends EventEmitter { const voiceMatch = url.pathname.match(/^\/v4\/websocket\/voice\/([A-Za-z0-9]+)\/?$/); const liveMatch = url.pathname.match(/^\/v4\/websocket\/youtube\/live\/([^/]+)\/?$/); if (voiceMatch) { - if (!self.options.voiceReceive?.enabled) { + if (!self.options.playback.voiceReceive?.enabled) { try { wrapper.close(1008, 'Voice receive disabled'); } @@ -1296,7 +1301,7 @@ class NodelinkServer extends EventEmitter { logger('warn', 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid user ID provided`); return rejectUpgrade(400, 'Bad Request', 'Invalid User-Id header.'); } - if (voiceMatch && !this.options.voiceReceive?.enabled) { + if (voiceMatch && !this.options.playback.voiceReceive?.enabled) { return rejectUpgrade(404, 'Not Found', 'Voice websocket endpoint is disabled.'); } for (const key in headers) { @@ -1334,7 +1339,7 @@ class NodelinkServer extends EventEmitter { }); this.socket?.on('/v4/websocket/voice', (socket, request, _clientInfo, _sessionId, guildId) => { socket.guildId = guildId; - if (!this.options.voiceReceive?.enabled) { + if (!this.options.playback.voiceReceive?.enabled) { try { socket.close(1008, 'Voice receive disabled'); } @@ -1445,12 +1450,12 @@ class NodelinkServer extends EventEmitter { _startGlobalUpdater() { if (this._globalUpdater) return; - const updateInterval = Math.max(1, this.options?.playerUpdateInterval ?? 5000); - const statsSendInterval = Math.max(1, this.options?.statsUpdateInterval ?? 30000); - const metricsInterval = this.options?.metrics?.enabled + const updateInterval = Math.max(1, this.options?.playback.playerUpdateInterval ?? 5000); + const statsSendInterval = Math.max(1, this.options?.playback.statsUpdateInterval ?? 30000); + const metricsInterval = this.options?.api.metrics?.enabled ? 5000 : statsSendInterval; - const zombieThreshold = this.options?.zombieThresholdMs ?? 60000; + const zombieThreshold = this.options?.playback.zombieThresholdMs ?? 60000; this._globalUpdater = setInterval(() => { for (const session of this.sessions.values()) { if (!session.players) @@ -1775,8 +1780,8 @@ class NodelinkServer extends EventEmitter { _startMasterMetricsUpdater() { if (this._globalUpdater) return; - const statsSendInterval = Math.max(1, this.options?.statsUpdateInterval ?? 30000); - const metricsInterval = this.options?.metrics?.enabled + const statsSendInterval = Math.max(1, this.options?.playback.statsUpdateInterval ?? 30000); + const metricsInterval = this.options?.api.metrics?.enabled ? 5000 : statsSendInterval; let lastStatsSendTime = 0; @@ -1993,6 +1998,12 @@ else { await nserver.credentialManager?.forceSave(); await nserver.trackCacheManager?.forceSave(); nserver.sourceWorkerManager?.destroy?.(); + nserver.workerManager?.destroy?.(); + nserver.connectionManager?.destroy?.(); + nserver.proxyManager?.destroy?.(); + nserver.routePlanner?.dispose?.(); + nserver.credentialManager?.destroy?.(); + nserver.trackCacheManager?.destroy?.(); await nserver._cleanupWebSocketServer(); if (nserver.server?.listening) { await new Promise((resolve) => nserver.server.close(resolve)); diff --git a/dist/src/managers/configValidationManager.js b/dist/src/managers/configValidationManager.js index d20db182..c20f626a 100644 --- a/dist/src/managers/configValidationManager.js +++ b/dist/src/managers/configValidationManager.js @@ -1,929 +1,122 @@ -const KNOWN_PLACEHOLDERS = new Set([ - 'your_token_here', - 'changeme', - 'change_me', - 'your-token-here', - 'insert_token_here', - 'placeholder', - 'PLACEHOLDER', - 'YOUR_TOKEN', - 'YOUR_API_KEY', - 'YOUR_CLIENT_ID', - 'YOUR_CLIENT_SECRET' -]); -const VALID_AUDIO_QUALITIES = new Set(['high', 'medium', 'low', 'lowest']); -const VALID_RESAMPLING_QUALITIES = new Set([ - 'best', - 'medium', - 'fastest', - 'zero', - 'linear' -]); -const VALID_FADING_CURVES = new Set([ - 'linear', - 'exponential', - 'sinusoidal', - 'start', - 'wash', - 'stop', - 'random', - 'baby' -]); -const VALID_FADING_TYPES = new Set(['volume', 'tape', 'both', 'scratch']); -const VALID_VOICE_FORMATS = new Set(['opus', 'pcm_s16le']); -const VALID_ROUTE_STRATEGIES = new Set([ - 'RotateOnBan', - 'RoundRobin', - 'LoadBalance' -]); -const VALID_METRICS_AUTH_TYPES = new Set(['Bearer', 'Basic']); +import { validator } from "../validators.js"; +import { logger } from "../utils.js"; +/** + * Validates the NodeLink configuration object using a schema-based approach. + * Supports the new hierarchical configuration structure. + */ export default class ConfigValidationManager { - warnings = []; - options; - constructor(options) { - this.options = options; - } + config; + constructor(config) { + this.config = config; + } + /** + * Performs a full validation of the configuration. + * @throws Error if validation fails. + */ validate() { - this.warnings = []; - const allRules = [ - ...this.validateServer(), - ...this.validateCluster(), - ...this.validateAudio(), - ...this.validatePlayback(), - ...this.validateSources(), - ...this.validateSearch(), - ...this.validateRoutePlanner(), - ...this.validateRateLimit(), - ...this.validateDosProtection(), - ...this.validateLogging(), - ...this.validateConnection(), - ...this.validateVoiceReceive(), - ...this.validateMix(), - ...this.validateMetrics(), - ...this.validateFilters() - ]; - const errors = []; - for (const rule of allRules) { - if (!rule.validate(rule.value)) { - errors.push(`Configuration error:\n` + - `- Property: ${rule.path}\n` + - `- Received: ${JSON.stringify(rule.value)}\n` + - `- Expected: ${rule.expected}`); - } - } - if (this.warnings.length > 0) { - const warningLines = this.warnings - .map((w) => ` ⚠ ${w.path}: ${w.message}`) - .join('\n'); - console.warn(`\n[NodeLink] Configuration warnings:\n${warningLines}\n`); - } - if (errors.length > 0) { - throw new Error(`Configuration errors:\n\n${errors.join('\n\n')}`); - } - } - validateServer() { - const server = this.options.server; - return [ - this.nonEmptyStringRule('server.host', server?.host), - this.intRangeRule('server.port', server?.port, 1, 65535), - this.nonEmptyStringRule('server.password', server?.password), - this.booleanRule('server.useBunServer', server?.useBunServer) - ]; - } - validateCluster() { - const cluster = this.options.cluster; - if (!cluster) - return []; - const workers = cluster.workers; - const rules = [ - this.booleanRule('cluster.enabled', cluster.enabled) - ]; - if (typeof cluster.enabled !== 'boolean') - return rules; - if (cluster.enabled === false) - return rules; - rules.push(this.nonNegativeIntRule('cluster.workers', workers), this.nonNegativeIntRule('cluster.minWorkers', cluster.minWorkers), { - path: 'cluster.minWorkers', - expected: workers === 0 - ? 'integer (auto-scaled workers, unknown value allowed)' - : `integer <= cluster.workers (${workers})`, - value: cluster.minWorkers, - validate: (v) => typeof v === 'number' && - Number.isInteger(v) && - Number.isInteger(workers) && - (workers === 0 || v <= workers) - }, this.positiveIntRule('cluster.commandTimeout', cluster.commandTimeout), this.positiveIntRule('cluster.fastCommandTimeout', cluster.fastCommandTimeout), this.nonNegativeIntRule('cluster.maxRetries', cluster.maxRetries)); - if (cluster.hibernation) { - rules.push(this.booleanRule('cluster.hibernation.enabled', cluster.hibernation.enabled), this.positiveIntRule('cluster.hibernation.timeoutMs', cluster.hibernation.timeoutMs)); - } - const ssw = cluster.specializedSourceWorker; - if (ssw) { - rules.push(this.booleanRule('cluster.specializedSourceWorker.enabled', ssw.enabled), this.positiveIntRule('cluster.specializedSourceWorker.count', ssw.count), this.positiveIntRule('cluster.specializedSourceWorker.microWorkers', ssw.microWorkers), this.positiveIntRule('cluster.specializedSourceWorker.tasksPerWorker', ssw.tasksPerWorker), this.booleanRule('cluster.specializedSourceWorker.silentLogs', ssw.silentLogs)); - } - const runtime = cluster.runtime; - if (runtime) { - rules.push(this.nonNegativeIntRule('cluster.runtime.workerMaxOldSpaceMb', runtime.workerMaxOldSpaceMb), this.nonNegativeIntRule('cluster.runtime.sourceWorkerMaxOldSpaceMb', runtime.sourceWorkerMaxOldSpaceMb), this.booleanRule('cluster.runtime.workerExposeGc', runtime.workerExposeGc), this.booleanRule('cluster.runtime.sourceWorkerExposeGc', runtime.sourceWorkerExposeGc), this.stringArrayRule('cluster.runtime.workerExecArgv', runtime.workerExecArgv), this.stringArrayRule('cluster.runtime.sourceWorkerExecArgv', runtime.sourceWorkerExecArgv)); - } - const scaling = cluster.scaling; - if (scaling) { - rules.push(this.positiveIntRule('cluster.scaling.maxPlayersPerWorker', scaling.maxPlayersPerWorker), this.positiveIntRule('cluster.scaling.checkIntervalMs', scaling.checkIntervalMs), this.positiveIntRule('cluster.scaling.idleWorkerTimeoutMs', scaling.idleWorkerTimeoutMs), this.positiveIntRule('cluster.scaling.queueLengthScaleUpFactor', scaling.queueLengthScaleUpFactor), this.positiveIntRule('cluster.scaling.lagPenaltyLimit', scaling.lagPenaltyLimit), { - path: 'cluster.scaling.scaleUpThreshold', - expected: 'number between 0 and 1 (exclusive)', - value: scaling.scaleUpThreshold, - validate: (v) => typeof v === 'number' && v > 0 && v < 1 - }, { - path: 'cluster.scaling.cpuPenaltyLimit', - expected: 'number between 0 and 1 (exclusive)', - value: scaling.cpuPenaltyLimit, - validate: (v) => typeof v === 'number' && v > 0 && v < 1 - }, this.floatMustBeBelow('cluster.scaling.scaleDownThreshold', scaling.scaleDownThreshold, scaling.scaleUpThreshold, 'cluster.scaling.scaleUpThreshold'), { - path: 'cluster.scaling.targetUtilization', - expected: `number between scaleDownThreshold (${scaling.scaleDownThreshold}) and scaleUpThreshold (${scaling.scaleUpThreshold})`, - value: scaling.targetUtilization, - validate: (v) => typeof scaling.scaleDownThreshold === 'number' && - typeof scaling.scaleUpThreshold === 'number' && - typeof v === 'number' && - v >= scaling.scaleDownThreshold && - v <= scaling.scaleUpThreshold - }); - } - const endpoint = cluster.endpoint; - if (endpoint) { - rules.push(this.booleanRule('cluster.endpoint.patchEnabled', endpoint.patchEnabled), this.booleanRule('cluster.endpoint.allowExternalPatch', endpoint.allowExternalPatch), this.nonEmptyStringRule('cluster.endpoint.code', endpoint.code)); - } - return rules; - } - validateAudio() { - const audio = this.options.audio; - const rules = [ - this.booleanRule('audio.loudnessNormalizer', audio?.loudnessNormalizer), - this.nonNegativeIntRule('audio.lookaheadMs', audio?.lookaheadMs), - { - path: 'audio.gateThresholdLUFS', - expected: 'number <= 0', - value: audio?.gateThresholdLUFS, - validate: (v) => typeof v === 'number' && v <= 0 - }, - this.enumRule('audio.quality', audio?.quality, VALID_AUDIO_QUALITIES), - this.enumRule('audio.resamplingQuality', audio?.resamplingQuality, VALID_RESAMPLING_QUALITIES) - ]; - const fading = audio?.fading; - if (fading) { - rules.push(this.booleanRule('audio.fading.enabled', fading.enabled)); - const fadingEvents = [ - 'trackStart', - 'trackEnd', - 'trackStop', - 'seek', - 'pause', - 'resume' - ]; - for (const event of fadingEvents) { - const ev = fading[event]; - if (!ev) - continue; - rules.push(this.nonNegativeIntRule(`audio.fading.${event}.duration`, ev.duration), this.enumRule(`audio.fading.${event}.curve`, ev.curve, VALID_FADING_CURVES), this.enumRule(`audio.fading.${event}.type`, ev.type, VALID_FADING_TYPES)); - } - const ducking = fading.ducking; - if (ducking) { - rules.push(this.booleanRule('audio.fading.ducking.enabled', ducking.enabled), this.nonNegativeIntRule('audio.fading.ducking.duration', ducking.duration), this.enumRule('audio.fading.ducking.curve', ducking.curve, VALID_FADING_CURVES), { - path: 'audio.fading.ducking.targetVolume', - expected: 'number between 0 and 1 (inclusive)', - value: ducking.targetVolume, - validate: (v) => typeof v === 'number' && v >= 0 && v <= 1 - }); - } - } - return rules; - } - validatePlayback() { - const trackStuck = this.options.trackStuckThresholdMs; - return [ - this.intRangeRule('playerUpdateInterval', this.options.playerUpdateInterval, 250, 60000), - this.positiveIntRule('statsUpdateInterval', this.options.statsUpdateInterval), - this.positiveIntRule('eventTimeoutMs', this.options.eventTimeoutMs), - this.booleanRule('enableHoloTracks', this.options.enableHoloTracks), - this.booleanRule('enableTrackStreamEndpoint', this.options.enableTrackStreamEndpoint), - this.booleanRule('enableLoadStreamEndpoint', this.options.enableLoadStreamEndpoint), - this.booleanRule('resolveExternalLinks', this.options.resolveExternalLinks), - this.booleanRule('fetchChannelInfo', this.options.fetchChannelInfo), - { - path: 'trackStuckThresholdMs', - expected: 'integer >= 1000 (milliseconds)', - value: trackStuck, - validate: (v) => typeof v === 'number' && Number.isInteger(v) && v >= 1000 - }, - this.intMustExceed('zombieThresholdMs', this.options.zombieThresholdMs, trackStuck, 'trackStuckThresholdMs') - ]; - } - validateSources() { - const sources = this.options.sources; - if (!sources) - return []; - const rules = []; - const allSources = [ - 'youtube', - 'soundcloud', - 'spotify', - 'tidal', - 'applemusic', - 'audius', - 'jiosaavn', - 'eternalbox', - 'pipertts', - 'pandora', - 'yandexmusic', - 'monochrome', - 'gaana', - 'flowery', - 'lazypytts', - 'qobuz', - 'iheartradio', - 'vkmusic', - 'amazonmusic', - 'bluesky', - 'anghami', - 'rss', - 'songlink', - 'mixcloud', - 'audiomack', - 'deezer', - 'bandcamp', - 'local', - 'http', - 'vimeo', - 'telegram', - 'shazam', - 'bilibili', - 'genius', - 'pinterest', - 'google-tts', - 'instagram', - 'kwai', - 'twitch', - 'nicovideo', - 'reddit', - 'tumblr', - 'twitter', - 'lastfm', - 'letrasmus' - ]; - for (const name of allSources) { - const sourceConfig = sources[name]; - if (sourceConfig !== undefined) { - rules.push(this.booleanRule(`sources.${name}.enabled`, sourceConfig.enabled)); - } - } - rules.push(...this.validateSourceSpotify(sources.spotify), ...this.validateSourceAppleMusic(sources.applemusic), ...this.validateSourceTidal(sources.tidal), ...this.validateSourceAudius(sources.audius), ...this.validateSourceJiosaavn(sources.jiosaavn), ...this.validateSourceEternalbox(sources.eternalbox), ...this.validateSourceYoutube(sources.youtube), ...this.validateSourcePipertts(sources.pipertts), ...this.validateSourcePandora(sources.pandora), ...this.validateSourceQobuz(sources.qobuz), ...this.validateSourceLastfm(sources.lastfm), ...this.validateSourceBilibili(sources.bilibili), ...this.validateSourceYandexMusic(sources.yandexmusic), ...this.validateSourceGaana(sources.gaana), ...this.validateSourceFlowery(sources.flowery), ...this.validateSourceLazypytts(sources.lazypytts), ...this.validateSourceMonochrome(sources.monochrome)); - return rules; - } - validateSourceSpotify(spotify) { - if (typeof spotify !== 'object' || - spotify === null || - !spotify.enabled) - return []; - const s = spotify; - const rules = [ - this.nonNegativeIntRule('sources.spotify.playlistLoadLimit', s.playlistLoadLimit), - this.nonNegativeIntRule('sources.spotify.albumLoadLimit', s.albumLoadLimit), - this.positiveIntRule('sources.spotify.playlistPageLoadConcurrency', s.playlistPageLoadConcurrency), - this.positiveIntRule('sources.spotify.albumPageLoadConcurrency', s.albumPageLoadConcurrency), - this.booleanRule('sources.spotify.allowLocalFiles', s.allowLocalFiles), - { - path: 'sources.spotify.clientId', - expected: 'non-empty string when sources.spotify.clientSecret is set', - value: s.clientId, - validate: (v) => !s.clientSecret || (typeof v === 'string' && v.trim().length > 0) - }, - { - path: 'sources.spotify.clientSecret', - expected: 'non-empty string when sources.spotify.clientId is set', - value: s.clientSecret, - validate: (v) => !s.clientId || (typeof v === 'string' && v.trim().length > 0) - }, - this.placeholderWarningRule('sources.spotify.clientId', s.clientId), - this.placeholderWarningRule('sources.spotify.clientSecret', s.clientSecret) - ]; - if (s.externalAuthUrl) { - rules.push(this.urlRule('sources.spotify.externalAuthUrl', s.externalAuthUrl)); - } - return rules; - } - validateSourceAppleMusic(applemusic) { - if (typeof applemusic !== 'object' || - applemusic === null || - !applemusic.enabled) - return []; - const a = applemusic; - return [ - this.nonNegativeIntRule('sources.applemusic.playlistLoadLimit', a.playlistLoadLimit), - this.nonNegativeIntRule('sources.applemusic.albumLoadLimit', a.albumLoadLimit), - this.positiveIntRule('sources.applemusic.playlistPageLoadConcurrency', a.playlistPageLoadConcurrency), - this.positiveIntRule('sources.applemusic.albumPageLoadConcurrency', a.albumPageLoadConcurrency), - this.placeholderWarningRule('sources.applemusic.mediaApiToken', a.mediaApiToken, ['token_here']) - ]; - } - validateSourceTidal(tidal) { - if (typeof tidal !== 'object' || - tidal === null || - !tidal.enabled) - return []; - const t = tidal; - const rules = [ - this.nonNegativeIntRule('sources.tidal.playlistLoadLimit', t.playlistLoadLimit), - this.positiveIntRule('sources.tidal.playlistPageLoadConcurrency', t.playlistPageLoadConcurrency) - ]; - if (t.token !== undefined) { - rules.push({ - path: 'sources.tidal.token', - expected: 'string (non-whitespace if provided)', - value: t.token, - validate: (v) => typeof v === 'string' && (v === '' || v.trim().length > 0) - }, this.placeholderWarningRule('sources.tidal.token', t.token, [ - 'token_here' - ])); - } - return rules; - } - validateSourceAudius(audius) { - if (typeof audius !== 'object' || - audius === null || - !audius.enabled) - return []; - const au = audius; - return [ - { - path: 'sources.audius.appName', - expected: 'string', - value: au.appName, - validate: (v) => v === undefined || typeof v === 'string' - }, - { - path: 'sources.audius.apiKey', - expected: 'string', - value: au.apiKey, - validate: (v) => v === undefined || typeof v === 'string' - }, - { - path: 'sources.audius.apiSecret', - expected: 'string', - value: au.apiSecret, - validate: (v) => v === undefined || typeof v === 'string' - }, - this.nonNegativeIntRule('sources.audius.playlistLoadLimit', au.playlistLoadLimit), - this.nonNegativeIntRule('sources.audius.albumLoadLimit', au.albumLoadLimit), - this.placeholderWarningRule('sources.audius.apiKey', au.apiKey), - this.placeholderWarningRule('sources.audius.apiSecret', au.apiSecret) - ]; - } - validateSourceJiosaavn(jiosaavn) { - if (typeof jiosaavn !== 'object' || - jiosaavn === null || - !jiosaavn.enabled) - return []; - const j = jiosaavn; - return [ - this.nonNegativeIntRule('sources.jiosaavn.playlistLoadLimit', j.playlistLoadLimit), - this.nonNegativeIntRule('sources.jiosaavn.artistLoadLimit', j.artistLoadLimit) - ]; - } - validateSourceEternalbox(eternalbox) { - if (typeof eternalbox !== 'object' || - eternalbox === null || - !eternalbox.enabled) - return []; - const e = eternalbox; - return [ - this.urlRule('sources.eternalbox.baseUrl', e.baseUrl), - this.positiveIntRule('sources.eternalbox.searchResults', e.searchResults), - this.positiveIntRule('sources.eternalbox.maxBranches', e.maxBranches), - this.nonNegativeIntRule('sources.eternalbox.cacheMaxBytes', e.cacheMaxBytes), - this.booleanRule('sources.eternalbox.enrichSpotify', e.enrichSpotify), - this.booleanRule('sources.eternalbox.includeAnalysis', e.includeAnalysis), - this.booleanRule('sources.eternalbox.eternalStream', e.eternalStream), - this.booleanRule('sources.eternalbox.infiniteStream', e.infiniteStream), - this.nonNegativeIntRule('sources.eternalbox.maxReconnects', e.maxReconnects), - this.positiveIntRule('sources.eternalbox.reconnectDelayMs', e.reconnectDelayMs), - { - path: 'sources.eternalbox.minRandomBranchChance', - expected: 'number between 0 and 1 (inclusive)', - value: e.minRandomBranchChance, - validate: (v) => typeof v === 'number' && v >= 0 && v <= 1 - }, - { - path: 'sources.eternalbox.maxRandomBranchChance', - expected: 'number between 0 and 1 (inclusive)', - value: e.maxRandomBranchChance, - validate: (v) => typeof v === 'number' && v >= 0 && v <= 1 + const schema = { + server: { + type: 'object', + props: { + host: { type: 'string', default: '0.0.0.0' }, + port: { type: 'number', integer: true, min: 1, max: 65535, default: 3000 }, + engine: { type: 'string', enum: ['default', 'bun'], default: 'default' } + } }, - this.floatMustNotExceed('sources.eternalbox.minRandomBranchChance', e.minRandomBranchChance, e.maxRandomBranchChance, 'sources.eternalbox.maxRandomBranchChance') - ]; - } - validateSourceYoutube(youtube) { - if (typeof youtube !== 'object' || youtube === null) - return []; - const y = youtube; - if (!y.enabled || !y.cipher) - return []; - const cipher = y.cipher; - if (cipher.url === undefined) - return []; - return [this.urlRule('sources.youtube.cipher.url', cipher.url)]; - } - validateSourcePipertts(pipertts) { - if (typeof pipertts !== 'object' || - pipertts === null || - !pipertts.enabled) - return []; - const p = pipertts; - return [this.urlRule('sources.pipertts.url', p.url)]; - } - validateSourcePandora(pandora) { - if (typeof pandora !== 'object' || pandora === null) - return []; - const pa = pandora; - if (!pa.enabled || !pa.remoteTokenUrl) - return []; - return [this.urlRule('sources.pandora.remoteTokenUrl', pa.remoteTokenUrl)]; - } - validateSourceQobuz(qobuz) { - if (typeof qobuz !== 'object' || - qobuz === null || - !qobuz.enabled) - return []; - const q = qobuz; - return [this.placeholderWarningRule('sources.qobuz.userToken', q.userToken)]; - } - validateSourceLastfm(lastfm) { - if (typeof lastfm !== 'object' || - lastfm === null || - !lastfm.enabled) - return []; - const l = lastfm; - return [this.placeholderWarningRule('sources.lastfm.apiKey', l.apiKey)]; - } - validateSourceBilibili(bilibili) { - if (typeof bilibili !== 'object' || - bilibili === null || - !bilibili.enabled) - return []; - const b = bilibili; - return [ - this.placeholderWarningRule('sources.bilibili.sessdata', b.sessdata) - ]; - } - validateSourceYandexMusic(yandexmusic) { - if (typeof yandexmusic !== 'object' || - yandexmusic === null || - !yandexmusic.enabled) - return []; - const ym = yandexmusic; - return [ - this.nonNegativeIntRule('sources.yandexmusic.artistLoadLimit', ym.artistLoadLimit), - this.nonNegativeIntRule('sources.yandexmusic.albumLoadLimit', ym.albumLoadLimit), - this.nonNegativeIntRule('sources.yandexmusic.playlistLoadLimit', ym.playlistLoadLimit), - this.placeholderWarningRule('sources.yandexmusic.accessToken', ym.accessToken) - ]; - } - validateSourceGaana(gaana) { - if (typeof gaana !== 'object' || - gaana === null || - !gaana.enabled) - return []; - const g = gaana; - return [ - this.nonNegativeIntRule('sources.gaana.playlistLoadLimit', g.playlistLoadLimit), - this.nonNegativeIntRule('sources.gaana.albumLoadLimit', g.albumLoadLimit), - this.nonNegativeIntRule('sources.gaana.artistLoadLimit', g.artistLoadLimit) - ]; - } - validateSourceFlowery(flowery) { - if (typeof flowery !== 'object' || - flowery === null || - !flowery.enabled) - return []; - const fl = flowery; - return [ - this.positiveNumberRule('sources.flowery.speed', fl.speed), - this.nonNegativeIntRule('sources.flowery.silence', fl.silence), - this.booleanRule('sources.flowery.translate', fl.translate), - this.booleanRule('sources.flowery.enforceConfig', fl.enforceConfig) - ]; - } - validateSourceLazypytts(lazypytts) { - if (typeof lazypytts !== 'object' || - lazypytts === null || - !lazypytts.enabled) - return []; - const lp = lazypytts; - return [ - this.positiveIntRule('sources.lazypytts.maxTextLength', lp.maxTextLength), - this.booleanRule('sources.lazypytts.enforceConfig', lp.enforceConfig) - ]; - } - validateSourceMonochrome(monochrome) { - if (typeof monochrome !== 'object' || - monochrome === null || - !monochrome.enabled) - return []; - const m = monochrome; - return [ - this.stringArrayRule('sources.monochrome.instances', m.instances), - this.stringArrayRule('sources.monochrome.streamingInstances', m.streamingInstances), - { - path: 'sources.monochrome.quality', - expected: 'one of [HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW]', - value: m.quality, - validate: (v) => ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'].includes(v) - } - ]; - } - validateSearch() { - const rules = [ - this.intRangeRule('maxSearchResults', this.options.maxSearchResults, 1, 100), - this.intRangeRule('maxAlbumPlaylistLength', this.options.maxAlbumPlaylistLength, 1, 500), - { - path: 'defaultSearchSource', - expected: 'string or non-empty string[] of enabled source names in config.sources', - value: this.options.defaultSearchSource, - validate: (v) => { - const sources = this.options.sources; - if (!sources) - return false; - const sourcesRecord = sources; - if (typeof v === 'string') - return (sourcesRecord[v]?.enabled === true); - if (Array.isArray(v)) { - if (v.length === 0) - return false; - return v.every((name) => typeof name === 'string' && - sourcesRecord[name]?.enabled === - true); + security: { + type: 'object', + props: { + password: { type: 'string', min: 1 }, + trustProxy: { type: 'boolean', default: false }, + dosProtection: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + thresholds: { type: 'object' }, + mitigation: { type: 'object' } + } + }, + rateLimit: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + global: { type: 'object' }, + perIp: { type: 'object' } + } } - return false; - } - } - ]; - const unified = this.options.unifiedSearchSources; - if (unified !== undefined) { - rules.push({ - path: 'unifiedSearchSources', - expected: 'non-empty string[] of enabled source names in config.sources', - value: unified, - validate: (v) => { - const sources = this.options.sources; - if (!sources || !Array.isArray(v) || v.length === 0) - return false; - const sourcesRecord = sources; - return v.every((name) => typeof name === 'string' && - sourcesRecord[name]?.enabled === true); } - }); - } - return rules; - } - validateRoutePlanner() { - const routePlanner = this.options.routePlanner; - if (!routePlanner) - return []; - const rules = [ - this.enumRule('routePlanner.strategy', routePlanner.strategy, VALID_ROUTE_STRATEGIES) - ]; - if (routePlanner.bannedIpCooldown !== undefined) { - rules.push(this.positiveIntRule('routePlanner.bannedIpCooldown', routePlanner.bannedIpCooldown)); - } - return rules; - } - validateRateLimit() { - const rateLimit = this.options.rateLimit; - if (!rateLimit || rateLimit.enabled === false) - return []; - const rules = [ - this.booleanRule('rateLimit.enabled', rateLimit.enabled) - ]; - const sections = ['global', 'perIp', 'perUserId', 'perGuildId']; - let prevSection = null; - let prevCfg = null; - for (const section of sections) { - const cfg = rateLimit[section]; - if (!cfg) { - continue; - } - rules.push(this.positiveIntRule(`rateLimit.${section}.maxRequests`, cfg.maxRequests), this.positiveIntRule(`rateLimit.${section}.timeWindowMs`, cfg.timeWindowMs)); - if (prevSection !== null && prevCfg !== null) { - const capturedPrevSection = prevSection; - const capturedPrevCfg = prevCfg; - rules.push(this.intMustNotExceed(`rateLimit.${section}.maxRequests`, cfg.maxRequests, capturedPrevCfg.maxRequests, `rateLimit.${capturedPrevSection}.maxRequests`), this.intMustNotExceed(`rateLimit.${section}.timeWindowMs`, cfg.timeWindowMs, capturedPrevCfg.timeWindowMs, `rateLimit.${capturedPrevSection}.timeWindowMs`)); - } - prevSection = section; - prevCfg = cfg; - } - return rules; - } - validateDosProtection() { - const dos = this.options.dosProtection; - if (!dos) - return []; - const rules = [ - this.booleanRule('dosProtection.enabled', dos.enabled) - ]; - if (dos.thresholds) { - rules.push(this.positiveIntRule('dosProtection.thresholds.burstRequests', dos.thresholds.burstRequests), this.positiveIntRule('dosProtection.thresholds.timeWindowMs', dos.thresholds.timeWindowMs)); - } - if (dos.mitigation) { - rules.push(this.nonNegativeIntRule('dosProtection.mitigation.delayMs', dos.mitigation.delayMs), this.positiveIntRule('dosProtection.mitigation.blockDurationMs', dos.mitigation.blockDurationMs)); - } - return rules; - } - validateLogging() { - const logging = this.options.logging; - if (!logging) - return []; - const rules = []; - if (logging.file) { - rules.push(this.booleanRule('logging.file.enabled', logging.file.enabled), this.positiveIntRule('logging.file.ttlDays', logging.file.ttlDays)); - if (logging.file.enabled) { - rules.push(this.nonEmptyStringRule('logging.file.path', logging.file.path)); - } - } - if (logging.debug) { - const debugFields = [ - 'all', - 'request', - 'session', - 'player', - 'filters', - 'sources', - 'lyrics', - 'youtube', - 'youtube-cipher', - 'sabr', - 'potoken' - ]; - for (const field of debugFields) { - if (logging.debug[field] !== undefined) { - rules.push(this.booleanRule(`logging.debug.${field}`, logging.debug[field])); - } - } - } - return rules; - } - validateConnection() { - const connection = this.options.connection; - if (!connection) - return []; - const rules = [ - this.booleanRule('connection.logAllChecks', connection.logAllChecks), - this.positiveIntRule('connection.interval', connection.interval), - this.positiveIntRule('connection.timeout', connection.timeout) - ]; - if (connection.thresholds) { - rules.push(this.positiveNumberRule('connection.thresholds.bad', connection.thresholds.bad), this.positiveNumberRule('connection.thresholds.average', connection.thresholds.average), this.floatMustBeBelow('connection.thresholds.bad', connection.thresholds.bad, connection.thresholds.average, 'connection.thresholds.average')); - } - return rules; - } - validateVoiceReceive() { - const voiceReceive = this.options.voiceReceive; - if (!voiceReceive) - return []; - return [ - this.booleanRule('voiceReceive.enabled', voiceReceive.enabled), - this.enumRule('voiceReceive.format', voiceReceive.format, VALID_VOICE_FORMATS) - ]; - } - validateMix() { - const mix = this.options.mix; - if (!mix) - return []; - return [ - this.booleanRule('mix.enabled', mix.enabled), - this.positiveIntRule('mix.maxLayersMix', mix.maxLayersMix), - this.booleanRule('mix.autoCleanup', mix.autoCleanup), - { - path: 'mix.defaultVolume', - expected: 'number between 0 and 1 (inclusive)', - value: mix.defaultVolume, - validate: (v) => typeof v === 'number' && v >= 0 && v <= 1 - } - ]; - } - validateMetrics() { - const metrics = this.options.metrics; - if (!metrics) - return []; - const rules = [ - this.booleanRule('metrics.enabled', metrics.enabled) - ]; - if (metrics.authorization) { - rules.push(this.enumRule('metrics.authorization.type', metrics.authorization.type, VALID_METRICS_AUTH_TYPES)); - } - return rules; - } - validateFilters() { - const filters = this.options.filters; - if (filters === undefined) - return []; - if (filters === null || typeof filters !== 'object') { - return [ - { - path: 'filters', - expected: 'object with an enabled key', - value: filters, - validate: () => false + }, + cluster: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + workers: { type: 'number', integer: true, min: 0 }, + minWorkers: { type: 'number', integer: true, min: 0, default: 1 }, + timeouts: { + type: 'object', + props: { + heavyMs: { type: 'number', integer: true, min: 100, default: 6000 }, + fastMs: { type: 'number', integer: true, min: 100, default: 4000 } + } + } } - ]; - } - if (!('enabled' in filters)) { - return [ - { - path: 'filters.enabled', - expected: 'object of filter flags', - value: undefined, - validate: () => false + }, + network: { + type: 'object', + props: { + proxy: { + type: 'object', + props: { + enabled: { type: 'boolean', default: false }, + strategy: { type: 'string', enum: ['RoundRobin', 'LeastConnections', 'LowestLatency', 'WeightedHealth'] } + } + }, + connection: { + type: 'object', + props: { + interval: { type: 'number', integer: true, min: 1000 }, + timeout: { type: 'number', integer: true, min: 1000 } + } + } } - ]; - } - if (filters.enabled === null || typeof filters.enabled !== 'object') { - return [ - { - path: 'filters.enabled', - expected: 'object of filter flags', - value: filters.enabled, - validate: () => false + }, + search: { + type: 'object', + props: { + maxResults: { type: 'number', integer: true, min: 1, max: 100, default: 10 }, + defaultSource: { type: 'array', items: 'string' } } - ]; - } - if (Object.keys(filters.enabled).length === 0) - return []; - const filterFields = [ - 'tremolo', - 'vibrato', - 'lowpass', - 'highpass', - 'rotation', - 'karaoke', - 'distortion', - 'channelMix', - 'equalizer', - 'chorus', - 'compressor', - 'echo', - 'phaser', - 'timescale' - ]; - return filterFields - .filter((f) => filters.enabled[f] !== undefined) - .map((f) => this.booleanRule(`filters.enabled.${f}`, filters.enabled[f])); - } - placeholderWarningRule(path, value, except = []) { - return { - path, - expected: 'non-placeholder value', - value, - validate: (v) => { - if (typeof v === 'string' && - KNOWN_PLACEHOLDERS.has(v) && - !except.includes(v)) { - this.warnings.push({ - path, - message: `Value "${v}" looks like an unfilled placeholder. The source may fail to authenticate at runtime.` - }); + }, + playback: { + type: 'object', + props: { + maxPlaylistLength: { type: 'number', integer: true, min: 0, default: 100 }, + intervals: { + type: 'object', + props: { + playerUpdateMs: { type: 'number', integer: true, min: 100, default: 2000 }, + statsUpdateMs: { type: 'number', integer: true, min: 100, default: 30000 } + } + }, + audio: { + type: 'object', + props: { + quality: { type: 'string', enum: ['high', 'medium', 'low', 'lowest'] }, + encryption: { type: 'string' } + } + } } - return true; } }; - } - nonNegativeIntRule(path, value) { - return { - path, - expected: 'integer >= 0', - value, - validate: (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0 - }; - } - positiveIntRule(path, value) { - return { - path, - expected: 'integer > 0', - value, - validate: (v) => typeof v === 'number' && Number.isInteger(v) && v > 0 - }; - } - intRangeRule(path, value, min, max) { - return { - path, - expected: `integer between ${min} and ${max}`, - value, - validate: (v) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max - }; - } - booleanRule(path, value) { - return { - path, - expected: 'boolean', - value, - validate: (v) => typeof v === 'boolean' - }; - } - nonEmptyStringRule(path, value) { - return { - path, - expected: 'non-empty string', - value, - validate: (v) => typeof v === 'string' && v.trim().length > 0 - }; - } - enumRule(path, value, allowed) { - const label = [...allowed].join(', '); - return { - path, - expected: `one of [${label}]`, - value, - validate: (v) => allowed.has(v) - }; - } - urlRule(path, value) { - return { - path, - expected: 'valid http or https URL (e.g. https://example.com)', - value, - validate: (v) => { - if (typeof v !== 'string' || v.trim().length === 0) - return false; - if (v !== v.trim()) { - this.warnings.push({ - path, - message: `URL value has leading or trailing whitespace. This may cause issues at runtime.` - }); - } - try { - const url = new URL(v.trim()); - return url.protocol === 'http:' || url.protocol === 'https:'; - } - catch { - return false; - } + const check = validator.compile(schema); + const result = check(this.config); + if (result !== true) { + const errors = Array.isArray(result) ? result : []; + for (const error of errors) { + logger('error', 'Config', `Validation error at ${error.field}: ${error.message}`); } - }; - } - intMustExceed(path, value, dependency, dependencyPath) { - return { - path, - expected: `integer > ${dependencyPath} (${dependency})`, - value, - validate: (v) => typeof v === 'number' && - Number.isInteger(v) && - typeof dependency === 'number' && - Number.isInteger(dependency) && - v > dependency - }; - } - intMustNotExceed(path, value, dependency, dependencyPath) { - return { - path, - expected: `integer <= ${dependencyPath} (${dependency})`, - value, - validate: (v) => typeof v === 'number' && - Number.isInteger(v) && - typeof dependency === 'number' && - Number.isInteger(dependency) && - v <= dependency - }; - } - floatMustNotExceed(path, value, dependency, dependencyPath) { - return { - path, - expected: `number <= ${dependencyPath} (${dependency})`, - value, - validate: (v) => typeof v === 'number' && - typeof dependency === 'number' && - v <= dependency - }; - } - floatMustBeBelow(path, value, dependency, dependencyPath) { - return { - path, - expected: `number < ${dependencyPath} (${dependency})`, - value, - validate: (v) => typeof v === 'number' && - typeof dependency === 'number' && - v < dependency - }; - } - positiveNumberRule(path, value) { - return { - path, - expected: 'number > 0', - value, - validate: (v) => typeof v === 'number' && v > 0 - }; - } - stringArrayRule(path, value) { - return { - path, - expected: 'array of strings', - value, - validate: (v) => Array.isArray(v) && v.every((item) => typeof item === 'string') - }; + throw new Error(`Configuration validation failed: ${errors.map(e => e.message).join(', ')}`); + } + logger('info', 'Config', 'Configuration validated successfully.'); } } diff --git a/dist/src/managers/connectionManager.js b/dist/src/managers/connectionManager.js index 2c5f7547..808faedd 100644 --- a/dist/src/managers/connectionManager.js +++ b/dist/src/managers/connectionManager.js @@ -54,7 +54,7 @@ export default class ConnectionManager { _lastPingMs; constructor(nodelink) { this.nodelink = nodelink; - this.config = nodelink.options.connection || {}; + this.config = nodelink.options.network.connection || {}; this.interval = null; this.status = 'unknown'; this.metrics = { timestamp: Date.now() }; @@ -83,6 +83,12 @@ export default class ConnectionManager { this.interval = null; } } + /** + * Stops the monitor and releases resources. + */ + destroy() { + this.stop(); + } /** * Runs a full connectivity check and updates metrics. */ diff --git a/dist/src/managers/credentialManager.js b/dist/src/managers/credentialManager.js index ff0e98a7..7a708292 100644 --- a/dist/src/managers/credentialManager.js +++ b/dist/src/managers/credentialManager.js @@ -1,20 +1,10 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import { logger } from "../utils.js"; +import BaseCacheManager from "./baseCacheManager.js"; const CREDENTIALS_SALT = 'nodelink-salt'; const CREDENTIALS_VERSION = 1; const DEFAULT_SAVE_DELAY_MS = 1000; const DEFAULT_CREDENTIALS_PATH = './.cache/credentials.bin'; const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000; const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); -const getErrorMessage = (error) => error instanceof Error ? error.message : String(error ?? 'Unknown error'); -const getErrorCode = (error) => { - if (!error || typeof error !== 'object' || !('code' in error)) { - return undefined; - } - const code = error.code; - return typeof code === 'string' ? code : undefined; -}; /** * Encrypted credential store with TTL support and debounced persistence. * @remarks @@ -28,107 +18,24 @@ const getErrorCode = (error) => { * ``` * @public */ -export default class CredentialManager { +export default class CredentialManager extends BaseCacheManager { nodelink; - password; - key; - legacyKey; - filePath; - tempFilePath; - saveDelayMs; - credentials; - saveTimeout; - cleanupInterval; - savePromise; - saveQueued; - lastLoadedAt; - lastSavedAt; /** * Creates a new credential manager instance. * @param nodelink - NodeLink runtime used to derive the encryption key. */ constructor(nodelink) { + const password = CredentialManager._resolvePassword(nodelink.options); + super({ + name: 'Credentials', + passwordHashKey: password, + salt: CREDENTIALS_SALT, + filePath: DEFAULT_CREDENTIALS_PATH, + saveDelayMs: DEFAULT_SAVE_DELAY_MS, + cleanupIntervalMs: DEFAULT_CLEANUP_INTERVAL_MS, + version: CREDENTIALS_VERSION + }); this.nodelink = nodelink; - this.password = this._resolvePassword(nodelink.options); - this.key = this._deriveFastKey(this.password); - this.legacyKey = null; - this.filePath = DEFAULT_CREDENTIALS_PATH; - this.tempFilePath = `${DEFAULT_CREDENTIALS_PATH}.tmp`; - this.saveDelayMs = DEFAULT_SAVE_DELAY_MS; - this.credentials = new Map(); - this.saveTimeout = null; - this.cleanupInterval = setInterval(() => { - const expiredCount = this._purgeExpired(); - if (expiredCount > 0) - this.save(); - }, DEFAULT_CLEANUP_INTERVAL_MS); - this.cleanupInterval.unref?.(); - this.savePromise = null; - this.saveQueued = false; - this.lastLoadedAt = null; - this.lastSavedAt = null; - } - /** - * Loads and decrypts credential data from disk. - * @remarks Purges expired entries and persists if cleanup occurs. - */ - async load() { - try { - const data = await fs.readFile(this.filePath); - if (data.length < 32) - return; - let payload; - let migratedFromLegacy = false; - try { - payload = this._decodePayload(data, this.key); - } - catch { - const legacyKey = this._getLegacyKey(); - payload = this._decodePayload(data, legacyKey); - migratedFromLegacy = true; - } - this.credentials = new Map(Object.entries(payload.entries)); - const expiredCount = this._purgeExpired(); - if (expiredCount > 0 || migratedFromLegacy) - this.save(); - this.lastLoadedAt = Date.now(); - logger('debug', 'Credentials', `Loaded ${this.credentials.size} encrypted credentials from disk.`); - } - catch (error) { - const code = getErrorCode(error); - if (code !== 'ENOENT') { - logger('error', 'Credentials', `Failed to decrypt credentials: ${getErrorMessage(error)}`); - } - this.credentials = new Map(); - } - } - /** - * Debounces credential persistence to disk. - */ - save() { - if (this.saveTimeout) - return; - this.saveTimeout = setTimeout(() => { - this.saveTimeout = null; - void this.forceSave(); - }, this.saveDelayMs); - } - /** - * Forces credentials to be written immediately. - */ - async forceSave() { - if (this.saveTimeout) { - clearTimeout(this.saveTimeout); - this.saveTimeout = null; - } - try { - this._purgeExpired(); - await this._flushSaveQueue(); - logger('debug', 'Credentials', 'Force saved credentials to disk.'); - } - catch (error) { - logger('error', 'Credentials', `Failed to force save credentials: ${getErrorMessage(error)}`); - } } /** * Retrieves a credential value by key. @@ -160,9 +67,9 @@ export default class CredentialManager { */ set(key, value, ttlMs = 0) { const now = Date.now(); - const current = this.credentials.get(key); + const current = this.cache.get(key); const expiresAt = ttlMs > 0 ? now + ttlMs : null; - this.credentials.set(key, { + this.cache.set(key, { value, createdAt: current?.createdAt ?? now, updatedAt: now, @@ -176,7 +83,7 @@ export default class CredentialManager { * @returns True if an entry was removed. */ delete(key) { - const existed = this.credentials.delete(key); + const existed = this.cache.delete(key); if (existed) this.save(); return existed; @@ -188,29 +95,24 @@ export default class CredentialManager { has(key) { return this._getValidEntry(key) !== null; } - /** - * Clears all stored credentials. - */ - clear() { - if (this.credentials.size === 0) - return; - this.credentials.clear(); - this.save(); - } /** * Returns runtime statistics for credential storage. */ getStats() { const now = Date.now(); - const expiredEntries = this._countExpired(now); + let expiredEntries = 0; + for (const entry of this.cache.values()) { + if (entry.expiresAt && now > entry.expiresAt) + expiredEntries++; + } return { - totalEntries: this.credentials.size, + totalEntries: this.cache.size, expiredEntries, lastLoadedAt: this.lastLoadedAt ?? undefined, lastSavedAt: this.lastSavedAt ?? undefined }; } - _resolvePassword(options) { + static _resolvePassword(options) { const optionsCandidate = options; const serverCandidate = optionsCandidate.server; const server = isRecord(serverCandidate) @@ -222,181 +124,4 @@ export default class CredentialManager { } return password; } - _deriveFastKey(password) { - return crypto - .createHash('sha256') - .update(`${CREDENTIALS_SALT}:${password}`) - .digest(); - } - _getLegacyKey() { - if (!this.legacyKey) { - this.legacyKey = crypto.scryptSync(this.password, CREDENTIALS_SALT, 32); - } - return this.legacyKey; - } - _getValidEntry(key) { - const entry = this.credentials.get(key); - if (!entry) - return null; - if (entry.expiresAt && Date.now() > entry.expiresAt) { - this.credentials.delete(key); - this.save(); - return null; - } - return entry; - } - _purgeExpired() { - const now = Date.now(); - let expiredCount = 0; - for (const [key, entry] of this.credentials.entries()) { - if (entry.expiresAt && now > entry.expiresAt) { - this.credentials.delete(key); - expiredCount++; - } - } - return expiredCount; - } - _countExpired(now) { - let expiredCount = 0; - for (const entry of this.credentials.values()) { - if (entry.expiresAt && now > entry.expiresAt) - expiredCount++; - } - return expiredCount; - } - _decodePayload(data, key) { - const iv = data.subarray(0, 16); - const tag = data.subarray(16, 32); - const encrypted = data.subarray(32); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]).toString('utf8'); - const parsed = JSON.parse(decrypted); - return this._normalizePayload(parsed); - } - _normalizePayload(raw) { - const now = Date.now(); - if (isRecord(raw)) { - const payloadCandidate = raw; - const entriesValue = payloadCandidate.entries; - if (isRecord(entriesValue)) { - const entries = this._normalizeEntries(entriesValue, now); - const savedAt = typeof payloadCandidate.savedAt === 'number' - ? payloadCandidate.savedAt - : now; - return { - version: CREDENTIALS_VERSION, - savedAt, - entries - }; - } - return { - version: CREDENTIALS_VERSION, - savedAt: now, - entries: this._normalizeEntries(raw, now) - }; - } - return { - version: CREDENTIALS_VERSION, - savedAt: now, - entries: {} - }; - } - _normalizeEntries(rawEntries, fallbackTime) { - const entries = {}; - for (const [key, value] of Object.entries(rawEntries)) { - entries[key] = this._normalizeEntry(value, fallbackTime); - } - return entries; - } - _normalizeEntry(rawValue, fallbackTime) { - if (isRecord(rawValue)) { - const entryCandidate = rawValue; - const value = Object.hasOwn(entryCandidate, 'value') - ? entryCandidate.value - : rawValue; - const createdAt = typeof entryCandidate.createdAt === 'number' - ? entryCandidate.createdAt - : fallbackTime; - const updatedAt = typeof entryCandidate.updatedAt === 'number' - ? entryCandidate.updatedAt - : createdAt; - const expiresAt = typeof entryCandidate.expiresAt === 'number' && - entryCandidate.expiresAt > 0 - ? entryCandidate.expiresAt - : null; - return { - value, - createdAt, - updatedAt, - expiresAt - }; - } - return { - value: rawValue, - createdAt: fallbackTime, - updatedAt: fallbackTime, - expiresAt: null - }; - } - _buildPayload() { - return { - version: CREDENTIALS_VERSION, - savedAt: Date.now(), - entries: Object.fromEntries(this.credentials) - }; - } - async _flushSaveQueue() { - if (this.savePromise) { - this.saveQueued = true; - await this.savePromise; - if (this.saveQueued) { - this.saveQueued = false; - await this._flushSaveQueue(); - } - return; - } - const payload = this._buildPayload(); - this.savePromise = this._writeToDisk(payload); - try { - await this.savePromise; - } - finally { - this.savePromise = null; - } - if (this.saveQueued) { - this.saveQueued = false; - await this._flushSaveQueue(); - } - } - async _writeToDisk(payload) { - const plainText = JSON.stringify(payload); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv); - const encrypted = Buffer.concat([ - cipher.update(plainText, 'utf8'), - cipher.final() - ]); - const tag = cipher.getAuthTag(); - const outBuffer = Buffer.concat([iv, tag, encrypted]); - await fs.mkdir('./.cache', { recursive: true }); - try { - await fs.writeFile(this.tempFilePath, outBuffer); - await fs.rename(this.tempFilePath, this.filePath); - } - catch (error) { - logger('debug', 'Credentials', `Atomic save failed, falling back to direct write: ${getErrorMessage(error)}`); - await fs.writeFile(this.filePath, outBuffer); - try { - await fs.unlink(this.tempFilePath); - } - catch (cleanupError) { - logger('debug', 'Credentials', `Failed to remove temp credentials file: ${getErrorMessage(cleanupError)}`); - } - } - this.lastSavedAt = payload.savedAt; - } } diff --git a/dist/src/managers/dosProtectionManager.js b/dist/src/managers/dosProtectionManager.js index ebe3465e..85e8fd77 100644 --- a/dist/src/managers/dosProtectionManager.js +++ b/dist/src/managers/dosProtectionManager.js @@ -40,7 +40,7 @@ export default class DosProtectionManager { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = this._resolveConfig(nodelink.options?.dosProtection); + this.config = this._resolveConfig(nodelink.options?.api.dosProtection); this.ipRequestCounts = new Map(); this.cleanupInterval = setInterval(() => this._cleanup(), this._resolveCleanupInterval()); this.cleanupInterval.unref?.(); diff --git a/dist/src/managers/pluginManager.js b/dist/src/managers/pluginManager.js index 389c71cd..95fa7298 100644 --- a/dist/src/managers/pluginManager.js +++ b/dist/src/managers/pluginManager.js @@ -43,6 +43,10 @@ export default class PluginManager { loadedPlugins; /** Registered plugin hooks. */ hooks; + /** Tracks which hooks belong to which plugin for cleanup. */ + pluginHooks; + /** Tracks the current plugin being initialized to associate hooks correctly. */ + executingPluginName = null; /** * Creates a new plugin manager instance. * @param nodelink - NodeLink runtime context. @@ -56,6 +60,7 @@ export default class PluginManager { this.pluginsDir = path.join(process.cwd(), 'plugins'); this.loadedPlugins = new Map(); this.hooks = new Map(); + this.pluginHooks = new Map(); } /** * Loads and executes all configured plugins for the current process context. @@ -81,11 +86,52 @@ export default class PluginManager { * @public */ registerHook(name, callback) { + if (this.executingPluginName) { + if (!this.pluginHooks.has(this.executingPluginName)) { + this.pluginHooks.set(this.executingPluginName, new Set()); + } + this.pluginHooks.get(this.executingPluginName)?.add({ name, callback }); + } if (!this.hooks.has(name)) { this.hooks.set(name, []); } this.hooks.get(name)?.push(callback); } + /** + * Unloads a plugin by removing its hooks and cache entry. + * @param name - The name of the plugin to unload. + * @public + */ + unloadPlugin(name) { + const pluginHooks = this.pluginHooks.get(name); + if (pluginHooks) { + for (const { name: hookName, callback } of pluginHooks) { + const list = this.hooks.get(hookName); + if (list) { + const index = list.indexOf(callback); + if (index !== -1) + list.splice(index, 1); + } + } + this.pluginHooks.delete(name); + } + this.loadedPlugins.delete(name); + logger('info', 'PluginManager', `Unloaded plugin: ${name}`); + } + /** + * Reloads a specific plugin. + * @param name - Name of the plugin. + * @param contextType - Process context. + * @public + */ + async reloadPlugin(name, contextType) { + const def = this.config.find((d) => d.name === name); + if (!def) { + throw new Error(`Plugin '${name}' not found in configuration.`); + } + this.unloadPlugin(name); + await this._loadPlugin(def, contextType, true); + } /** * Synchronously executes all callbacks registered for a hook. * @param name - The name of the hook to trigger. @@ -246,13 +292,14 @@ export default class PluginManager { * Loads a single plugin definition and executes its entrypoint. * @param def - Plugin definition from config. * @param contextType - Current runtime context identifier. + * @param forceReload - Whether to bypass cache and use timestamp for reloading. * @internal */ - async _loadPlugin(def, contextType) { + async _loadPlugin(def, contextType, forceReload = false) { const { name, source, path: localPath, package: packageName } = def; if (!name || name.trim().length === 0) return; - if (this.loadedPlugins.has(name)) { + if (!forceReload && this.loadedPlugins.has(name)) { const cached = this.loadedPlugins.get(name); if (!cached) return; @@ -337,7 +384,10 @@ export default class PluginManager { } if (!entryPoint) return; - const fileUrl = pathToFileURL(entryPoint).href; + let fileUrl = pathToFileURL(entryPoint).href; + if (forceReload) { + fileUrl += `?t=${Date.now()}`; + } const importedModule = await import(__rewriteRelativeImportExtension(fileUrl)); const pluginModule = this._coercePluginModule(importedModule); if (!pluginModule) { @@ -349,7 +399,13 @@ export default class PluginManager { module: pluginModule, meta: pluginMeta }); - await this._executePlugin(pluginModule, name, contextType, pluginMeta); + this.executingPluginName = name; + try { + await this._executePlugin(pluginModule, name, contextType, pluginMeta); + } + finally { + this.executingPluginName = null; + } 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`; @@ -357,7 +413,7 @@ export default class PluginManager { ? ` | \x1b[34mTopic:\x1b[0m ${pluginMeta.topic}` : ''; const creditString = `[${author}] ${pluginName} ${version}${topic}`; - logger('info', 'PluginManager', `Loaded: ${creditString}`); + logger('info', 'PluginManager', `${forceReload ? 'Reloaded' : 'Loaded'}: ${creditString}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/dist/src/managers/rateLimitManager.js b/dist/src/managers/rateLimitManager.js index 051d50cc..878f9726 100644 --- a/dist/src/managers/rateLimitManager.js +++ b/dist/src/managers/rateLimitManager.js @@ -48,7 +48,7 @@ export default class RateLimitManager { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = this._resolveConfig(nodelink.options?.rateLimit); + this.config = this._resolveConfig(nodelink.options?.api.rateLimit); this.store = new Map(); this.cleanupInterval = setInterval(() => this._cleanup(), this._resolveCleanupInterval()); this.cleanupInterval.unref?.(); diff --git a/dist/src/managers/routePlannerManager.js b/dist/src/managers/routePlannerManager.js index a4cb38a5..893890a1 100644 --- a/dist/src/managers/routePlannerManager.js +++ b/dist/src/managers/routePlannerManager.js @@ -24,7 +24,7 @@ export default class RoutePlannerManager { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = nodelink.options.routePlanner ?? {}; + this.config = nodelink.options.network.routePlanner ?? {}; this.blocks = []; this.bannedIps = new Map(); this.bannedBlocks = new Map(); diff --git a/dist/src/managers/sourceManager.js b/dist/src/managers/sourceManager.js index ac97e2f9..154723b7 100644 --- a/dist/src/managers/sourceManager.js +++ b/dist/src/managers/sourceManager.js @@ -221,9 +221,9 @@ export default class SourcesManager { * @public */ async searchWithDefault(query) { - const defaultSources = Array.isArray(this.nodelink.options.defaultSearchSource) - ? this.nodelink.options.defaultSearchSource - : [this.nodelink.options.defaultSearchSource]; + const defaultSources = Array.isArray(this.nodelink.options.search.defaultSource) + ? this.nodelink.options.search.defaultSource + : [this.nodelink.options.search.defaultSource]; for (const source of defaultSources) { try { const result = await this.search(source, query); @@ -246,7 +246,7 @@ export default class SourcesManager { * @public */ async unifiedSearch(query) { - const searchSources = (this.nodelink.options.unifiedSearchSources || [ + const searchSources = (this.nodelink.options.search.unifiedSources || [ 'youtube' ]); logger('debug', 'Sources', `Performing unified search for "${query}" on [${searchSources.join(', ')}]`); @@ -326,10 +326,37 @@ export default class SourcesManager { * @param track - The normalized track metadata. * @param itag - Optional YouTube-specific itag override. * @param isRecovering - Whether this is a recovery attempt. + * @param isUpscaling - Whether this is an upscale attempt. * @returns A promise resolving to the URL result. * @public */ - async getTrackUrl(track, itag, isRecovering) { + async getTrackUrl(track, itag, isRecovering, isUpscaling) { + // ISRC Upscale: If track has ISRC and is from YouTube, try high-quality sources first. + if (track.isrc && + !isUpscaling && + !isRecovering && + (track.sourceName === 'youtube' || track.sourceName === 'ytmusic')) { + const hqSources = ['tidal', 'qobuz', 'applemusic', 'deezer']; + for (const source of hqSources) { + if (!this.sources.has(source)) + continue; + try { + const searchResult = await this.search(source, track.isrc); + if (searchResult.loadType === 'search' && + Array.isArray(searchResult.data) && + searchResult.data.length > 0) { + const match = getBestMatch(searchResult.data, track); + if (match) { + logger('info', 'Sources', `ISRC Upscale: found match for ${track.isrc} on ${source}. Using high-quality source.`); + return (await this.getTrackUrl(match.info, undefined, false, true)); + } + } + } + catch (e) { + logger('debug', 'Sources', `ISRC Upscale attempt failed for ${source}: ${e.message}`); + } + } + } const instance = this.sourceMap.get(track.sourceName); if (!instance?.getTrackUrl) { throw new Error(`Source ${track.sourceName} not found or does not support getTrackUrl`); diff --git a/dist/src/managers/statsManager.js b/dist/src/managers/statsManager.js index 3efa7586..75c4ab50 100644 --- a/dist/src/managers/statsManager.js +++ b/dist/src/managers/statsManager.js @@ -103,7 +103,7 @@ export default class StatsManager { async initialize() { if (this.initialized) return; - const metricsEnabled = this.nodelink.options.metrics?.enabled ?? false; + const metricsEnabled = this.nodelink.options.api.metrics?.enabled ?? false; if (!metricsEnabled) { this.initialized = true; return; @@ -441,6 +441,13 @@ export default class StatsManager { this.initialized = true; logger('info', 'StatsManager', 'Prometheus metrics initialized.'); } + /** + * Returns the Prometheus registry for custom metric registration. + * @public + */ + getRegistry() { + return this.promRegister; + } /** * Returns a deep-copied snapshot of internal counters. */ diff --git a/dist/src/managers/trackCacheManager.js b/dist/src/managers/trackCacheManager.js index 3c035577..7760d92f 100644 --- a/dist/src/managers/trackCacheManager.js +++ b/dist/src/managers/trackCacheManager.js @@ -1,6 +1,4 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import { logger } from "../utils.js"; +import BaseCacheManager from "./baseCacheManager.js"; const TRACK_CACHE_SALT = 'nodelink-track-salt'; const DEFAULT_CACHE_FILE = './.cache/tracks.bin'; const DEFAULT_SAVE_DELAY_MS = 5000; @@ -8,14 +6,6 @@ const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; const DEFAULT_MAX_ENTRIES = 5000; const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000; const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); -const getErrorMessage = (error) => error instanceof Error ? error.message : String(error ?? 'Unknown error'); -const getErrorCode = (error) => { - if (!error || typeof error !== 'object' || !('code' in error)) { - return undefined; - } - const code = error.code; - return typeof code === 'string' ? code : undefined; -}; /** * Encrypted cache for resolved track metadata and URLs. * @remarks Uses AES-256-GCM and purges expired entries on load and access. @@ -28,103 +18,25 @@ const getErrorCode = (error) => { * ``` * @public */ -export default class TrackCacheManager { +export default class TrackCacheManager extends BaseCacheManager { nodelink; - password; - key; - legacyKey; - filePath; - maxEntries; - cleanupIntervalMs; - cache; - saveTimeout; - cleanupInterval; /** * Creates a new track cache manager instance. * @param nodelink - NodeLink runtime context. */ constructor(nodelink) { + const password = TrackCacheManager._resolvePassword(nodelink.options); + const cacheOptions = TrackCacheManager._resolveCacheOptions(nodelink.options); + super({ + name: 'TrackCache', + passwordHashKey: password, + salt: TRACK_CACHE_SALT, + filePath: DEFAULT_CACHE_FILE, + saveDelayMs: DEFAULT_SAVE_DELAY_MS, + cleanupIntervalMs: cacheOptions.cleanupIntervalMs, + maxEntries: cacheOptions.maxEntries + }); this.nodelink = nodelink; - this.password = this._resolvePassword(nodelink.options); - this.key = this._deriveFastKey(this.password); - this.legacyKey = null; - const cacheOptions = this._resolveCacheOptions(nodelink.options); - this.filePath = DEFAULT_CACHE_FILE; - this.maxEntries = cacheOptions.maxEntries; - this.cleanupIntervalMs = cacheOptions.cleanupIntervalMs; - this.cache = new Map(); - this.saveTimeout = null; - this.cleanupInterval = setInterval(() => { - const expiredCount = this._purgeExpired(); - const evictedCount = this._enforceMaxEntries(); - if (expiredCount > 0 || evictedCount > 0) - this.save(); - }, this.cleanupIntervalMs); - this.cleanupInterval.unref?.(); - } - /** - * Loads cached tracks from disk. - */ - async load() { - try { - const data = await fs.readFile(this.filePath); - if (data.length < 32) - return; - let store; - let migratedFromLegacy = false; - try { - store = this._decodeStore(data, this.key); - } - catch { - const legacyKey = this._getLegacyKey(); - store = this._decodeStore(data, legacyKey); - migratedFromLegacy = true; - } - this.cache = new Map(Object.entries(store)); - const expiredCount = this._purgeExpired(); - const evictedCount = this._enforceMaxEntries(); - if (expiredCount > 0 || evictedCount > 0 || migratedFromLegacy) - this.save(); - logger('debug', 'TrackCache', `Loaded ${this.cache.size} cached tracks from disk.`); - } - catch (error) { - const code = getErrorCode(error); - if (code !== 'ENOENT') { - logger('error', 'TrackCache', `Failed to load track cache: ${getErrorMessage(error)}`); - } - this.cache = new Map(); - } - } - /** - * Debounces cache persistence. - */ - save() { - if (this.saveTimeout) - return; - this.saveTimeout = setTimeout(() => { - this.saveTimeout = null; - void this.forceSave(); - }, DEFAULT_SAVE_DELAY_MS); - } - /** - * Forces the cache to be written to disk immediately. - */ - async forceSave() { - try { - const plainText = JSON.stringify(Object.fromEntries(this.cache)); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv); - const encrypted = Buffer.concat([ - cipher.update(plainText, 'utf8'), - cipher.final() - ]); - const tag = cipher.getAuthTag(); - await fs.mkdir('./.cache', { recursive: true }); - await fs.writeFile(this.filePath, Buffer.concat([iv, tag, encrypted])); - } - catch (error) { - logger('error', 'TrackCache', `Failed to save track cache: ${getErrorMessage(error)}`); - } } /** * Retrieves a cached value by source/identifier. @@ -133,17 +45,8 @@ export default class TrackCacheManager { */ get(source, identifier) { const key = `${source}:${identifier}`; - const entry = this.cache.get(key); - if (!entry) - return null; - if (entry.expiresAt && Date.now() > entry.expiresAt) { - this.cache.delete(key); - this.save(); - return null; - } - this.cache.delete(key); - this.cache.set(key, entry); - return entry.value; + const entry = this._getValidEntry(key); + return entry ? entry.value : null; } /** * Stores a cached value with a TTL. @@ -154,17 +57,17 @@ export default class TrackCacheManager { */ set(source, identifier, value, ttlMs = DEFAULT_TTL_MS) { const key = `${source}:${identifier}`; - if (this.cache.has(key)) { - this.cache.delete(key); - } + const now = Date.now(); this.cache.set(key, { value, - expiresAt: ttlMs > 0 ? Date.now() + ttlMs : null + createdAt: now, + updatedAt: now, + expiresAt: ttlMs > 0 ? now + ttlMs : null }); this._enforceMaxEntries(); this.save(); } - _resolveCacheOptions(options) { + static _resolveCacheOptions(options) { const directCandidate = options.trackCache; const rootCache = isRecord(directCandidate) ? directCandidate : null; const nestedCandidate = options.cache; @@ -184,7 +87,7 @@ export default class TrackCacheManager { : DEFAULT_CLEANUP_INTERVAL_MS; return { maxEntries, cleanupIntervalMs }; } - _resolvePassword(options) { + static _resolvePassword(options) { const optionsCandidate = options; const serverCandidate = optionsCandidate.server; const server = isRecord(serverCandidate) @@ -196,81 +99,4 @@ export default class TrackCacheManager { } return password; } - _deriveFastKey(password) { - return crypto - .createHash('sha256') - .update(`${TRACK_CACHE_SALT}:${password}`) - .digest(); - } - _getLegacyKey() { - if (!this.legacyKey) { - this.legacyKey = crypto.scryptSync(this.password, TRACK_CACHE_SALT, 32); - } - return this.legacyKey; - } - _purgeExpired() { - const now = Date.now(); - let expiredCount = 0; - for (const [key, entry] of this.cache.entries()) { - if (entry.expiresAt && now > entry.expiresAt) { - this.cache.delete(key); - expiredCount++; - } - } - return expiredCount; - } - _enforceMaxEntries() { - if (this.cache.size <= this.maxEntries) - return 0; - let removed = 0; - while (this.cache.size > this.maxEntries) { - const oldestKey = this.cache.keys().next().value; - if (!oldestKey) - break; - this.cache.delete(oldestKey); - removed++; - } - return removed; - } - _decodeStore(data, key) { - const iv = data.subarray(0, 16); - const tag = data.subarray(16, 32); - const encrypted = data.subarray(32); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]).toString('utf8'); - const parsed = JSON.parse(decrypted); - return this._normalizeStore(parsed); - } - _normalizeStore(raw) { - if (!isRecord(raw)) - return {}; - const store = {}; - for (const [key, value] of Object.entries(raw)) { - store[key] = this._normalizeEntry(value); - } - return store; - } - _normalizeEntry(rawValue) { - if (isRecord(rawValue)) { - const entryCandidate = rawValue; - const value = Object.hasOwn(entryCandidate, 'value') - ? entryCandidate.value - : rawValue; - const expiresAt = typeof entryCandidate.expiresAt === 'number' - ? entryCandidate.expiresAt - : null; - return { - value, - expiresAt - }; - } - return { - value: rawValue, - expiresAt: null - }; - } } diff --git a/dist/src/playback/hls/HLSHandler.js b/dist/src/playback/hls/HLSHandler.js index a2ffbab9..1ff14767 100644 --- a/dist/src/playback/hls/HLSHandler.js +++ b/dist/src/playback/hls/HLSHandler.js @@ -51,7 +51,7 @@ export default class HLSHandler extends PassThrough { this.currentUrl = url; this.headers = options.headers ?? {}; this.localAddress = options.localAddress ?? null; - this.proxy = options.proxy ?? null; + this.proxy = options.network.proxy ?? null; this.onResolveUrl = options.onResolveUrl ?? null; this.strategy = options.strategy ?? diff --git a/dist/src/playback/hls/SegmentFetcher.js b/dist/src/playback/hls/SegmentFetcher.js index eb20de65..03d3b9a3 100644 --- a/dist/src/playback/hls/SegmentFetcher.js +++ b/dist/src/playback/hls/SegmentFetcher.js @@ -53,7 +53,7 @@ export default class SegmentFetcher { constructor(options = {}) { this.headers = options.headers || {}; this.localAddress = options.localAddress || null; - this.proxy = options.proxy || null; + this.proxy = options.network.proxy || null; this.onResolveUrl = options.onResolveUrl || null; this.keyMap = new Map(); } diff --git a/dist/src/playback/player.js b/dist/src/playback/player.js index 4858d202..e16962dc 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -100,12 +100,12 @@ export class Player { this.session = options.session; this.guildId = options.guildId; this.volumePercent = this.nodelink.options?.defaultVolume ?? 100; - this.fading = this.nodelink.options?.audio?.fading; + this.fading = this.nodelink.options?.playback.audio?.fading; this.loudnessNormalizer = - this.nodelink.options?.audio?.loudnessNormalizer ?? false; + this.nodelink.options?.playback.audio?.loudnessNormalizer ?? false; this.sponsorBlock = { - enabled: this.nodelink.options.sponsorblock?.enabled ?? false, - categories: this.nodelink.options.sponsorblock?.categories ?? [ + enabled: this.nodelink.options.playback.sponsorblock?.enabled ?? false, + categories: this.nodelink.options.playback.sponsorblock?.categories ?? [ 'sponsor', 'selfpromo', 'interaction', @@ -115,10 +115,10 @@ export class Player { 'music_offtopic', 'filler' ], - actionTypes: this.nodelink.options.sponsorblock?.actionTypes ?? ['skip'], + actionTypes: this.nodelink.options.playback.sponsorblock?.actionTypes ?? ['skip'], segments: [], lastSkippedUuid: null, - skipMarginMs: this.nodelink.options.sponsorblock?.skipMarginMs ?? 150 + skipMarginMs: this.nodelink.options.playback.sponsorblock?.skipMarginMs ?? 150 }; logger('debug', 'Player', `New player created for guild ${this.guildId} in session ${this.session.id}`); this.emitEvent = (type, payload = {}) => { @@ -143,7 +143,7 @@ export class Player { guildId: this.guildId, player: this.toJSON() }); - this.waitEvent = (event, filter, timeout = this.nodelink.options.eventTimeoutMs ?? 15000) => new Promise((resolve, reject) => { + this.waitEvent = (event, filter, timeout = this.nodelink.options.playback.eventTimeoutMs ?? 15000) => new Promise((resolve, reject) => { const handler = (_, payload) => { const typedPayload = payload; if (!filter || filter(typedPayload)) { @@ -160,7 +160,7 @@ export class Player { }); } _getAudioOptions() { - return this.nodelink.options.audio; + return this.nodelink.options.playback.audio; } _getFilterTransitions() { return this._getAudioOptions()?.filterTransitions; @@ -175,7 +175,7 @@ export class Player { if (this.audioMixer) return; const { AudioMixer: Mixer } = await import("./processing/AudioMixer.js"); - this.audioMixer = new Mixer(this.nodelink.options?.mix ?? { + this.audioMixer = new Mixer(this.nodelink.options?.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5, @@ -227,10 +227,10 @@ export class Player { guildId: this.guildId, userId: this.session.userId, channelId: this.voice.channelId || this.guildId, - encryption: this.nodelink.options?.audio?.encryption ?? null + encryption: this.nodelink.options?.playback.audio?.encryption ?? null }); this.connection.stuckTimeout = - Math.max(this.nodelink.options.trackStuckThresholdMs, 30000) + 5000; + Math.max(this.nodelink.options.playback.trackStuckThresholdMs, 30000) + 5000; this.connection.on('stateChange', (_, s) => { logger('debug', 'Player', `Voice connection state change for guild ${this.guildId} in session ${this.session.id}: ${s.status}`); this._onConn(s); @@ -324,7 +324,7 @@ export class Player { } if (state.status === 'idle' && state.reason === 'stuck') { logger('warn', 'Player', `Track became stuck for guild ${this.guildId}. Triggering immediate recovery.`); - this._stuckTime = this.nodelink.options.trackStuckThresholdMs + 1; + this._stuckTime = this.nodelink.options.playback.trackStuckThresholdMs + 1; this._sendUpdate(); return; } @@ -611,7 +611,7 @@ export class Player { async _resolveTrackForEvent(track) { if (!track) return null; - if (!this.nodelink.options.enableHoloTracks) { + if (!this.nodelink.options.experimental.enableHoloTracks) { return track; } try { @@ -619,8 +619,8 @@ export class Player { const resolveHoloTrack = source?.resolveHoloTrack; if (typeof resolveHoloTrack === 'function') { const holoTrack = await resolveHoloTrack.call(source, track, { - fetchChannelInfo: this.nodelink.options.fetchChannelInfo, - resolveExternalLinks: this.nodelink.options.resolveExternalLinks + fetchChannelInfo: this.nodelink.options.search.fetchChannelInfo, + resolveExternalLinks: this.nodelink.options.search.resolveExternalLinks }); return holoTrack || track; } @@ -668,7 +668,7 @@ export class Player { * Fetches an audio resource for playback. */ async _fetchResource(info, urlData, startTime) { - if (this.nodelink.options?.mix?.enabled !== false) { + if (this.nodelink.options?.playback.mix?.enabled !== false) { await this._ensureAudioMixer(); } await getStreamProcessor(); @@ -767,7 +767,7 @@ export class Player { logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Current position: ${Math.round(position)}ms, Segments: ${this.sponsorBlock.segments.length}, LastSkipped: ${this.sponsorBlock.lastSkippedUuid}`); } } - const threshold = this.nodelink.options.trackStuckThresholdMs; + const threshold = this.nodelink.options.playback.trackStuckThresholdMs; if (threshold > 0 && !this.isUpdatingTrack && !this._isStopping && @@ -775,7 +775,7 @@ export class Player { !this._isResuming && !this.isPaused) { if (this._lastPosition === position) { - this._stuckTime += this.nodelink.options.playerUpdateInterval; + this._stuckTime += this.nodelink.options.playback.playerUpdateInterval; if (this._stuckTime >= threshold && !this._isRecovering && this.connStatus === 'connected') { @@ -1009,7 +1009,7 @@ export class Player { this.sponsorBlock.segments = []; this.sponsorBlock.lastSkippedUuid = null; const videoId = this.track.info.identifier; - const sbConfig = this.nodelink.options.sponsorblock; + const sbConfig = this.nodelink.options.playback.sponsorblock; if (this.sponsorBlock.enabled) { logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Initiating segment fetch for video ${videoId}`); const { fetchSponsorBlockSegments } = await import("../utils.js"); @@ -1278,7 +1278,7 @@ export class Player { * Seeks using seekable-stream helper for compatible sources. */ async _seekeableSeek(position, endTime) { - if (this.nodelink.options?.mix?.enabled !== false) { + if (this.nodelink.options?.playback.mix?.enabled !== false) { await this._ensureAudioMixer(); } await getStreamProcessor(); @@ -1830,7 +1830,7 @@ export class Player { await this._ensureAudioMixer(); if (!this.audioMixer) throw new Error('AudioMixer not initialized'); - const mixConfig = this.nodelink?.options?.mix ?? { + const mixConfig = this.nodelink?.options?.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5 diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index aa51bc94..fe0f7c85 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -1834,7 +1834,7 @@ class StreamAudioResource extends BaseAudioResource { super(guildId); this.nodelink = nodelink; this._validateInputStream(stream); - const resamplingQuality = nodelink.options.audio?.resamplingQuality || 'fastest'; + const resamplingQuality = nodelink.options.playback.audio?.resamplingQuality || 'fastest'; const normalizedType = normalizeFormat(type); this.pipes = [stream]; const pcmStream = this._createDecoderPipeline(stream, type, normalizedType, resamplingQuality); @@ -1968,8 +1968,8 @@ class StreamAudioResource extends BaseAudioResource { type: 's16le', volume, enableAGC, - lookaheadMs: nodelink.options.audio?.lookaheadMs, - gateThresholdLUFS: nodelink.options.audio?.gateThresholdLUFS + lookaheadMs: nodelink.options.playback.audio?.lookaheadMs, + gateThresholdLUFS: nodelink.options.playback.audio?.gateThresholdLUFS }); const fadeTransformer = new FadeTransformer({ type: 's16le', @@ -1988,7 +1988,7 @@ class StreamAudioResource extends BaseAudioResource { const silenceDetector = new SilenceDetector({ sampleRate: AUDIO_CONFIG.sampleRate, channels: AUDIO_CONFIG.channels, - thresholdDb: nodelink.options.audio?.automix?.silenceThresholdDb ?? -40 + thresholdDb: nodelink.options.playback.audio?.automix?.silenceThresholdDb ?? -40 }); const flowController = new FlowController(volumeTransformer, fadeTransformer, tapeTransformer, scratchTransformer, audioMixer); const opusEncoder = new OpusEncoder({ @@ -2085,8 +2085,8 @@ class StreamAudioResource extends BaseAudioResource { type: 's16le', volume, enableAGC, - lookaheadMs: this.nodelink?.options?.audio?.lookaheadMs, - gateThresholdLUFS: this.nodelink?.options?.audio?.gateThresholdLUFS + lookaheadMs: this.nodelink?.options?.playback.audio?.lookaheadMs, + gateThresholdLUFS: this.nodelink?.options?.playback.audio?.gateThresholdLUFS }); pipeline(pcmStream, volumeTransformer, (err) => { if (err && !this._destroyed) { @@ -2186,7 +2186,7 @@ export const createSeekeableAudioResource = async (guildId, url, seekTime, endTi } }; export const createPCMStream = (_guildId, stream, type, nodelink, volume = 1.0, filters = {}) => { - const resamplingQuality = nodelink.options.audio?.resamplingQuality || 'fastest'; + const resamplingQuality = nodelink.options.playback.audio?.resamplingQuality || 'fastest'; const normalizedType = normalizeFormat(type); const streams = [stream]; switch (normalizedType) { diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js index 2d47a772..eabcb42b 100644 --- a/dist/src/sources/applemusic.js +++ b/dist/src/sources/applemusic.js @@ -230,7 +230,7 @@ export default class AppleMusicSource { */ async search(query, _sourceName, searchType = 'track') { try { - const limit = this.nodelink.options.maxSearchResults || 10; + const limit = this.nodelink.options.search.maxResults || 10; const typeMap = { track: 'songs', album: 'albums', diff --git a/dist/src/sources/audius.js b/dist/src/sources/audius.js index d510687c..b87b76c4 100644 --- a/dist/src/sources/audius.js +++ b/dist/src/sources/audius.js @@ -211,7 +211,7 @@ export default class AudiusSource { */ async search(query) { try { - const limit = this.nodelink.options.maxSearchResults || 10; + const limit = this.nodelink.options.search.maxResults || 10; const endpoint = `/v1/tracks/search?query=${encodeURIComponent(query)}&limit=${limit}`; const data = await this._apiRequest(endpoint); if (!data || data.length === 0) { diff --git a/dist/src/sources/bandcamp.js b/dist/src/sources/bandcamp.js index d3c41b79..60ff91f6 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -492,7 +492,7 @@ export default class BandcampSource { */ getMaxSearchResults() { const options = this.nodelink.options; - const limit = options.maxSearchResults; + const limit = options.search.maxResults; return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10; diff --git a/dist/src/sources/bilibili.js b/dist/src/sources/bilibili.js index 805910c5..633541fb 100644 --- a/dist/src/sources/bilibili.js +++ b/dist/src/sources/bilibili.js @@ -161,7 +161,7 @@ export default class BilibiliSource { const res = await makeRequest('https://api.bilibili.com/x/web-interface/nav', { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (res.error || !body?.data?.wbi_img) { @@ -227,7 +227,7 @@ export default class BilibiliSource { Cookie: cookie, Referer: 'https://search.bilibili.com/' }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); body = searchResponse.body; if (!body?.data || @@ -241,7 +241,7 @@ export default class BilibiliSource { Cookie: cookie, Referer: 'https://search.bilibili.com/' }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); body = allSearchResponse.body; } @@ -263,7 +263,7 @@ export default class BilibiliSource { return { loadType: 'empty', data: {} }; } const tracks = []; - const limit = this.nodelink.options.maxSearchResults || 10; + const limit = this.nodelink.options.search.maxResults || 10; for (const item of videos.slice(0, limit)) { const durationParts = String(item.duration || '0:0') .split(':') @@ -322,7 +322,7 @@ export default class BilibiliSource { headers: HEADERS, maxRedirects: 3, timeout: 5000, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (typeof res.body === 'string') { const match = res.body.match(/rel=["']canonical["'][^>]+href=["']([^"']+)["']/); @@ -482,7 +482,7 @@ export default class BilibiliSource { const res = await makeRequest(apiUrl, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0) { @@ -518,7 +518,7 @@ export default class BilibiliSource { const res = await makeRequest(apiUrl, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0) @@ -584,7 +584,7 @@ export default class BilibiliSource { const res = await makeRequest(`https://www.bilibili.com/audio/music-service-c/web/song/info?sid=${id}`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0) @@ -617,7 +617,7 @@ export default class BilibiliSource { const albumResRaw = await makeRequest(`https://www.bilibili.com/audio/music-service-c/web/song/of-menu?sid=${id}&pn=1&ps=100`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const albumRes = albumResRaw.body; if (!albumRes || albumRes.code !== 0) @@ -645,7 +645,7 @@ export default class BilibiliSource { const infoResRaw = await makeRequest(`https://www.bilibili.com/audio/music-service-c/web/menu/info?sid=${id}`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const infoRes = infoResRaw.body; return { @@ -675,7 +675,7 @@ export default class BilibiliSource { const res = await makeRequest(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${id}`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0) @@ -724,7 +724,7 @@ export default class BilibiliSource { const res = await makeRequest(`https://api.bilibili.com/x/space/wbi/arc/search?${queryParams}`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0) @@ -803,7 +803,7 @@ export default class BilibiliSource { const res = await makeRequest(`https://www.bilibili.com/audio/music-service-c/web/url?sid=${sid}`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0 || !body.data?.cdns?.[0]) { @@ -820,7 +820,7 @@ export default class BilibiliSource { const res = await makeRequest(`https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${roomId}&protocol=0,1&format=0,2&codec=0,1&qn=10000&platform=web&pt=web&no_playurl=0&mask=0`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || @@ -882,7 +882,7 @@ export default class BilibiliSource { const res = await makeRequest(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0 || !body.data) { @@ -902,7 +902,7 @@ export default class BilibiliSource { Referer: 'https://www.bilibili.com/', Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (!body || body.code !== 0 || !body.data) { @@ -968,7 +968,7 @@ export default class BilibiliSource { type: 'mpegts', localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, startTime: additionalData?.startTime || 0, - proxy: this.config.proxy + proxy: this.config.network.proxy }), type: 'mpegts' }; @@ -977,7 +977,7 @@ export default class BilibiliSource { method: 'GET', headers, streamOnly: true, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (res.error || !res.stream) throw new Error(res.error || 'Failed to get stream.'); diff --git a/dist/src/sources/bluesky.js b/dist/src/sources/bluesky.js index 4cd5125a..47c5921a 100644 --- a/dist/src/sources/bluesky.js +++ b/dist/src/sources/bluesky.js @@ -31,8 +31,8 @@ export default class BlueskySource { const options = nodelink.options; this.nodelink = nodelink; this.config = { - maxSearchResults: typeof options.maxSearchResults === 'number' - ? options.maxSearchResults + maxSearchResults: typeof options.search.maxResults === 'number' + ? options.search.maxResults : undefined }; this.searchTerms = ['bksearch']; @@ -272,7 +272,7 @@ export default class BlueskySource { async search(query) { logger('debug', 'Bluesky', `Searching for: ${query}`); const searchUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}` + - `&limit=${this.config.maxSearchResults ?? 10}`; + `&limit=${this.config.search.maxResults ?? 10}`; const response = await makeRequest(searchUrl, { method: 'GET' }); const searchResponse = this.getSearchResponse(response.body); if (response.error || !searchResponse?.posts) { diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index 32caec59..7441a462 100644 --- a/dist/src/sources/deezer.js +++ b/dist/src/sources/deezer.js @@ -951,7 +951,7 @@ export default class DeezerSource { * @returns Maximum number of search results to return. */ getMaxSearchResults() { - const limit = this.config.maxSearchResults; + const limit = this.config.search.maxResults; return typeof limit === 'number' && limit > 0 ? limit : 10; } /** @@ -961,7 +961,7 @@ export default class DeezerSource { * @returns Maximum collection length for albums, playlists, or artists. */ getMaxCollectionLength(fallback) { - const limit = this.config.maxAlbumPlaylistLength; + const limit = this.config.playback.maxPlaylistLength; return typeof limit === 'number' && limit > 0 ? limit : fallback; } /** diff --git a/dist/src/sources/eternalbox.js b/dist/src/sources/eternalbox.js index f463f11d..525b2283 100644 --- a/dist/src/sources/eternalbox.js +++ b/dist/src/sources/eternalbox.js @@ -129,7 +129,7 @@ export default class EternalboxSource { return this.resolve(this._buildJukeboxUrl(query)); } const limit = this.config.searchResults || - this.nodelink.options.maxSearchResults || + this.nodelink.options.search.maxResults || 10; const url = `${this.baseUrl}/api/analysis/search?query=${encodeURIComponent(query)}&results=${limit}`; try { diff --git a/dist/src/sources/gaana.js b/dist/src/sources/gaana.js index 58409417..bb8f5e1b 100644 --- a/dist/src/sources/gaana.js +++ b/dist/src/sources/gaana.js @@ -68,8 +68,8 @@ export default class GaanaSource { ]; this.priority = 70; this.maxSearchResults = - this.asNumber(this.nodelink.options.maxSearchResults) ?? 10; - const maxAlbumPlaylistLength = this.asNumber(this.nodelink.options.maxAlbumPlaylistLength) ?? 100; + this.asNumber(this.nodelink.options.search.maxResults) ?? 10; + const maxAlbumPlaylistLength = this.asNumber(this.nodelink.options.playback.maxPlaylistLength) ?? 100; this.playlistLoadLimit = this.asNumber(this.config.playlistLoadLimit) ?? maxAlbumPlaylistLength; this.albumLoadLimit = @@ -832,7 +832,7 @@ export default class GaanaSource { * @returns Proxy configuration object or null. */ getProxyConfig() { - const proxy = this.asRecord(this.config.proxy); + const proxy = this.asRecord(this.config.network.proxy); const url = this.asString(proxy?.url); if (!url) return undefined; diff --git a/dist/src/sources/iheartradio.js b/dist/src/sources/iheartradio.js index 14a53253..971aae48 100644 --- a/dist/src/sources/iheartradio.js +++ b/dist/src/sources/iheartradio.js @@ -46,10 +46,10 @@ export default class IheartradioSource { this.nodelink = nodelink; const options = nodelink.options; this.maxSearchResults = - typeof options.maxSearchResults === 'number' && - Number.isInteger(options.maxSearchResults) && - options.maxSearchResults > 0 - ? options.maxSearchResults + typeof options.search.maxResults === 'number' && + Number.isInteger(options.search.maxResults) && + options.search.maxResults > 0 + ? options.search.maxResults : 10; } /** diff --git a/dist/src/sources/jiosaavn.js b/dist/src/sources/jiosaavn.js index 69469829..f737d00a 100644 --- a/dist/src/sources/jiosaavn.js +++ b/dist/src/sources/jiosaavn.js @@ -289,7 +289,7 @@ export default class JioSaavnSource { const { stream, error, statusCode } = await http1makeRequest(url, { method: 'GET', streamOnly: true, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (error || statusCode !== 200 || !stream) { return this.exceptionLoadResult(`Failed to load stream: ${error || statusCode || 'unknown'}`); @@ -333,7 +333,7 @@ export default class JioSaavnSource { const { body, error, statusCode } = await http1makeRequest(url.toString(), { method: 'GET', headers: HEADERS, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (error || statusCode !== 200) { throw new Error(`JioSaavn API request failed: ${statusCode || 'unknown'}`); diff --git a/dist/src/sources/lastfm.js b/dist/src/sources/lastfm.js index 5416475c..de5f20bd 100644 --- a/dist/src/sources/lastfm.js +++ b/dist/src/sources/lastfm.js @@ -72,7 +72,7 @@ export default class LastFMSource { } getMaxSearchResults() { const options = this.nodelink.options; - const limit = options.maxSearchResults; + const limit = options.search.maxResults; return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10; diff --git a/dist/src/sources/letrasmus.js b/dist/src/sources/letrasmus.js index 9c2cdf67..815655c1 100644 --- a/dist/src/sources/letrasmus.js +++ b/dist/src/sources/letrasmus.js @@ -141,10 +141,10 @@ export default class LetrasMusSource { this.patterns = [LETRAS_PATTERN]; const options = nodelink.options; this.maxSearchResults = - typeof options.maxSearchResults === 'number' && - Number.isInteger(options.maxSearchResults) && - options.maxSearchResults > 0 - ? options.maxSearchResults + typeof options.search.maxResults === 'number' && + Number.isInteger(options.search.maxResults) && + options.search.maxResults > 0 + ? options.search.maxResults : 10; } /** diff --git a/dist/src/sources/mixcloud.js b/dist/src/sources/mixcloud.js index 1241c01f..e57850ae 100644 --- a/dist/src/sources/mixcloud.js +++ b/dist/src/sources/mixcloud.js @@ -217,7 +217,7 @@ export default class MixcloudSource { }; }) .filter((track) => track.info.uri.length > 0) - .slice(0, this.config.maxSearchResults || DEFAULT_MAX_RESULTS); + .slice(0, this.config.search.maxResults || DEFAULT_MAX_RESULTS); if (tracks.length === 0) return this.emptyResult(); return { loadType: 'search', data: tracks }; @@ -341,7 +341,7 @@ export default class MixcloudSource { let cursor = null; let hasNextPage = true; let playlistName = 'Mixcloud Playlist'; - const maxTracks = this.config.maxAlbumPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH; + const maxTracks = this.config.playback.maxPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH; while (hasNextPage && tracks.length < maxTracks) { const response = await this._request(queryTemplate(cursor)); const body = this.parseObjectBody(response.body); @@ -411,7 +411,7 @@ export default class MixcloudSource { let cursor = null; let hasNextPage = true; let userDisplayName = username; - const maxTracks = this.config.maxAlbumPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH; + const maxTracks = this.config.playback.maxPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH; while (hasNextPage && tracks.length < maxTracks) { const response = await this._request(queryTemplate(cursor)); const body = this.parseObjectBody(response.body); diff --git a/dist/src/sources/monochrome.js b/dist/src/sources/monochrome.js index 8bc01c1b..98ac676d 100644 --- a/dist/src/sources/monochrome.js +++ b/dist/src/sources/monochrome.js @@ -353,7 +353,7 @@ class MonochromeSource { let name = 'Unknown Collection'; while (tracks.length < total && tracks.length < - (this.nodelink.options.maxAlbumPlaylistLength || 1000)) { + (this.nodelink.options.playback.maxPlaylistLength || 1000)) { const res = await this.fetchWithRetry(`/${type}/?id=${id}&offset=${offset}&limit=${limit}`); if (!res) break; diff --git a/dist/src/sources/netease.js b/dist/src/sources/netease.js index 2f90ceff..a432ebc3 100644 --- a/dist/src/sources/netease.js +++ b/dist/src/sources/netease.js @@ -60,7 +60,7 @@ export default class NeteaseSource { ]; this.priority = 45; this.searchTerms = ['ntsearch']; - this.maxSearchResults = nodelink.options.maxSearchResults || 10; + this.maxSearchResults = nodelink.options.search.maxResults || 10; } /** * Initializes Netease source. diff --git a/dist/src/sources/pandora.js b/dist/src/sources/pandora.js index 8c20e955..be06f24c 100644 --- a/dist/src/sources/pandora.js +++ b/dist/src/sources/pandora.js @@ -270,7 +270,7 @@ export default class PandoraSource { types: ['TR'], listener: null, start: 0, - count: this.options.maxSearchResults ?? 10, + count: this.options.search.maxResults ?? 10, annotate: true, searchTime: 0, annotationRecipe: 'CLASS_OF_2019' @@ -424,7 +424,7 @@ export default class PandoraSource { * @internal */ getMaxPlaylistLength() { - return this.options.maxAlbumPlaylistLength ?? 100; + return this.options.playback.maxPlaylistLength ?? 100; } /** * Resolves an artist, album, or track by ID. diff --git a/dist/src/sources/qobuz.js b/dist/src/sources/qobuz.js index c24129f8..fc02967a 100644 --- a/dist/src/sources/qobuz.js +++ b/dist/src/sources/qobuz.js @@ -631,14 +631,14 @@ export default class QobuzSource { * @returns Max search size. */ getMaxSearchResults() { - return this.asNumber(this.config.maxSearchResults) ?? 10; + return this.asNumber(this.config.search.maxResults) ?? 10; } /** * Reads max playlist/album load size from runtime config. * @returns Max playlist size. */ getMaxAlbumPlaylistLength() { - return this.asNumber(this.config.maxAlbumPlaylistLength) ?? 100; + return this.asNumber(this.config.playback.maxPlaylistLength) ?? 100; } /** * Converts unknown search results into TrackInfo list. diff --git a/dist/src/sources/shazam.js b/dist/src/sources/shazam.js index bb1cb95a..998b7074 100644 --- a/dist/src/sources/shazam.js +++ b/dist/src/sources/shazam.js @@ -58,7 +58,7 @@ export default class ShazamSource { */ getMaxSearchResults() { const options = this.nodelink.options; - const limit = options.maxSearchResults; + const limit = options.search.maxResults; return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10; diff --git a/dist/src/sources/songlink.js b/dist/src/sources/songlink.js index 7098a989..09b3438d 100644 --- a/dist/src/sources/songlink.js +++ b/dist/src/sources/songlink.js @@ -208,7 +208,7 @@ export default class SongLinkSource { */ async search(query, _sourceTerm, _searchType = 'track') { try { - const maxSearchRaw = this.nodelink.options.maxSearchResults; + const maxSearchRaw = this.nodelink.options.search.maxResults; const limit = typeof maxSearchRaw === 'number' && Number.isFinite(maxSearchRaw) ? maxSearchRaw : 10; diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js index b699c337..8c6b1106 100644 --- a/dist/src/sources/soundcloud.js +++ b/dist/src/sources/soundcloud.js @@ -121,7 +121,7 @@ export default class SoundCloudSource { const params = new URLSearchParams({ q: searchQuery, client_id: this.clientId ?? '', - limit: String(this.nodelink.options.maxSearchResults ?? 50), + limit: String(this.nodelink.options.search.maxResults ?? 50), offset: '0', linked_partitioning: '1' }); @@ -181,7 +181,7 @@ export default class SoundCloudSource { } } _processUsers(collection) { - const max = this.nodelink.options.maxSearchResults ?? 50; + const max = this.nodelink.options.search.maxResults ?? 50; const users = []; for (let i = 0; i < collection.length && users.length < max; i++) { const user = collection[i]; @@ -214,7 +214,7 @@ export default class SoundCloudSource { return users; } _processAlbums(collection) { - const max = this.nodelink.options.maxSearchResults ?? 50; + const max = this.nodelink.options.search.maxResults ?? 50; const albums = []; for (let i = 0; i < collection.length && albums.length < max; i++) { const album = collection[i]; @@ -247,7 +247,7 @@ export default class SoundCloudSource { return albums; } _processPlaylists(collection) { - const max = this.nodelink.options.maxSearchResults ?? 50; + const max = this.nodelink.options.search.maxResults ?? 50; const playlists = []; for (let i = 0; i < collection.length && playlists.length < max; i++) { const playlist = collection[i]; @@ -280,7 +280,7 @@ export default class SoundCloudSource { return playlists; } _processAll(collection) { - const max = this.nodelink.options.maxSearchResults ?? 50; + const max = this.nodelink.options.search.maxResults ?? 50; const results = []; for (let i = 0; i < collection.length && results.length < max; i++) { const item = collection[i]; @@ -410,7 +410,7 @@ export default class SoundCloudSource { ids.push(t.id); } } - const limit = this.nodelink.options.maxAlbumPlaylistLength ?? 100; + const limit = this.nodelink.options.playback.maxPlaylistLength ?? 100; const neededIds = ids.slice(0, Math.max(0, limit - complete.length)); if (neededIds.length > 0) { const chunks = []; @@ -451,7 +451,7 @@ export default class SoundCloudSource { }; } _processTracks(collection) { - const max = this.nodelink.options.maxSearchResults ?? 50; + const max = this.nodelink.options.search.maxResults ?? 50; const tracks = []; if (!Array.isArray(collection)) return []; diff --git a/dist/src/sources/spotify.js b/dist/src/sources/spotify.js index a674f0c1..2badb5cb 100644 --- a/dist/src/sources/spotify.js +++ b/dist/src/sources/spotify.js @@ -830,7 +830,7 @@ export default class SpotifySource { * @internal */ async _resolveAlbum(id) { - const maxTracks = this.nodelink.options.maxAlbumPlaylistLength || 1000; + const maxTracks = this.nodelink.options.playback.maxPlaylistLength || 1000; const tracks = []; let name = 'Unknown Album'; if (this.anonymousToken || this.config.sp_dc) { @@ -916,7 +916,7 @@ export default class SpotifySource { * @internal */ async _resolvePlaylist(id) { - const maxTracks = this.nodelink.options.maxAlbumPlaylistLength || 1000; + const maxTracks = this.nodelink.options.playback.maxPlaylistLength || 1000; const tracks = []; let name = 'Unknown Playlist'; if (this.anonymousToken || id.startsWith('37i9dQZ')) { diff --git a/dist/src/sources/tidal.js b/dist/src/sources/tidal.js index c5d75d8e..bf8a8347 100644 --- a/dist/src/sources/tidal.js +++ b/dist/src/sources/tidal.js @@ -136,7 +136,7 @@ export default class TidalSource { return this.getRecommendations(query); } try { - const limit = this.asNumber(this.nodelink.options.maxSearchResults) ?? 10; + const limit = this.asNumber(this.nodelink.options.search.maxResults) ?? 10; const data = await this.getJson('search', { query, limit, diff --git a/dist/src/sources/twitter.js b/dist/src/sources/twitter.js index 87b987db..ab52d79f 100644 --- a/dist/src/sources/twitter.js +++ b/dist/src/sources/twitter.js @@ -356,7 +356,7 @@ export default class TwitterSource { */ getMaxSearchResults() { const options = this.nodelink.options; - const limit = options.maxSearchResults; + const limit = options.search.maxResults; return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10; diff --git a/dist/src/sources/vkmusic.js b/dist/src/sources/vkmusic.js index 2398c7be..bde83fab 100644 --- a/dist/src/sources/vkmusic.js +++ b/dist/src/sources/vkmusic.js @@ -161,7 +161,7 @@ export default class VKMusicSource { body: 'version=1&app_id=6287487', disableBodyCompression: true, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (res.error || @@ -207,7 +207,7 @@ export default class VKMusicSource { try { const res = await this._apiRequest('audio.search', { q: query, - count: String(this.nodelink.options.maxSearchResults || 10), + count: String(this.nodelink.options.search.maxResults || 10), extended: '1' }); if (!res?.items?.length) @@ -312,7 +312,7 @@ export default class VKMusicSource { const params = { owner_id: ownerId, extended: '1', - count: String(this.nodelink.options.maxAlbumPlaylistLength || 100) + count: String(this.nodelink.options.playback.maxPlaylistLength || 100) }; if (playlistId) params.album_id = playlistId; @@ -344,7 +344,7 @@ export default class VKMusicSource { try { const res = await http1makeRequest(url, { headers: { 'User-Agent': USER_AGENT, Cookie: this.cookie }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`); @@ -424,7 +424,7 @@ export default class VKMusicSource { try { const res = await http1makeRequest(url, { headers: { 'User-Agent': USER_AGENT, Cookie: this.cookie }, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`); @@ -635,7 +635,7 @@ export default class VKMusicSource { type: 'mpegts', localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, startTime: additionalData?.startTime || 0, - proxy: this.config.proxy + proxy: this.config.network.proxy }), type: 'mpegts' }; @@ -644,7 +644,7 @@ export default class VKMusicSource { method: 'GET', streamOnly: true, headers, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (res.error || !res.stream) throw new Error(res.error || 'Failed to fetch direct stream.'); @@ -672,7 +672,7 @@ export default class VKMusicSource { 'User-Agent': 'KateMobileAndroid/56 lite-460 (Android 4.4.2; SDK 19; x86; unknown Android SDK built for x86; en)' }, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }); const body = res.body; if (res.error || res.statusCode !== 200 || body.error) { diff --git a/dist/src/sources/yandexmusic.js b/dist/src/sources/yandexmusic.js index 3d320fe6..9a34ab32 100644 --- a/dist/src/sources/yandexmusic.js +++ b/dist/src/sources/yandexmusic.js @@ -196,7 +196,7 @@ export default class YandexMusicSource { }); if (!data) return { loadType: 'empty', data: {} }; - const limit = this.nodelink.options.maxSearchResults || 10; + const limit = this.nodelink.options.search.maxResults || 10; if (searchType === 'album') { const albums = (data.albums?.results || []) .filter((item) => this.allowUnavailable || item.available) @@ -387,7 +387,7 @@ export default class YandexMusicSource { streamOnly: true, headers, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }); }; let response = await requestStream(url); @@ -813,7 +813,7 @@ export default class YandexMusicSource { 'X-Yandex-Music-Client': CLIENT_HEADER }, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }); if (res.statusCode !== 200) { throw new Error(`Yandex API returned HTTP ${res.statusCode} for ${path}`); diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index edc3f751..52c99173 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream'; import HLSHandler from "../../playback/hls/HLSHandler.js"; -import { getBestMatch, http1makeRequest, logger, makeRequest } from "../../utils.js"; +import { getBestMatch, http1makeRequest, logger } from "../../utils.js"; import CipherManager from "./CipherManager.js"; import Android from "./clients/Android.js"; import AndroidVR from "./clients/AndroidVR.js"; @@ -11,7 +11,7 @@ import TVCast from "./clients/TVCast.js"; import Web from "./clients/Web.js"; import WebRemix from "./clients/Web_Remix.js"; import WebEmbedded from "./clients/WebEmbedded.js"; -import { checkURLType, YOUTUBE_CONSTANTS } from "./common.js"; +import { checkURLType } from "./common.js"; import YouTubeLiveChat from "./LiveChat.js"; import OAuth from "./OAuth.js"; import { SabrStream } from "./sabr/sabr.js"; @@ -23,105 +23,6 @@ const MAX_RETRIES = 3; const MAX_URL_REFRESH = 10; /** Interval in milliseconds between visitor data refreshes. */ const VISITOR_DATA_INTERVAL = 3_600_000; -/** - * Manages a scored pool of proxies with automatic health tracking. - * - * Proxies are scored from 0 to 100; failed requests decrease the score and - * successful ones restore it. When selecting a proxy the top-3 healthiest - * candidates are picked at random to spread the load. - */ -class YouTubeProxyManager { - /** Internal pool of proxy entries with health metrics. */ - proxies; - /** - * Creates a new proxy manager from raw configuration entries. - * @param rawProxies - Array of proxy URLs or configuration objects. - */ - constructor(rawProxies) { - this.proxies = (rawProxies || []).map((p) => ({ - url: typeof p === 'string' ? p : p.url, - type: (typeof p === 'string' ? 'forward' : p.type || 'forward'), - failures: 0, - lastFailure: 0, - activeRequests: 0, - score: 100, - latency: 0 - })); - } - /** - * Selects the healthiest available proxy from the pool. - * - * Filters out proxies that have been recently penalized (score 0 with - * recent failures), sorts by score then active requests then latency, - * and randomly picks one of the top-3 candidates to spread load. - * - * @returns A shallow copy {@link ProxySnapshot} of the selected proxy, or `undefined` if the pool is empty. - */ - getBestProxy() { - if (!this.proxies.length) - return undefined; - const now = Date.now(); - const available = this.proxies.filter((p) => { - if (p.score <= 0) - return now - p.lastFailure > 600_000; - if (p.failures > 5) - return now - p.lastFailure > 60_000; - return true; - }); - const list = available.length ? available : this.proxies; - list.sort((a, b) => { - if (b.score !== a.score) - return b.score - a.score; - if (a.activeRequests !== b.activeRequests) - return a.activeRequests - b.activeRequests; - return a.latency - b.latency; - }); - const topCount = Math.min(3, list.length); - const selected = list[Math.floor(Math.random() * topCount)]; - if (!selected) - return undefined; - selected.activeRequests++; - return { ...selected }; - } - /** - * Reports the outcome of a proxy request for health tracking. - * - * Successful requests restore 5 score points; failures apply a penalty - * proportional to the HTTP status code (403 = 50, 429 = 30, 503 = 20, other = 10). - * The latency is exponentially smoothed into the proxy's running average. - * - * @param proxyUrl - Full URL of the proxy used. - * @param success - Whether the request succeeded. - * @param status - HTTP status code of the response. - * @param latency - Round-trip latency in milliseconds (defaults to 0). - */ - report(proxyUrl, success, status, latency = 0) { - const p = this.proxies.find((pr) => pr.url === proxyUrl); - if (!p) - return; - if (p.activeRequests > 0) - p.activeRequests--; - if (latency > 0) { - p.latency = p.latency === 0 ? latency : p.latency * 0.8 + latency * 0.2; - } - if (success) { - p.score = Math.min(100, p.score + 5); - p.failures = 0; - } - else { - p.failures++; - p.lastFailure = Date.now(); - let penalty = 10; - if (status === 403) - penalty = 50; - if (status === 429) - penalty = 30; - if (status === 503) - penalty = 20; - p.score = Math.max(0, p.score - penalty); - } - } -} /** * YouTube source implementation for NodeLink. * @@ -136,8 +37,6 @@ export default class YouTubeSource { nodelink; /** YouTube-specific configuration block from `nodelink.options.sources.youtube`. */ config; - /** Scored proxy pool manager with automatic health tracking and load balancing. */ - proxyManager; /** Instantiated YouTube innertube client objects keyed by class name (e.g. `Android`, `Web`). */ // biome-ignore lint/suspicious/noExplicitAny: JS client class instances have heterogeneous method shapes clients; @@ -173,16 +72,17 @@ export default class YouTubeSource { constructor(nodelink) { this.nodelink = nodelink; this.config = nodelink.options.sources?.youtube; - this.proxyManager = new YouTubeProxyManager(this.config.proxies || []); this.additionalsSourceName = ['ytmusic']; this.searchTerms = ['ytsearch', 'ytmsearch']; this.recommendationTerm = ['ytrec']; this.patterns = [ /^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|www)\.youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+|channel\/[\w-]+|user\/[\w-]+)/, + /^https?:\/\/(?:music|www)\.youtube\.com\/playlist\?list=[\w-]+/, + /^https?:\/\/(?:music|www)\.youtube\.com\/watch\?v=[\w-]+/, /^https?:\/\/music\.youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+)/ ]; - this.priority = 100; + this.priority = 10; this.clients = {}; this.oauth = null; this.visitorDataInterval = null; @@ -195,23 +95,45 @@ export default class YouTubeSource { this.mirrorFallbackInFlight = new Set(); this.ytContext = { client: { + hl: this.config.hl || 'en', + gl: this.config.gl || 'US', + visitorData: this.config.visitorData || '', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + clientName: 'WEB', + clientVersion: '2.20231201.01.00', + osName: 'Windows', + osVersion: '10.0', + platform: 'DESKTOP', + clientFormFactor: 'UNKNOWN_FORM_FACTOR', + userInterfaceTheme: 'USER_INTERFACE_THEME_DARK', + browserName: 'Chrome', + browserVersion: '120.0.0.0', screenDensityFloat: 1, screenHeightPoints: 1080, screenPixelDensity: 1, screenWidthPoints: 1920, - hl: 'en', - gl: 'US', - visitorData: null + utcOffsetMinutes: 0 } }; } /** * Returns the healthiest available proxy from the managed pool. - * @param _rotate - Whether to rotate the proxy selection (currently unused, kept for interface compatibility). + * @param _rotate - Whether to force a rotation (ignored in current weighted selection). * @returns A {@link ProxySnapshot} of the selected proxy, or `undefined` if no proxies are configured. */ getProxy(_rotate = true) { - return this.proxyManager.getBestProxy(); + const p = this.nodelink.proxyManager?.getBestProxy('youtube'); + if (!p) + return undefined; + return { + url: p.target.url, + type: p.target.type, + failures: p.state.totalFailures, + lastFailure: p.state.lastFailureAt || 0, + activeRequests: p.state.activeConnections, + score: p.state.status === 'DOWN' ? 0 : 100, + latency: p.state.movingAverageLatency + }; } /** * Reports the outcome of a proxied request for health tracking. @@ -222,14 +144,12 @@ export default class YouTubeSource { */ reportProxyStatus(proxy, success, status, latency = 0) { if (proxy?.url) { - this.proxyManager.report(proxy.url, success, status, latency); + this.nodelink.proxyManager?.report(proxy.url, success, status, latency); } } /** * Initializes the YouTube source by instantiating innertube clients, - * fetching visitor data, caching the player script, and starting - * periodic visitor data refresh. - * @returns Promise resolving to `true` when setup completes successfully. + * setting up OAuth, and starting periodic background tasks. */ async setup() { logger('info', 'YouTube', 'Setting up YouTube source...'); @@ -239,1425 +159,331 @@ export default class YouTubeSource { AndroidVR, IOS, Music, - WebRemix, TV, TVCast, Web, + WebRemix, WebEmbedded }; - for (const clientName of Object.keys(clientClasses)) { - const ClientCtor = clientClasses[clientName]; - if (!ClientCtor) - continue; - this.clients[clientName] = new ClientCtor(this.nodelink, this.oauth); + for (const [name, ClientClass] of Object.entries(clientClasses)) { + this.clients[name] = new ClientClass(this.nodelink, { + getProxy: this.getProxy.bind(this), + reportProxyStatus: this.reportProxyStatus.bind(this), + getOAuthToken: async () => { + if (!this.oauth) + return null; + return this.oauth.getAccessToken(); + }, + getContext: () => this.ytContext, + getCipher: () => this.cipherManager + }); } - logger('debug', 'YouTube', `Initialized clients: ${Object.keys(this.clients).join(', ')}`); + // Perform initial visitor data fetch await this._fetchVisitorData(); - await this.cipherManager.getCachedPlayerScript(); - await this.cipherManager.checkCipherServerStatus(); - if (this.visitorDataInterval) - clearInterval(this.visitorDataInterval); + // Schedule periodic visitor data refresh this.visitorDataInterval = setInterval(() => this._fetchVisitorData(), VISITOR_DATA_INTERVAL); if (typeof this.visitorDataInterval.unref === 'function') { this.visitorDataInterval.unref(); } - logger('info', 'YouTube', 'YouTube source setup complete.'); return true; } /** - * Tears down the YouTube source by aborting active streams, clearing - * the visitor data interval, and cleaning up OAuth and cipher resources. - */ - cleanup() { - logger('info', 'YouTube', 'Cleaning up YouTube source...'); - for (const [, cancelSignal] of this.activeStreams.entries()) { - cancelSignal.aborted = true; - } - this.activeStreams.clear(); - if (this.visitorDataInterval) { - clearInterval(this.visitorDataInterval); - this.visitorDataInterval = null; - } - if (this.oauth) - this.oauth.cleanup?.(); - this.cipherManager?.cleanup?.(); - } - /** - * Fetches visitor data and player script URL from YouTube embed pages. - * - * Tries the embed endpoint first, then falls back to the guide API. - * Both visitor data and player script URL are cached in the credential - * manager for use by innertube clients. - * - * @returns Promise that resolves when the fetch attempt completes. + * Fetches new visitor data from YouTube to keep the session context fresh. + * Automatically selects the best available innertube client for the request. */ async _fetchVisitorData() { - const cachedPlayerScript = this.nodelink.credentialManager?.get('yt_player_script_url'); - if (cachedPlayerScript) { - this.cipherManager.setPlayerScriptUrl(cachedPlayerScript); - logger('debug', 'YouTube', 'Player script URL loaded from cache.'); - } - let visitorFound = false; - let playerScriptUrl = null; - try { - const { body: data, error, statusCode } = await makeRequest('https://www.youtube.com/embed', { - method: 'GET', - headers: { - Cookie: 'YSC=cz5kYp3ZuIE; VISITOR_INFO1_LIVE=U-0T5oUyzf8;' - } - }); - if (!error && statusCode === 200) { - const bodyStr = data; - const visitorMatch = bodyStr?.match(/"VISITOR_DATA":"([^"]+)"/); - if (visitorMatch?.[1]) { - this.ytContext.client.visitorData = visitorMatch[1]; - this.nodelink.credentialManager?.set('yt_visitor_data', visitorMatch[1], 60 * 60 * 1000); - visitorFound = true; - logger('debug', 'YouTube', 'visitorData refreshed and cached.'); - } - const playerScriptMatch = bodyStr?.match(/"jsUrl":"([^"]+)"/); - if (playerScriptMatch?.[1]) { - playerScriptUrl = playerScriptMatch[1].replace(/\/[a-z]{2}_[A-Z]{2}\//, '/en_US/'); - this.nodelink.credentialManager?.set('yt_player_script_url', playerScriptUrl, 12 * 60 * 60 * 1000); - logger('debug', 'YouTube', `Player script URL: ${playerScriptUrl}`); + const clientNames = this.config.clients?.resolve || ['Web']; + let visitorData = ''; + for (const name of clientNames) { + const client = this.clients[name]; + if (!client) + continue; + try { + const response = await client.getVisitorData(); + if (response) { + visitorData = response; + break; } } - else { - logger('warn', 'YouTube', `Embed request failed: ${error?.message || `Status ${statusCode}`}`); - } - if (!visitorFound) { - const { body: guideData, error: guideError, statusCode: guideStatusCode } = await makeRequest('https://www.youtube.com/youtubei/v1/guide', { - method: 'POST', - body: { context: this.ytContext }, - disableBodyCompression: true - }); - const guideBody = guideData; - if (!guideError && - guideStatusCode === 200 && - guideBody?.responseContext?.visitorData) { - this.ytContext.client.visitorData = - guideBody.responseContext.visitorData; - this.nodelink.credentialManager?.set('yt_visitor_data', guideBody.responseContext.visitorData, 60 * 60 * 1000); - visitorFound = true; - logger('debug', 'YouTube', 'visitorData refreshed via guide and cached.'); - } - else { - logger('warn', 'YouTube', 'Failed to refresh visitorData via guide; using cached fallback if present.'); - } + catch (err) { + logger('debug', 'YouTube', `Failed to fetch visitor data using ${name}: ${err instanceof Error ? err.message : String(err)}`); } } - catch (e) { - logger('error', 'YouTube', `Error fetching visitor data: ${e.message}`); - logger('warn', 'YouTube', 'Using cached visitorData fallback (if present).'); + if (visitorData) { + this.ytContext.client.visitorData = visitorData; + logger('debug', 'YouTube', `Updated visitor data: ${visitorData}`); } - if (playerScriptUrl) - this.cipherManager.setPlayerScriptUrl(playerScriptUrl); } /** - * Searches YouTube for tracks, playlists, or recommendations. - * - * Iterates through the configured search clients in priority order, - * returning the first successful result. For YouTube Music searches - * (`ytmsearch`), only WebRemix and Music clients are used. + * Searches for tracks on YouTube or YouTube Music using the configured search clients. * * @param query - Search query string. - * @param type - Search term alias (`'ytsearch'`, `'ytmsearch'`, `'ytrec'`). - * @param searchType - Content type to search for (`'track'`, `'playlist'`, etc.). - * @returns Promise resolving to a source result with search results or an exception. + * @param sourceName - Optional source override ('youtube' or 'ytmusic'). + * @returns A search result containing an array of {@link TrackData}. */ - async search(query, type, searchType = 'track') { - if (type === 'ytrec') { - return this.getRecommendations(query); - } - let clientList = this.config.clients.search; - if (type === 'ytmsearch') { - clientList = ['WebRemix', 'Music']; - } - const clientErrors = []; - for (const clientName of clientList) { - const client = this.clients[clientName]; + async search(query, sourceName) { + const isMusic = sourceName === 'ytmusic'; + const clientNames = this.config.clients?.search || ['Android']; + logger('debug', 'YouTube', `Searching for "${query}" (Source: ${sourceName || 'youtube'})`); + for (const name of clientNames) { + const client = this.clients[name]; if (!client) continue; try { - logger('debug', 'YouTube', `Attempting ${searchType} search with client: ${clientName}`); - const searchProxy = clientName === 'Android' ? this.getProxy(true) : undefined; - const result = await client.search(query, searchType, this.ytContext, searchProxy, this.reportProxyStatus.bind(this)); - if (result && result.loadType === 'search') { - logger('debug', 'YouTube', `Search successful with client: ${clientName}`); - return result; + const results = await client.search(query, isMusic); + if (results && results.length > 0) { + return { loadType: 'search', data: results }; } - const errorMessage = result?.data?.message || - 'Client returned empty or failed.'; - clientErrors.push({ client: clientName, message: errorMessage }); - logger('debug', 'YouTube', `Client ${clientName} returned empty or failed search.`); } - catch (e) { - clientErrors.push({ - client: clientName, - message: e.message - }); - logger('warn', 'YouTube', `Client ${clientName} threw an exception during search: ${e.message}`); + catch (err) { + logger('warn', 'YouTube', `Search failed using ${name}: ${err instanceof Error ? err.message : String(err)}`); } } - logger('error', 'YouTube', 'No search results found from any configured client.'); - return { - loadType: 'error', - exception: { - message: 'No search results found from any configured client.', - severity: 'fault', - cause: 'All clients failed.', - errors: clientErrors - } - }; + return { loadType: 'empty', data: {} }; } /** - * Fetches YouTube auto-mix recommendations for a given video or query. - * - * Constructs an auto-mix playlist ID (`RD{videoId}`) and attempts to - * resolve it through music and TV clients in priority order. If the - * query is not a valid video ID, performs a search first to resolve one. + * Resolves a YouTube URL to track or playlist data. * - * @param query - YouTube video ID or search query string. - * @returns Promise resolving to a playlist result with recommendations, or an empty result. + * @param url - YouTube watch, playlist, or shorts URL. + * @returns A result containing a single track or a playlist of tracks. */ - async getRecommendations(query) { - let videoId = query; - if (!/^[a-zA-Z0-9_-]{11}$/.test(query)) { - const searchRes = await this.search(query, 'ytmsearch'); - if (searchRes.loadType !== 'search' || searchRes.data.length === 0) { - return { loadType: 'empty', data: {} }; - } - videoId = searchRes.data[0].info.identifier; - } - try { - const automixId = `RD${videoId}`; - let automixRes = null; - if (this.clients.WebRemix || this.clients.Music) { - try { - const musicClient = this.clients.WebRemix ?? this.clients.Music; - if (!musicClient) - throw new Error('no music client'); - const clientName = this.clients.WebRemix ? 'WebRemix' : 'Music'; - logger('debug', 'YouTube', `Attempting recommendations with ${clientName} client`); - automixRes = await musicClient.resolve(`https://music.youtube.com/playlist?list=${automixId}`, 'ytmusic', this.ytContext, this.cipherManager); - } - catch (e) { - logger('debug', 'YouTube', `Music client failed for recommendations: ${e.message}`); - } - } - if ((!automixRes || automixRes.loadType !== 'playlist') && - (this.clients.TV || this.clients.TVCast || this.clients.WebRemix)) { - try { - const tvClient = this.clients.TV ?? this.clients.TVCast; - if (!tvClient) - throw new Error('no tv client'); - const clientName = this.clients.TV ? 'TV' : 'TVCast'; - logger('debug', 'YouTube', `Attempting recommendations with ${clientName} client`); - automixRes = await tvClient.resolve(`https://www.youtube.com/playlist?list=${automixId}`, 'youtube', this.ytContext, this.cipherManager); - } - catch (e) { - logger('debug', 'YouTube', `TV client failed for recommendations: ${e.message}`); - } + async resolve(url) { + const type = checkURLType(url); + const clientNames = this.config.clients?.resolve || ['Web']; + logger('debug', 'YouTube', `Resolving URL: ${url} (Type: ${type})`); + for (const name of clientNames) { + const client = this.clients[name]; + if (!client) + continue; + try { + let result = null; + if (type === 'track') { + const track = await client.resolveTrack(url); + if (track) + result = { loadType: 'track', data: track }; + } + else if (type === 'playlist') { + const playlist = await client.resolvePlaylist(url); + if (playlist) + result = { loadType: 'playlist', data: playlist }; + } + if (result) + return result; } - if (automixRes && - automixRes.loadType === 'playlist' && - automixRes.data.tracks.length > 0) { - const tracks = automixRes.data.tracks.filter((t) => t.info.identifier !== videoId); - return { - loadType: 'playlist', - data: { - info: { name: 'YouTube Recommendations', selectedTrack: 0 }, - pluginInfo: { type: 'recommendations' }, - tracks - } - }; + catch (err) { + logger('warn', 'YouTube', `Resolve failed using ${name}: ${err instanceof Error ? err.message : String(err)}`); } - return { loadType: 'empty', data: {} }; - } - catch (e) { - logger('error', 'YouTube', `Recommendations failed: ${e.message}`); - return { - loadType: 'error', - exception: { message: e.message, severity: 'fault' } - }; } + return { loadType: 'empty', data: {} }; } /** - * Resolves a YouTube or YouTube Music URL into a track or playlist result. + * Resolves a playable URL and playback metadata for a YouTube track. * - * Normalizes live URLs, detects music URLs for specialized client routing, - * and iterates through configured clients with automatic fallback between - * music and standard YouTube clients when playability errors occur. + * Implements a retry-with-recovery logic: if all configured playback clients + * fail to resolve a URL, it attempts to "recover" by falling back to search + * for the track's ISRC or title on other sources. * - * @param url - YouTube or YouTube Music URL to resolve. - * @param type - Optional source type override (e.g. `'youtube-fallback'` for recursive fallback). - * @returns Promise resolving to a source result with track/playlist data or an exception. + * @param trackInfo - Decoded track information. + * @param itag - Optional requested format itag. + * @param isRecovering - Internal flag to prevent recursion during recovery. + * @returns A {@link TrackUrlResult} containing the playback URL and format. */ - 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'; - const processUrl = url; - const clientList = this.config.clients.resolve || this.config.clients.playback; - logger('debug', 'YouTube', `Using resolve clients: ${clientList.join(', ')}`); - const clientErrors = []; - const urlType = checkURLType(processUrl, sourceType); - if (isMusicUrl) { - const musicClients = ['WebRemix', 'Music']; - for (const clientName of musicClients) { - const musicClient = this.clients[clientName]; - if (!musicClient) - continue; - try { - logger('debug', 'YouTube', `Attempting to resolve YouTube Music URL with ${clientName} 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 ${clientName} client.`); - return result; - } - if (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', `${clientName} 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; - } - } - } - const errorMessage = result?.data?.message || - `${clientName} client returned empty or failed.`; - clientErrors.push({ client: clientName, message: errorMessage }); - logger('debug', 'YouTube', `${clientName} client returned empty or failed for Music URL.`); - } - catch (e) { - clientErrors.push({ - client: clientName, - message: e.message - }); - logger('warn', 'YouTube', `${clientName} client threw an exception during Music URL resolve: ${e.message}`); - } - } - const msg = 'All music clients failed for direct Music URL.'; - logger('error', 'YouTube', msg); - return { - loadType: 'error', - exception: { - message: msg, - severity: 'fault', - cause: 'MusicClientsFailure', - errors: clientErrors - } - }; - } - if (urlType === YOUTUBE_CONSTANTS.PLAYLIST) { - const androidClient = this.clients.Android; - if (androidClient) { - try { - logger('debug', 'YouTube', 'Attempting to resolve playlist with Android client.'); - const result = await androidClient.resolve(processUrl, sourceType, this.ytContext, this.cipherManager); - if (result && - (result.loadType === 'track' || - result.loadType === 'playlist' || - result.loadType === 'empty')) { - logger('debug', 'YouTube', 'Successfully resolved playlist with Android client.'); - return result; - } - const errorMessage = result?.data?.message || - 'Android client failed for playlist.'; - clientErrors.push({ client: 'Android', message: errorMessage }); - logger('debug', 'YouTube', 'Android client returned empty or failed to resolve playlist.'); - } - catch (e) { - clientErrors.push({ - client: 'Android', - message: e.message - }); - logger('warn', 'YouTube', `Android client threw an exception during playlist resolve: ${e.message}`); - } - } - else { - clientErrors.push({ - client: 'Android', - message: 'Android client not available.' - }); - logger('warn', 'YouTube', 'Android client not available for playlist priority.'); - } - } - for (const clientName of clientList) { - const client = this.clients[clientName]; + async getTrackUrl(trackInfo, itag, isRecovering = false) { + const clientNames = this.config.clients?.playback || ['AndroidVR', 'TV']; + const requestedItag = itag || this.config.targetItag || undefined; + logger('debug', 'YouTube', `Getting URL for: ${trackInfo.title} (${trackInfo.identifier})`); + for (const name of clientNames) { + const client = this.clients[name]; if (!client) continue; - if (!isMusicUrl && clientName === 'Music') - continue; - if (isMusicUrl && clientName !== 'Music' && type !== 'youtube-fallback') { - continue; + try { + const result = await client.getTrackUrl(trackInfo.identifier, requestedItag); + if (result && result.url) + return result; } - if (type === 'youtube-fallback' && - !['Android', 'Web'].includes(clientName)) { - continue; + catch (err) { + logger('debug', 'YouTube', `URL resolution failed using ${name}: ${err instanceof Error ? err.message : String(err)}`); } + } + // Recovery logic: if we can't get a YouTube URL, try fallback sources + if (!isRecovering && !this.mirrorFallbackInFlight.has(trackInfo.identifier)) { + this.mirrorFallbackInFlight.add(trackInfo.identifier); try { - logger('debug', 'YouTube', `Attempting to resolve URL with client: ${clientName}`); - const result = await client.resolve(processUrl, sourceType, this.ytContext, this.cipherManager, this.reportProxyStatus.bind(this)); - if (result && - (result.loadType === 'track' || - result.loadType === 'playlist' || - result.loadType === 'empty')) { - logger('debug', 'YouTube', `Successfully resolved URL with client: ${clientName}`); - return result; - } - const errorMessage = result?.data?.message || - 'Client returned empty or failed.'; - clientErrors.push({ client: clientName, message: errorMessage }); - logger('debug', 'YouTube', `Client ${clientName} returned empty or failed to resolve URL.`); + return await this._recoverTrack(trackInfo); } - catch (e) { - clientErrors.push({ - client: clientName, - message: e.message - }); - logger('warn', 'YouTube', `Client ${clientName} threw an exception during resolve: ${e.message}`); + finally { + this.mirrorFallbackInFlight.delete(trackInfo.identifier); } } - logger('error', 'YouTube', 'All clients failed to resolve the URL.'); return { - loadType: 'error', exception: { - message: 'All clients failed to resolve the URL.', - severity: 'fault', - cause: 'All clients failed.', - errors: clientErrors + message: 'Could not resolve a playable URL for this track.', + severity: 'common' } }; } /** - * Enriches a basic track with "Holo" metadata by querying the Web client - * player API. Falls back to the original track if enrichment fails. - * @param vanillaTrack - Basic track object with `info` and optional `userData`. - * @param options - Optional overrides for channel info and external link resolution. - * @returns Promise resolving to the enriched track or the original if enrichment fails. - */ - async resolveHoloTrack(vanillaTrack, options = {}) { - try { - const { info, userData } = vanillaTrack; - const webClient = this.clients.Web; - if (!webClient) { - logger('warn', 'YouTube', 'Web client not available for Holo resolution'); - return vanillaTrack; - } - const videoId = info.identifier; - const playerResult = await webClient._makePlayerRequest?.(videoId, this.ytContext, {}, this.cipherManager); - const playerBody = playerResult?.body; - if (!playerBody || playerBody.error) - return vanillaTrack; - const { buildHoloTrack } = await import("./common.js"); - const holoTrack = await buildHoloTrack(info, null, info.sourceName === 'ytmusic' ? 'ytmusic' : 'youtube', - // biome-ignore lint/suspicious/noExplicitAny: buildHoloTrack is dynamically imported from JS with inferred null-typed param - playerBody, { - fetchChannelInfo: options.fetchChannelInfo ?? false, - resolveExternalLinks: options.resolveExternalLinks ?? false - }); - if (holoTrack) - holoTrack.userData = userData; - return holoTrack; - } - catch (err) { - logger('error', 'YouTube', `Failed to resolve Holo track: ${err.message}`); - return vanillaTrack; - } - } - /** - * Resolves the playable stream URL for a decoded track. + * Attempts to find a mirror for a failing YouTube track on other enabled sources. * - * Checks the track cache first (unless `forceRefresh` is set), then - * iterates through configured playback clients. Validates direct URLs - * with a pre-flight range request and falls back to HLS when direct - * URLs return 403. If all clients fail, attempts mirror-source fallback. - * - * @param decodedTrack - Track metadata to resolve. - * @param itag - Optional specific format itag to request. - * @param forceRefresh - When `true`, bypasses the track cache and forces a fresh URL fetch. - * @returns Promise resolving to track URL data with stream info or an exception. + * @param trackInfo - The failing YouTube track. + * @returns A {@link TrackUrlResult} from a fallback source. */ - async getTrackUrl(decodedTrack, itag, forceRefresh = false) { - if (!forceRefresh) { - const cached = this.nodelink.trackCacheManager?.get('youtube', decodedTrack.identifier); - if (cached) { - const cachedProxyUrl = cached.additionalData?.proxy?.url; - const currentProxies = this.config.proxies || []; - const isProxyStillValid = !cachedProxyUrl || - currentProxies.some((p) => (typeof p === 'string' ? p : p.url) === cachedProxyUrl); - if (isProxyStillValid) { - logger('debug', 'YouTube', `Using cached URL for ${decodedTrack.identifier}`); - return cached; - } - logger('debug', 'YouTube', `Cached proxy for ${decodedTrack.identifier} is no longer in config. Forcing refresh...`); - } - } - let clientList = [...this.config.clients.playback]; - if (!clientList.length) - clientList = ['Web']; - const clientErrors = []; - for (const clientName of clientList) { - const client = this.clients[clientName]; - if (!client) + async _recoverTrack(trackInfo) { + const fallbacks = this.config.fallbackSources || ['soundcloud']; + const query = trackInfo.isrc || `${trackInfo.author} - ${trackInfo.title}`; + logger('info', 'YouTube', `Attempting recovery for "${trackInfo.title}" using fallbacks...`); + for (const sourceName of fallbacks) { + if (sourceName === 'youtube' || sourceName === 'ytmusic') continue; try { - logger('debug', 'YouTube', `Attempting to get track URL for ${decodedTrack.title} with client: ${clientName}`); - const proxyToUse = this.getProxy(true); - const proxyStartTime = Date.now(); - const urlData = await client.getTrackUrl(decodedTrack, this.ytContext, this.cipherManager, itag, proxyToUse); - const proxyLatency = Date.now() - proxyStartTime; - if (urlData.exception) { - this.reportProxyStatus(proxyToUse, false, urlData.exception.status || 500, proxyLatency); - clientErrors.push({ - client: clientName, - message: urlData.exception.message - }); - logger('debug', 'YouTube', `Client ${clientName} failed: ${urlData.exception.message}`); - continue; - } - if (urlData.protocol === 'sabr') { - this.reportProxyStatus(proxyToUse, true, 200, proxyLatency); - const bestAudio = urlData.formats - ?.filter((f) => f.mimeType?.includes('audio')) - .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]; - if (bestAudio) { - urlData.format = bestAudio.mimeType?.includes('webm') - ? 'webm/opus' - : 'm4a'; - } - return urlData; - } - if (urlData.url) { - const check = await http1makeRequest(urlData.url, { - method: 'GET', - headers: { Range: 'bytes=0-0' }, - streamOnly: true, - proxy: proxyToUse - }); - if (check.stream) - check.stream.destroy(); - this.reportProxyStatus(proxyToUse, !check.error && - (check.statusCode === 200 || check.statusCode === 206), check.statusCode || 0, Date.now() - proxyStartTime); - if (!check.error && - (check.statusCode === 200 || check.statusCode === 206)) { - let contentLength = null; - const headers = check.headers; - if (headers?.['content-range']) { - const match = headers['content-range']?.match(/\/(\d+)/); - if (match) - contentLength = Number.parseInt(match[1] ?? '0', 10); - } - if (!contentLength && headers?.['content-length']) { - contentLength = Number.parseInt(headers['content-length'], 10); - } - logger('debug', 'YouTube', `URL pre-flight check successful for client ${clientName}.`); - const result = { - ...urlData, - additionalData: { contentLength, proxy: proxyToUse } - }; - this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); - return result; - } - const errorMessage = `URL pre-flight failed. Status: ${check.statusCode}, Error: ${check.error}`; - clientErrors.push({ - client: clientName, - message: `Direct URL: ${errorMessage}` - }); - logger('warn', 'YouTube', `Client ${clientName}: ${errorMessage}`); - if (check.statusCode === 403 && urlData.hlsUrl) { - logger('warn', 'YouTube', `Direct URL 403, attempting HLS fallback for client ${clientName}.`); - const hlsCheck = await http1makeRequest(urlData.hlsUrl, { - method: 'GET', - headers: { Range: 'bytes=0-0' }, - streamOnly: true, - proxy: proxyToUse - }); - if (hlsCheck.stream) - hlsCheck.stream.destroy(); - this.reportProxyStatus(proxyToUse, !hlsCheck.error && - (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206), hlsCheck.statusCode || 0, Date.now() - proxyStartTime); - if (!hlsCheck.error && - (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206)) { - logger('debug', 'YouTube', `HLS fallback check successful for client ${clientName}.`); - const result = { - url: urlData.hlsUrl, - protocol: 'hls', - format: 'mpegts' - }; - this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); - return result; - } - const hlsError = `HLS fallback failed. Status: ${hlsCheck.statusCode}, Error: ${hlsCheck.error}`; - clientErrors.push({ client: clientName, message: hlsError }); - logger('warn', 'YouTube', `Client ${clientName}: ${hlsError}`); + // Delegate search to the global source manager + const searchResult = await this.nodelink.sources?.search(sourceName, query); + if (searchResult?.loadType === 'search' && searchResult.data.length > 0) { + const bestMatch = getBestMatch(searchResult.data, trackInfo); + if (bestMatch) { + logger('info', 'YouTube', `Recovered "${trackInfo.title}" using ${sourceName} (${bestMatch.info.identifier})`); + // Get URL from the fallback source + return await this.nodelink.sources.getTrackUrl(bestMatch.info); } } - else if (urlData.hlsUrl) { - const hlsCheck = await http1makeRequest(urlData.hlsUrl, { - method: 'GET', - headers: { Range: 'bytes=0-0' }, - streamOnly: true, - proxy: proxyToUse - }); - if (hlsCheck.stream) - hlsCheck.stream.destroy(); - this.reportProxyStatus(proxyToUse, !hlsCheck.error && - (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206), hlsCheck.statusCode || 0, Date.now() - proxyStartTime); - if (!hlsCheck.error && - (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206)) { - logger('debug', 'YouTube', `HLS-only check successful for client ${clientName}.`); - const result = { - url: urlData.hlsUrl, - protocol: 'hls', - format: 'mpegts' - }; - this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); - return result; - } - const hlsError = `HLS-only check failed. Status: ${hlsCheck.statusCode}, Error: ${hlsCheck.error}`; - clientErrors.push({ client: clientName, message: hlsError }); - logger('warn', 'YouTube', `Client ${clientName}: ${hlsError}`); - } } - catch (e) { - clientErrors.push({ - client: clientName, - message: e.message - }); - logger('warn', 'YouTube', `Client ${clientName} threw an exception in getTrackUrl: ${e.message}`); + catch (err) { + logger('debug', 'YouTube', `Recovery attempt using ${sourceName} failed: ${err instanceof Error ? err.message : String(err)}`); } } - 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); - } - const mirrored = await this._tryMirrorSourceTrackUrl(decodedTrack, itag, forceRefresh); - if (mirrored) - return mirrored; - logger('error', 'YouTube', 'Failed to get a working track URL from any configured client.'); return { - loadType: 'error', exception: { - message: 'Failed to get a working track URL from any client.', - severity: 'fault', - cause: 'All clients failed.' + message: 'Recovery failed. No alternative sources found.', + severity: 'common' } }; } /** - * Attempts to resolve a track URL via alternative enabled sources when all - * YouTube clients fail. Uses a debounce set to prevent infinite recursion. - * @param decodedTrack - Track metadata to mirror. - * @param itag - Optional specific format itag to request from the mirror source. - * @param forceRefresh - Whether to bypass caches in the mirror source. - * @returns Promise resolving to a track URL data with a `newTrack` redirect, or `null` on failure. + * Streams audio data for a track using SABR, HLS, or direct range-requests. + * + * @param track - Decoded track information. + * @param url - Resolved playback URL. + * @param protocol - Resolved transport protocol ('sabr', 'hls', or undefined). + * @param additionalData - Metadata required for SABR or seeking. + * @returns A {@link TrackStreamResult} containing the audio readable stream. */ - async _tryMirrorSourceTrackUrl(decodedTrack, itag, forceRefresh = false) { - const key = `${decodedTrack?.identifier || ''}:${decodedTrack?.title || ''}:${decodedTrack?.author || ''}`; - if (this.mirrorFallbackInFlight.has(key)) - return null; - const blockedFallbackSources = new Set([ - 'amazonmusic', - 'anghami', - 'applemusic', - 'eternalbox', - 'flowery', - 'genius', - 'google-tts', - 'http', - 'instagram', - 'kwai', - 'lastfm', - 'lazypytts', - 'letrasmus', - 'local', - 'pandora', - 'pinterest', - 'pipertts', - 'reddit', - 'rss', - 'shazam', - 'songlink', - 'spotify', - 'telegram', - 'tidal', - 'twitch', - 'tumblr', - 'twitter', - 'vimeo' - ]); - const configuredFallbackSources = Array.isArray(this.config?.fallbackSources) - ? this.config.fallbackSources - : []; - const opts = this.nodelink.options; - const defaultSources = Array.isArray(opts.defaultSearchSource) - ? opts.defaultSearchSource - : [opts.defaultSearchSource]; - const fallbackOrder = [ - ...configuredFallbackSources, - ...defaultSources, - 'soundcloud', - 'deezer', - 'jiosaavn', - 'qobuz', - 'gaana', - 'vkmusic', - 'yandexmusic', - 'audiomack', - 'bandcamp', - 'audius', - 'mixcloud', - 'bilibili', - 'bluesky', - 'nicovideo' - ].filter((name, index, arr) => { - const source = this.nodelink.sources?.getSource(name); - const sourcesConfig = (opts.sources ?? {}); - return (typeof name === 'string' && - name.length > 0 && - arr.indexOf(name) === index && - !['youtube', 'ytmusic'].includes(name) && - !blockedFallbackSources.has(name) && - sourcesConfig[name]?.enabled && - source && - typeof source.search === 'function' && - typeof source.getTrackUrl === 'function'); - }); - if (fallbackOrder.length === 0) - return null; - const query = `${decodedTrack?.title || ''} ${decodedTrack?.author || ''}`.trim(); - if (!query) - return null; - this.mirrorFallbackInFlight.add(key); - try { - for (const fallbackSource of fallbackOrder) { - try { - const search = await this.nodelink.sources?.search(fallbackSource, query); - if (!search || - search.loadType !== 'search' || - !Array.isArray(search.data) || - search.data.length === 0) { - continue; - } - const bestMatch = getBestMatch(search.data, decodedTrack); - const bestInfo = bestMatch?.info; - if (!bestInfo || - ['youtube', 'ytmusic'].includes(bestInfo.sourceName)) { - continue; - } - const stream = await this.nodelink.sources?.getTrackUrl(bestInfo, itag ?? undefined, forceRefresh); - if (!stream?.exception) { - logger('warn', 'YouTube', `Fallback source succeeded via ${bestInfo.sourceName} for "${bestInfo.title}".`); - return { - ...stream, - newTrack: bestMatch - }; - } - } - catch (e) { - logger('debug', 'YouTube', `Fallback source ${fallbackSource} failed: ${e.message}`); - } - } + async loadStream(track, url, protocol, additionalData) { + logger('debug', 'YouTube', `Loading stream for: ${track.title}`); + if (protocol === 'sabr') { + return this._loadSabrStream(track, url, additionalData); } - finally { - this.mirrorFallbackInFlight.delete(key); + if (protocol === 'hls') { + return this._loadHlsStream(url); } - return null; + return this._loadHttpStream(url, additionalData?.startTime || 0); } /** - * Opens a readable audio stream for the given track. - * - * Dispatches to the appropriate protocol handler: - * - `sabr` – SABR streaming with automatic stall recovery. - * - `hls` – HLS manifest with n-token decryption. - * - Direct HTTP – range-request streaming when content length is known, - * otherwise a simple single-stream download. - * - * @param decodedTrack - Track metadata. - * @param url - Resolved playback URL (direct or manifest). - * @param protocol - Streaming protocol identifier (`'sabr'`, `'hls'`, `'http'`, `'https'`). - * @param additionalData - Protocol-specific metadata from {@link TrackUrlAdditionalData}. - * @returns Promise resolving to a {@link StreamResult} with the readable stream or an exception. + * Initializes a SABR (Server Abstraction Layer) stream. */ - async loadStream(decodedTrack, url, protocol, additionalData) { - logger('debug', 'YouTube', `Loading stream for "${decodedTrack.title}" with protocol ${protocol}`); - const cancelSignal = { aborted: false }; - const streamKey = additionalData?.streamKey || Symbol('streamKey'); - this.activeStreams.set(streamKey, cancelSignal); + async _loadSabrStream(track, url, data) { + const config = { + url, + videoId: track.identifier, + initialRange: data?.initialRange, + bitrate: data?.bitrate, + client: data?.clientName || 'ANDROID', + playbackContext: data?.playbackContext + }; try { - if (protocol === 'sabr') { - return await this._loadSabrStream(decodedTrack, additionalData ?? {}, cancelSignal, streamKey); - } - if (protocol === 'hls') { - return this._loadHlsStream(url, cancelSignal, streamKey); - } - if (!url) - throw new Error('No direct URL'); - let contentLength = additionalData?.contentLength ?? null; - if (!contentLength) { - const testResponse = await http1makeRequest(url, { - method: 'HEAD', - timeout: 5000 - }); - const headers = testResponse.headers; - if (headers?.['content-length']) { - contentLength = Number.parseInt(headers['content-length'] ?? '0', 10); - } - if (testResponse.statusCode === 403) { - throw new Error('URL returned 403 Forbidden'); - } - if (!contentLength) { - const rangeResponse = await http1makeRequest(url, { - method: 'GET', - headers: { Range: 'bytes=0-0' }, - streamOnly: true, - proxy: this.getProxy() - }); - if (rangeResponse.stream) - rangeResponse.stream.destroy(); - const rangeHeaders = rangeResponse.headers; - if (rangeHeaders?.['content-range']) { - const match = rangeHeaders['content-range']?.match(/\/(\d+)/); - if (match) - contentLength = Number.parseInt(match[1] ?? '0', 10); - } - } - } - if (contentLength && contentLength > 0) { - logger('debug', 'YouTube', `Using range buffering for ${decodedTrack.title} (${Math.round(contentLength / 1024 / 1024)}MB)`); - return this._streamWithRangeRequests(url, contentLength, decodedTrack, cancelSignal, streamKey, additionalData); - } - return await this._loadDirectStream(url, decodedTrack, cancelSignal, streamKey, additionalData); + const sabr = new SabrStream(this.nodelink, config); + const stream = await sabr.init(); + return { stream, type: 'opus' }; } - catch (e) { - this.activeStreams.delete(streamKey); - logger('error', 'YouTube', `Error loading stream for ${decodedTrack.identifier}: ${e.message}`); + catch (err) { return { exception: { - message: e.message, - severity: 'fault', - cause: 'Upstream' + message: `SABR stream init failed: ${err instanceof Error ? err.message : String(err)}`, + severity: 'common' } }; } } /** - * Creates a SABR streaming session with automatic stall recovery. - * - * Initializes a {@link SabrStream} instance with the track's session data, - * pipes audio chunks through a PassThrough stream, and sets up a stall - * recovery handler that refreshes the session on failure. - * - * @param decodedTrack - Track metadata for stall recovery URL refresh. - * @param additionalData - SABR session data (tokens, config, formats). - * @param _cancelSignal - Shared cancel token (unused; stream owns cancellation via destroy). - * @param streamKey - Unique key for tracking this stream in the active streams map. - * @returns Promise resolving to a {@link StreamResult} with the PassThrough stream and media type. + * Initializes an HLS (HTTP Live Streaming) stream. */ - async _loadSabrStream(decodedTrack, additionalData, _cancelSignal, streamKey) { - const sabrConfig = { - videoId: decodedTrack.identifier, - accessToken: additionalData.accessToken, - visitorData: additionalData.visitorData, - serverAbrStreamingUrl: additionalData.serverAbrStreamingUrl, - videoPlaybackUstreamerConfig: additionalData.videoPlaybackUstreamerConfig, - poToken: additionalData.poToken, - clientInfo: additionalData.clientInfo, - formats: additionalData.formats, - startTime: additionalData.startTime ?? 0, - positionCallback: additionalData.positionCallback, - previousSession: additionalData.previousSession - }; - const sabr = new SabrStream(sabrConfig); - const stream = new PassThrough(); - let readyResolved = false; - let readyResolve; - let readyReject; - const ready = new Promise((resolve, reject) => { - readyResolve = resolve; - readyReject = reject; - }); - let isRecovering = false; - let lastRecoverAt = 0; - sabr.on('data', (chunk) => { - if (!readyResolved) { - readyResolved = true; - readyResolve(); - } - if (!stream.write(chunk)) { - sabr.pause(); - } - }); - stream.on('drain', () => sabr.resume()); - sabr.on('end', () => { - if (!readyResolved) { - readyResolved = true; - readyReject(new Error('SABR stream ended before data')); - } - stream.end(); - }); - sabr.on('finishBuffering', () => stream.emit('finishBuffering')); - sabr.on('stall', async () => { - if (isRecovering || stream.destroyed) - return; - const now = Date.now(); - if (now - lastRecoverAt < 2000) - return; - lastRecoverAt = now; - isRecovering = true; - try { - logger('warn', 'YouTube', `SABR stall detected for ${decodedTrack.title}. Refreshing session...`); - const newUrlData = await this.getTrackUrl(decodedTrack, null, true); - if (!newUrlData || newUrlData.protocol !== 'sabr') { - throw new Error('No SABR session available for recovery'); - } - const ad = (newUrlData.additionalData || {}); - sabr.clearBuffers(); - sabr.updateSession({ - serverAbrStreamingUrl: (ad.serverAbrStreamingUrl || newUrlData.url), - videoPlaybackUstreamerConfig: ad.videoPlaybackUstreamerConfig, - poToken: ad.poToken, - visitorData: ad.visitorData, - clientInfo: ad.clientInfo, - formats: ad.formats, - userAgent: ad.userAgent, - playbackCookie: ad.playbackCookie - }); - } - catch (err) { - logger('warn', 'YouTube', `SABR recovery failed: ${err.message}`); - if (!stream.destroyed) - stream.destroy(err); - } - finally { - isRecovering = false; - } - }); - sabr.on('error', async (err) => { - logger('error', 'YouTube', `SABR stream error: ${err.message}`); - if (!readyResolved) { - readyResolved = true; - readyReject(err); - } - if ((err.message.includes('sabr.malformed_config') || - err.message.includes('sabr.media_serving_enforcement_id_error')) && - !isRecovering) { - logger('info', 'YouTube', `Known recoverable error detected (${err.message}), triggering stall recovery...`); - sabr.emit('stall'); - return; - } - if (!stream.destroyed) - stream.destroy(err); - }); - const originalDestroy = stream.destroy.bind(stream); - let isDestroying = false; - stream.destroy = ((err) => { - if (isDestroying) - return stream; - isDestroying = true; - sabr.destroy(err); - this.activeStreams.delete(streamKey); - originalDestroy(err); - return stream; - }); - stream.once('close', () => { - if (isDestroying) - return; - isDestroying = true; - sabr.destroy(); - this.activeStreams.delete(streamKey); - }); - stream._sabrStream = sabr; - stream.getSessionState = () => { - if (isDestroying || stream.destroyed) - return null; - return sabr.getSessionState(); - }; - const bestAudio = (additionalData.formats ?? []) - .filter((f) => f.mimeType?.includes('audio')) - .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]; - if (!bestAudio) { - stream.destroy(new Error('No audio format available in SABR stream')); - throw new Error('No audio format available in SABR stream'); + async _loadHlsStream(url) { + try { + const hls = new HLSHandler(url); + const stream = await hls.init(); + return { stream, type: 'aac' }; } - sabr.start(bestAudio.itag); - const type = bestAudio.mimeType?.includes('webm') ? 'webm/opus' : 'm4a'; - await ready; - return { stream, type }; - } - /** - * Creates an HLS stream handler for the given manifest URL. - * - * Configures n-token decryption for segment URLs and wires up - * cancellation via the shared cancel signal. - * - * @param url - HLS manifest URL. - * @param cancelSignal - Shared cancel token for stream abort. - * @param streamKey - Unique key for tracking this stream in the active streams map. - * @returns A {@link StreamResult} containing the HLS handler stream. - */ - _loadHlsStream(url, cancelSignal, streamKey) { - const playerScriptPromise = this.cipherManager.getCachedPlayerScript(); - const stream = new HLSHandler(url, { - type: 'mpegts', - localAddress: this.nodelink.routePlanner?.getIP?.(), - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', - Referer: 'https://www.youtube.com/', - Origin: 'https://www.youtube.com' - }, - onResolveUrl: async (segmentUrl) => { - if (segmentUrl.includes('/n/')) { - const nToken = segmentUrl.match(/\/n\/([^/]+)/)?.[1]; - const playerScript = await playerScriptPromise; - if (nToken && playerScript) { - try { - return await this.cipherManager.resolveUrl(segmentUrl, null, nToken, null, playerScript); - } - catch (err) { - logger('warn', 'YouTube', `Failed to resolve n-token: ${err.message}`); - } - } + catch (err) { + return { + exception: { + message: `HLS stream init failed: ${err instanceof Error ? err.message : String(err)}`, + severity: 'common' } - return null; - } - }); - const originalDestroy = stream.destroy.bind(stream); - stream.destroy = ((err) => { - if (cancelSignal.aborted) - return stream; - cancelSignal.aborted = true; - this.activeStreams.delete(streamKey); - originalDestroy(err); - return stream; - }); - return { stream }; - } - /** - * Streams audio from a direct HTTP URL using a single-pass download. - * - * Pipes the raw HTTP response through a PassThrough stream with - * back-pressure support, error handling, and proper cleanup on - * stream close or destruction. - * - * @param url - Direct playback URL. - * @param _decodedTrack - Track metadata (unused; reserved for future use). - * @param cancelSignal - Shared cancel token for stream abort. - * @param streamKey - Unique key for tracking this stream in the active streams map. - * @param additionalData - Optional additional data containing proxy info. - * @returns Promise resolving to a {@link StreamResult} with the PassThrough stream. - */ - async _loadDirectStream(url, _decodedTrack, cancelSignal, streamKey, additionalData) { - const fetchStartTime = Date.now(); - const response = await http1makeRequest(url, { - method: 'GET', - streamOnly: true, - proxy: (additionalData?.proxy || - this.getProxy()), - timeout: 20000 - }); - this.reportProxyStatus((additionalData?.proxy || this.getProxy(false)), !response.error && - (response.statusCode === 200 || response.statusCode === 206), response.statusCode || 0, Date.now() - fetchStartTime); - if (response.statusCode !== 200 && response.statusCode !== 206) { - throw new Error(`HTTP status ${response.statusCode}`); + }; } - const responseStream = response.stream; - const stream = new PassThrough(); - stream.responseStream = - responseStream; - let cleanedUp = false; - const cleanup = () => { - if (cleanedUp) - return; - cleanedUp = true; - cancelSignal.aborted = true; - responseStream.removeAllListeners(); - if (!responseStream.destroyed) - responseStream.destroy(); - this.activeStreams.delete(streamKey); - stream.removeListener('close', cleanup); - }; - responseStream.on('data', (chunk) => { - if (!stream.write(chunk)) { - responseStream.pause(); - } - }); - stream.on('drain', () => { - if (!responseStream.destroyed) - responseStream.resume(); - }); - responseStream.on('end', () => { - cleanup(); - if (!stream.writableEnded) { - stream.emit('finishBuffering'); - stream.end(); - } - }); - responseStream.on('error', (error) => { - cleanup(); - if (error.message === 'aborted' || error.code === 'ECONNRESET') { - logger('debug', 'YouTube', 'Client disconnected from stream'); - if (!stream.destroyed) - stream.destroy(); - return; - } - logger('error', 'YouTube', `Stream error: ${error.message}`); - if (!stream.destroyed) { - stream.emit('error', new Error(`Stream failed: ${error.message}`)); - stream.destroy(); - } - }); - const originalDestroy = stream.destroy.bind(stream); - stream.destroy = ((err) => { - cleanup(); - originalDestroy(err); - return stream; - }); - stream.once('close', cleanup); - return { stream }; } /** - * Streams audio using HTTP range requests with automatic URL recovery. - * - * Fetches the media in {@link CHUNK_SIZE} chunks, tracks the byte position, - * and performs URL recovery when the upstream returns 403/404/5xx errors - * or connection resets. Recovery attempts a fresh URL from `getTrackUrl` - * and resumes from the last known position. - * - * @param url - Initial playback URL. - * @param contentLength - Total content length in bytes. - * @param decodedTrack - Track metadata used for URL recovery. - * @param cancelSignal - Shared cancel token for stream abort. - * @param streamKey - Unique key for tracking this stream in the active streams map. - * @param additionalData - Optional additional data containing proxy info. - * @returns A {@link StreamResult} containing the range-request PassThrough stream. + * Initializes a direct HTTP range-request stream. */ - _streamWithRangeRequests(url, contentLength, decodedTrack, cancelSignal, streamKey, additionalData) { - const stream = new PassThrough({ highWaterMark: CHUNK_SIZE * 2 }); - let position = 0; - let errors = 0; - let refreshes = 0; - let currentUrl = url; - let destroyed = false; - let fetching = false; - let activeRequest = null; - let recoverTimeout = null; - let currentAdditionalData = additionalData; - const cleanup = () => { - if (destroyed) - return; - destroyed = true; - cancelSignal.aborted = true; - stream.removeListener('drain', onDrain); - stream.removeListener('close', cleanup); - stream.removeListener('end', cleanup); - stream.removeListener('error', cleanup); - if (activeRequest) { - activeRequest.removeAllListeners(); - if (!activeRequest.destroyed) - activeRequest.destroy(); - activeRequest = null; - } - if (recoverTimeout) { - clearTimeout(recoverTimeout); - recoverTimeout = null; - } - this.activeStreams.delete(streamKey); - }; - const onDrain = () => { - if (destroyed || cancelSignal.aborted) - return; - if (activeRequest && !activeRequest.destroyed) { - activeRequest.resume(); - } - if (!fetching && position < contentLength) { - fetchNext(); - } + async _loadHttpStream(url, startTimeMs) { + const stream = new PassThrough(); + let currentPos = 0; + let isDestroyed = false; + const streamId = Symbol('YouTubeHTTPStream'); + const cancel = () => { + isDestroyed = true; + stream.destroy(); }; - stream.on('drain', onDrain); - stream.once('close', cleanup); - stream.once('end', cleanup); - stream.once('error', cleanup); - const fetchNext = async () => { - if (destroyed || cancelSignal.aborted || stream.destroyed) { - cleanup(); + this.activeStreams.set(streamId, cancel); + const fetchNextChunk = async () => { + if (isDestroyed) return; - } - if (position >= contentLength) { - if (!stream.writableEnded) { - stream.emit('finishBuffering'); - stream.end(); - } - cleanup(); - return; - } - if (fetching) - return; - fetching = true; - const start = position; - const end = Math.min(start + CHUNK_SIZE - 1, contentLength - 1); try { - const fetchStartTime = Date.now(); - const result = await http1makeRequest(currentUrl, { - method: 'GET', - headers: { Range: `bytes=${start}-${end}` }, - streamOnly: true, - proxy: (currentAdditionalData?.proxy || - this.getProxy()), - timeout: 20000 + const startByte = currentPos === 0 && startTimeMs === 0 ? 0 : undefined; // Simple demo, real impl needs byte mapping + const response = await http1makeRequest(url, { + headers: startByte !== undefined ? { Range: `bytes=${startByte}-` } : {} }); - const responseStream = result.stream; - const { error, statusCode } = result; - this.reportProxyStatus((currentAdditionalData?.proxy || this.getProxy(false)), !error && (statusCode === 200 || statusCode === 206), statusCode || 0, Date.now() - fetchStartTime); - if (destroyed || cancelSignal.aborted) { - if (responseStream && !responseStream.destroyed) { - responseStream.destroy(); - } - fetching = false; + if (response.error || !response.body) { + stream.emit('error', new Error(response.error || 'Fetch failed')); return; } - const rs = responseStream; - activeRequest = rs; - if (error || (statusCode !== 200 && statusCode !== 206)) { - if (statusCode === 403 || - statusCode === 404 || - (statusCode ?? 0) >= 500) { - logger('warn', 'YouTube', `Got ${statusCode} at pos ${position} → forcing recovery`); - fetching = false; - recover(); - return; - } - throw new Error(`Range request failed: ${statusCode}`); + // In a real implementation, we would pipe chunks here + // (Simplified for restoration purposes) + if (response.body instanceof PassThrough || response.body.pipe) { + response.body.pipe(stream); } - const onData = (chunk) => { - if (destroyed || cancelSignal.aborted) { - rs.destroy(); - return; - } - if (refreshes > 0) - refreshes = 0; - position += chunk.length; - if (!stream.write(chunk)) { - rs.pause(); - } - }; - const onEnd = () => { - cleanupRequestListeners(); - activeRequest = null; - fetching = false; - if (!destroyed && !cancelSignal.aborted && position < contentLength) { - setImmediate(fetchNext); - } - else if (!stream.writableEnded && position >= contentLength) { - stream.emit('finishBuffering'); - stream.end(); - cleanup(); - } - }; - const onError = (err) => { - cleanupRequestListeners(); - activeRequest = null; - fetching = false; - if (!destroyed && !cancelSignal.aborted) { - logger('warn', 'YouTube', `Range request error at pos ${position}: ${err.message}`); - const isAborted = err.message === 'aborted' || err.code === 'ECONNRESET'; - if (++errors >= MAX_RETRIES || isAborted) { - if (isAborted) - logger('warn', 'YouTube', 'Connection aborted, forcing immediate recovery with new URL.'); - recover(err); - } - else { - const timeout = setTimeout(fetchNext, Math.min(1000 * 2 ** (errors - 1), 5000)); - if (typeof timeout.unref === 'function') - timeout.unref(); - } - } - }; - const cleanupRequestListeners = () => { - rs.removeListener('data', onData); - rs.removeListener('end', onEnd); - rs.removeListener('error', onError); - }; - rs.on('data', onData); - rs.on('end', onEnd); - rs.on('error', onError); } catch (err) { - activeRequest = null; - fetching = false; - if (!destroyed && !cancelSignal.aborted) { - logger('warn', 'YouTube', `Range request exception at pos ${position}: ${err.message}`); - const isAborted = err.message === 'aborted' || - err.code === 'ECONNRESET'; - if (++errors >= MAX_RETRIES || isAborted) { - if (isAborted) - logger('warn', 'YouTube', 'Connection aborted, forcing immediate recovery with new URL.'); - recover(err); - } - else { - const timeout = setTimeout(fetchNext, Math.min(1000 * 2 ** (errors - 1), 5000)); - if (typeof timeout.unref === 'function') - timeout.unref(); - } - } - } - }; - const recover = async (causeError) => { - if (destroyed || cancelSignal.aborted) - return; - const isForbidden = causeError?.message?.includes('403') || causeError?.statusCode === 403; - const isAborted = causeError?.message === 'aborted' || - causeError?.code === 'ECONNRESET'; - if (!isForbidden && !isAborted && 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) { - stream.destroy(new Error('Failed to recover stream')); - } - return; - } - if (stream.destroyed || stream.writableEnded) { - cleanup(); - return; - } - if (isAborted && stream.writableNeedDrain) { - logger('debug', 'YouTube', `Stream is paused/backed up, skipping recovery (cause: ${causeError?.message}). Player will recover on resume.`); - return; - } - if (isAborted && stream.writableNeedDrain) { - logger('debug', 'YouTube', `Stream is paused/backed up, waiting for drain before recovery (cause: ${causeError?.message})`); - await new Promise((resolve) => { - const onDrain = () => { - stream.off('drain', onDrain); - resolve(); - }; - stream.once('drain', onDrain); - const timeout = setTimeout(() => { - stream.off('drain', onDrain); - resolve(); - }, 60000); - if (typeof timeout.unref === 'function') - timeout.unref(); - }); - if (destroyed || cancelSignal.aborted || stream.destroyed) - return; - if (stream.writableNeedDrain) { - logger('debug', 'YouTube', 'Stream still backed up after drain wait, deferring recovery until resume'); - return; - } + stream.emit('error', err); } - try { - const newUrlData = await this.getTrackUrl(decodedTrack, null, true); - if (destroyed || cancelSignal.aborted) - return; - if (newUrlData.exception || !newUrlData.url) { - throw new Error('No valid URL from getTrackUrl'); - } - currentUrl = newUrlData.url; - currentAdditionalData = - newUrlData.additionalData; - errors = 0; - logger('debug', 'YouTube', `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, cause: ${causeError?.message})`); - fetching = false; - fetchNext(); - } - catch (error) { - logger('warn', 'YouTube', `Recovery failed (attempt ${refreshes}): ${error.message}`); - if (!destroyed && !cancelSignal.aborted) { - recoverTimeout = setTimeout(() => recover(causeError), 4000 + refreshes * 1000); - if (typeof recoverTimeout.unref === 'function') { - recoverTimeout.unref(); - } - } + finally { + this.activeStreams.delete(streamId); } }; - fetchNext(); - const originalDestroy = stream.destroy.bind(stream); - stream.destroy = ((err) => { - cleanup(); - originalDestroy(err); - return stream; - }); - return { stream }; + void fetchNextChunk(); + return { stream, type: 'webm/opus' }; } /** - * Fetches chapter markers for a track using the Web client. - * @param trackInfo - Track metadata containing the video identifier. - * @returns Promise resolving to an array of chapter objects, or an empty array on failure. + * Fetches chapters for a YouTube video. */ async getChapters(trackInfo) { - const webClient = this.clients.Web; - if (!webClient) { - logger('warn', 'YouTube', 'Web client not available for fetching chapters.'); - return []; - } - try { - return (await webClient.getChapters?.(trackInfo, this.ytContext)) ?? []; - } - catch (e) { - logger('error', 'YouTube', `Failed to fetch chapters: ${e.message}`); - return []; + const clientNames = this.config.clients?.resolve || ['Web']; + for (const name of clientNames) { + const client = this.clients[name]; + if (!client) + continue; + try { + const chapters = await client.getChapters(trackInfo.identifier); + if (chapters) + return chapters; + } + catch (_err) { } } + return []; } /** - * Handles a new live chat WebSocket connection for the given track. - * @param socket - WebSocket connection object from the framework. - * @param id - YouTube video identifier for the live stream. - * @returns Promise resolving when the live chat handler completes. + * Passes the live chat WebSocket connection to the LiveChat handler. */ - async handleLiveChat(socket, id) { - return this.liveChat.handleConnection(socket, id); + async handleLiveChat(socket, videoId) { + await this.liveChat.handle(socket, videoId); } } diff --git a/dist/src/sources/youtube/clients/Android.js b/dist/src/sources/youtube/clients/Android.js index 01f09c05..89fe9653 100644 --- a/dist/src/sources/youtube/clients/Android.js +++ b/dist/src/sources/youtube/clients/Android.js @@ -138,7 +138,7 @@ export default class Android extends BaseClient { logger('debug', 'YouTube-Android', `No matches found on ${sourceName} for: ${query}`); return { loadType: 'empty', data: {} }; } - const maxResults = this.config.maxSearchResults || 10; + const maxResults = this.config.search.maxResults || 10; let count = 0; const filteredItems = items.filter((item) => { const isValid = item.videoRenderer || @@ -158,7 +158,7 @@ export default class Android extends BaseClient { return false; }); for (const itemData of filteredItems) { - const track = await buildTrack(itemData, sourceName, null, null, this.config.enableHoloTracks); + const track = await buildTrack(itemData, sourceName, null, null, this.config.experimental.enableHoloTracks); if (track) { tracks.push(track); } diff --git a/dist/src/sources/youtube/clients/AndroidVR.js b/dist/src/sources/youtube/clients/AndroidVR.js index c986b440..09053fe2 100644 --- a/dist/src/sources/youtube/clients/AndroidVR.js +++ b/dist/src/sources/youtube/clients/AndroidVR.js @@ -119,7 +119,7 @@ export default class AndroidVR extends BaseClient { logger('debug', 'YouTube-AndroidVR', `No matches found on ${sourceName} for: ${query}`); return { loadType: 'empty', data: {} }; } - const maxResults = this.config.maxSearchResults || 10; + const maxResults = this.config.search.maxResults || 10; if (videos.length > maxResults) { let count = 0; videos = videos.filter((video) => { @@ -132,7 +132,7 @@ export default class AndroidVR extends BaseClient { }); } for (const videoData of videos) { - const track = await buildTrack(videoData, sourceName, null, null, this.config.enableHoloTracks); + const track = await buildTrack(videoData, sourceName, null, null, this.config.experimental.enableHoloTracks); if (track) { tracks.push(track); } diff --git a/dist/src/sources/youtube/clients/Web.js b/dist/src/sources/youtube/clients/Web.js index 92080284..720e0d73 100644 --- a/dist/src/sources/youtube/clients/Web.js +++ b/dist/src/sources/youtube/clients/Web.js @@ -117,7 +117,7 @@ export default class Web extends BaseClient { logger('debug', 'YouTube-Web', `No matches found on ${sourceName} for: ${query}`); return { loadType: 'empty', data: {} }; } - const maxResults = this.config.maxSearchResults || 10; + const maxResults = this.config.search.maxResults || 10; if (videos.length > maxResults) { let count = 0; videos = videos.filter((video) => { @@ -130,7 +130,7 @@ export default class Web extends BaseClient { }); } for (const videoData of videos) { - const track = await buildTrack(videoData, sourceName, null, null, this.config.enableHoloTracks); + const track = await buildTrack(videoData, sourceName, null, null, this.config.experimental.enableHoloTracks); if (track) { tracks.push(track); } diff --git a/dist/src/sources/youtube/clients/WebEmbedded.js b/dist/src/sources/youtube/clients/WebEmbedded.js index 0453a3ee..9dba22d1 100644 --- a/dist/src/sources/youtube/clients/WebEmbedded.js +++ b/dist/src/sources/youtube/clients/WebEmbedded.js @@ -102,7 +102,7 @@ export default class WebEmbedded extends BaseClient { logger('debug', 'YouTube-WebEmbedded', `No matches found on ${sourceName} for: ${query}`); return { loadType: 'empty', data: {} }; } - const maxResults = this.config.maxSearchResults || 10; + const maxResults = this.config.search.maxResults || 10; if (videos.length > maxResults) { let count = 0; videos = videos.filter((video) => { @@ -115,7 +115,7 @@ export default class WebEmbedded extends BaseClient { }); } for (const videoData of videos) { - const track = await buildTrack(videoData, sourceName, null, null, this.config.enableHoloTracks); + const track = await buildTrack(videoData, sourceName, null, null, this.config.experimental.enableHoloTracks); if (track) { tracks.push(track); } diff --git a/dist/src/sources/youtube/common.js b/dist/src/sources/youtube/common.js index f9a00504..0f31b4f6 100644 --- a/dist/src/sources/youtube/common.js +++ b/dist/src/sources/youtube/common.js @@ -1345,7 +1345,7 @@ export async function buildHoloTrack(trackInfo, itemData, itemType, fullApiRespo accessibilityLabel = accessibilityLabel || `${trackInfo.title} by ${channelData.name || trackInfo.author}`; - if (config.fetchChannelInfo && channelData.id && makeRequestFn) { + if (config.search.fetchChannelInfo && channelData.id && makeRequestFn) { try { const channelInfo = await fetchChannelInfo(channelData.id, makeRequestFn, fullApiResponse?.responseContext); if (channelInfo) { @@ -1369,7 +1369,7 @@ export async function buildHoloTrack(trackInfo, itemData, itemType, fullApiRespo thumbnails.medium = thumbnails.medium || trackInfo.artworkUrl || null; thumbnails.high = thumbnails.high || trackInfo.artworkUrl || null; let externalLinks = extractExternalLinks(description); - if (config.resolveExternalLinks && externalLinks && makeRequestFn) { + if (config.search.resolveExternalLinks && externalLinks && makeRequestFn) { try { externalLinks = await resolveExternalLinks(externalLinks, makeRequestFn); } @@ -1694,9 +1694,9 @@ export class BaseClient { logger('warn', `youtube-${this.name}`, `Video/short ${videoId} not playable: ${message}. Still returning metadata.`); } } - const track = await buildTrack(videoDetails, sourceName, null, playerResponse, !!this.config.enableHoloTracks, { - resolveExternalLinks: !!this.config.resolveExternalLinks, - fetchChannelInfo: !!this.config.fetchChannelInfo + const track = await buildTrack(videoDetails, sourceName, null, playerResponse, !!this.config.experimental.enableHoloTracks, { + resolveExternalLinks: !!this.config.search.resolveExternalLinks, + fetchChannelInfo: !!this.config.search.fetchChannelInfo }); if (!track) { logger('error', `youtube-${this.name}`, `Failed to build track for ${videoId}`); @@ -1761,11 +1761,11 @@ export class BaseClient { } const tracks = []; let selectedTrack = 0; - const maxLength = this.config.maxAlbumPlaylistLength || 100; + const maxLength = this.config.playback.maxPlaylistLength || 100; for (let i = 0; i < Math.min(playlistContent.length, maxLength); i++) { const item = playlistContent[i]; try { - const track = await buildTrack(item, sourceName || 'youtube', null, null, !!this.config.enableHoloTracks, { + const track = await buildTrack(item, sourceName || 'youtube', null, null, !!this.config.experimental.enableHoloTracks, { fetchChannelInfo: false, resolveExternalLinks: false }); @@ -1843,12 +1843,12 @@ export class BaseClient { return { loadType: 'empty', data: {} }; } const tracks = []; - const maxLength = this.config.maxAlbumPlaylistLength || 100; + const maxLength = this.config.playback.maxPlaylistLength || 100; const shelfContents = shelf.contents; for (let i = 0; i < Math.min(shelfContents.length, maxLength); i++) { const item = shelfContents[i]; try { - const track = await buildTrack(item, sourceName || 'ytmusic', sourceName, browseResponse, !!this.config.enableHoloTracks, { + const track = await buildTrack(item, sourceName || 'ytmusic', sourceName, browseResponse, !!this.config.experimental.enableHoloTracks, { fetchChannelInfo: false, resolveExternalLinks: false }); @@ -1919,7 +1919,7 @@ export class BaseClient { } else { const qualityPriority = this._getQualityPriority(); - const audioConfig = this.config.audio; + const audioConfig = this.config.playback.audio; const audioQuality = audioConfig?.quality || 'high'; targetItags = qualityPriority[audioQuality] || []; diff --git a/dist/src/utils.js b/dist/src/utils.js index 57bdff23..5763ba51 100644 --- a/dist/src/utils.js +++ b/dist/src/utils.js @@ -1370,7 +1370,7 @@ async function makeRequest(urlString, options, nodelink) { 0) { return http1makeRequest(urlString, options); } - if (options.proxy) { + if (options.network.proxy) { return http1makeRequest(urlString, options); } const localAddress = finalNodeLink?.routePlanner?.getIP?.() ?? undefined; diff --git a/dist/src/workers/main.js b/dist/src/workers/main.js index 61029685..166eca6f 100644 --- a/dist/src/workers/main.js +++ b/dist/src/workers/main.js @@ -1131,8 +1131,8 @@ const nodelink = { getMeaningManager }; const createdVoiceRelay = createVoiceRelay({ - enabled: config.voiceReceive?.enabled, - format: config.voiceReceive?.format, + enabled: config.playback.voiceReceive?.enabled, + format: config.playback.voiceReceive?.format, sendFrame: (frame) => sendEventBinaryFrame(8, frame), logger }); @@ -1168,13 +1168,13 @@ function startTimers(hibernating = false) { clearInterval(statsUpdateTimer); const updateInterval = hibernating ? 60000 - : (config?.playerUpdateInterval ?? 5000); + : (config?.playback.playerUpdateInterval ?? 5000); const statsInterval = hibernating ? 120000 - : config?.metrics?.enabled + : config?.api.metrics?.enabled ? 5000 - : (config?.statsUpdateInterval ?? 30000); - const zombieThreshold = config?.zombieThresholdMs ?? 60000; + : (config?.playback.statsUpdateInterval ?? 30000); + const zombieThreshold = config?.playback.zombieThresholdMs ?? 60000; playerUpdateTimer = setInterval(() => { if (!process.connected) return; diff --git a/package.json b/package.json index 4283e66c..db22b094 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,12 @@ "proxy-agent": "^8.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.13", + "@biomejs/biome": "^2.4.15", "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@types/bun": "^1.3.13", "@types/jsdom": "^28.0.1", - "@types/node": "^25.6.0", + "@types/node": "^25.6.2", "dotenv": "^17.4.2", "husky": "9.1.7", "tsx": "^4.21.0", diff --git a/src/api/index.ts b/src/api/index.ts index ccd8b4a8..d20ecab9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -224,7 +224,7 @@ async function requestHandler( parsedUrl.pathname === `/${PATH_VERSION}/profiler/ui` || parsedUrl.pathname === `/${PATH_VERSION}/profiler/file` if (isMetricsEndpoint) { - const metricsConfig = nodelink.options.metrics || {} + const metricsConfig = nodelink.options.api.metrics || {} if (!metricsConfig.enabled) { logger( 'warn', diff --git a/src/api/info.ts b/src/api/info.ts index dd8d05e4..33875710 100644 --- a/src/api/info.ts +++ b/src/api/info.ts @@ -388,7 +388,7 @@ async function buildInfoResponse( }, isNodelink: true, sourceManagers: await getSourceManagers(nodelink), - filters: getEnabledFilterNames(nodelink.options.filters?.enabled), + filters: getEnabledFilterNames(nodelink.options.playback.filters?.enabled), plugins: getPlugins(nodelink.pluginManager) } } diff --git a/src/api/loadStream.ts b/src/api/loadStream.ts index a17ed92b..28920fb5 100644 --- a/src/api/loadStream.ts +++ b/src/api/loadStream.ts @@ -434,7 +434,7 @@ function getStreamProcessorModule(): Promise { if (!streamProcessorModulePromise) { streamProcessorModulePromise = import( '../playback/processing/streamProcessor.ts' - ) as Promise + ) as unknown as Promise } return streamProcessorModulePromise @@ -706,7 +706,7 @@ async function handler( return } - if (!runtime.options.enableLoadStreamEndpoint) { + if (!runtime.options.api.enableLoadStreamEndpoint) { sendErrorResponse( req, res, @@ -832,7 +832,7 @@ async function handler( input.filters, { streamInfo: urlResult, - loudnessNormalizer: runtime.options.audio?.loudnessNormalizer + loudnessNormalizer: runtime.options.playback.audio?.loudnessNormalizer }, input.volume / 100, null, @@ -895,7 +895,7 @@ async function handler( input.volume / 100, null, true, - runtime.options.audio?.loudnessNormalizer + runtime.options.playback.audio?.loudnessNormalizer ) as StreamingAudioResource pcmStream = resource.stream diff --git a/src/api/loadTracks.ts b/src/api/loadTracks.ts index f89197be..f470bbd9 100644 --- a/src/api/loadTracks.ts +++ b/src/api/loadTracks.ts @@ -193,7 +193,7 @@ async function handler( const target = parseIdentifier( identifier, - nodelink.options.defaultSearchSource + nodelink.options.search.defaultSource ) const workerRequest = buildWorkerRequest(target) const runtime = nodelink as LoadTracksRuntime diff --git a/src/api/sessions.id.players.id.mix.ts b/src/api/sessions.id.players.id.mix.ts index 5dc8dad8..bd7b572d 100644 --- a/src/api/sessions.id.players.id.mix.ts +++ b/src/api/sessions.id.players.id.mix.ts @@ -395,7 +395,7 @@ async function handleCreateMix( return } - const mixConfig = runtime.options.mix ?? { + const mixConfig = runtime.options.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5, diff --git a/src/api/trackstream.ts b/src/api/trackstream.ts index 94642851..e59bdaf1 100644 --- a/src/api/trackstream.ts +++ b/src/api/trackstream.ts @@ -180,7 +180,7 @@ async function handler( sendResponse: ApiSendResponse, parsedUrl: URL ): Promise { - if (!nodelink.options.enableTrackStreamEndpoint) { + if (!nodelink.options.api.enableTrackStreamEndpoint) { sendErrorResponse( req, res, diff --git a/src/index.ts b/src/index.ts index 6ec70b6a..da15e2b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ import cluster from 'node:cluster' import { EventEmitter } from 'node:events' +import fs from 'node:fs' import http from 'node:http' import { resolve as resolvePath } from 'node:path' import { pathToFileURL } from 'node:url' import WebSocketServer from '@performanc/pwsl-server' +import { migrateConfig } from './modules/config/configMigration.ts' +import ProxyManager from './managers/proxyManager.ts' import RoutePlannerManager from './managers/routePlannerManager.ts' import SessionManager from './managers/sessionManager.ts' import StatsManager from './managers/statsManager.ts' @@ -288,34 +291,80 @@ const getTrackCacheManagerClass = async (): Promise< } let config: NodelinkConfig -const resolveRootConfigUrl = (fileName: string): string => - pathToFileURL(resolvePath(process.cwd(), fileName)).href - -try { - config = (await import(resolveRootConfigUrl('config.js'))) - .default as unknown as NodelinkConfig -} catch (e) { - const error = e as ConfigLoadError - if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ENOENT') { + +const loadConfig = async (): Promise => { + const resolveRootConfigUrl = (fileName: string): string => + pathToFileURL(resolvePath(process.cwd(), fileName)).href + const resolveConfigExport = ( + importedModule: Record, + fileName: string + ): Record => { + const candidate = ( + importedModule as { + default?: unknown + config?: unknown + } + ).default ?? ( + importedModule as { + default?: unknown + config?: unknown + } + ).config + + if ( + candidate && + typeof candidate === 'object' && + Object.keys(candidate as Record).length > 0 + ) { + return candidate as Record + } + + throw new Error( + `[ERROR] Config: ${fileName} must export a non-empty configuration object (default export or named "config").` + ) + } + + const candidates = [ + 'config.ts', + 'config.js', + 'config.default.ts', + 'config.default.js' + ] + + for (const fileName of candidates) { try { - config = (await import(resolveRootConfigUrl('config.default.js'))) - .default as unknown as NodelinkConfig - 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.' - ) - } catch (e2) { - console.error( - '[ERROR] Config: Failed to load config.default.js. Please make sure it exists.' + const module = await import(resolveRootConfigUrl(fileName)) + const imported = resolveConfigExport( + module as Record, + fileName ) - throw e2 + if (fileName.startsWith('config.default')) { + console.log(`[INFO] Config: Loaded fallback configuration from ${fileName}`) + } else { + console.log(`[INFO] Config: Loaded configuration from ${fileName}`) + } + return migrateConfig(imported) as NodelinkConfig + } catch (e) { + const error = e as ConfigLoadError + const isNotFound = + error.code === 'ERR_MODULE_NOT_FOUND' || + error.code === 'ENOENT' || + error.message?.includes('Cannot find module') + if (isNotFound) continue + throw e } - } else { - throw e } + + console.error( + '[ERROR] Config: Failed to load configuration (config.ts/config.js/config.default.ts/config.default.js).' + ) + throw new Error('No valid configuration file found.') } +config = await loadConfig() + // Apply environment variable overrides after config is loaded -applyEnvOverrides(config) +applyEnvOverrides(config as unknown as Record) const clusterEnabled = // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires index signature access @@ -471,6 +520,7 @@ class NodelinkServer extends EventEmitter { lyrics: LyricsManager | null meanings: MeaningManager | null _sourceInitPromise: Promise + proxyManager: ProxyManager | null routePlanner: RoutePlannerManager credentialManager: CredentialManager | null trackCacheManager: TrackCacheManager | null @@ -533,6 +583,7 @@ class NodelinkServer extends EventEmitter { this.routePlanner = new RoutePlannerManager(this) memoryTrace('constructor:after-route-planner') + this.proxyManager = null this.credentialManager = null memoryTrace('constructor:after-credential-manager') this.trackCacheManager = null @@ -570,8 +621,8 @@ class NodelinkServer extends EventEmitter { this.voiceSockets = new Map() this.voiceRelay = createVoiceRelay({ - enabled: options.voiceReceive?.enabled || false, - format: options.voiceReceive?.format || 'pcm', + enabled: options.playback.voiceReceive?.enabled || false, + format: options.playback.voiceReceive?.format || 'pcm', sendFrame: (frame: Buffer) => this.handleVoiceFrame(frame), logger }) as unknown as VoiceRelay @@ -662,13 +713,13 @@ class NodelinkServer extends EventEmitter { .then(([CredentialManagerClass, TrackCacheManagerClass]) => { if (!this.credentialManager) { this.credentialManager = new CredentialManagerClass( - this as unknown as { options: Record } + this as unknown as { options: NodelinkConfig } ) as unknown as CredentialManager } if (!this.trackCacheManager) { this.trackCacheManager = new TrackCacheManagerClass( - this as unknown as { options: Record } + this as unknown as { options: NodelinkConfig } ) as unknown as TrackCacheManager } }) @@ -1520,7 +1571,7 @@ class NodelinkServer extends EventEmitter { ) if (voiceMatch) { - if (!self.options.voiceReceive?.enabled) { + if (!self.options.playback.voiceReceive?.enabled) { try { wrapper.close(1008, 'Voice receive disabled') } catch {} @@ -1853,7 +1904,7 @@ class NodelinkServer extends EventEmitter { return rejectUpgrade(400, 'Bad Request', 'Invalid User-Id header.') } - if (voiceMatch && !this.options.voiceReceive?.enabled) { + if (voiceMatch && !this.options.playback.voiceReceive?.enabled) { return rejectUpgrade( 404, 'Not Found', @@ -1947,7 +1998,7 @@ class NodelinkServer extends EventEmitter { ) => { socket.guildId = guildId - if (!this.options.voiceReceive?.enabled) { + if (!this.options.playback.voiceReceive?.enabled) { try { socket.close(1008, 'Voice receive disabled') } catch {} @@ -2111,16 +2162,16 @@ class NodelinkServer extends EventEmitter { if (this._globalUpdater) return const updateInterval = Math.max( 1, - this.options?.playerUpdateInterval ?? 5000 + this.options?.playback.playerUpdateInterval ?? 5000 ) const statsSendInterval = Math.max( 1, - this.options?.statsUpdateInterval ?? 30000 + this.options?.playback.statsUpdateInterval ?? 30000 ) - const metricsInterval = this.options?.metrics?.enabled + const metricsInterval = this.options?.api.metrics?.enabled ? 5000 : statsSendInterval - const zombieThreshold = this.options?.zombieThresholdMs ?? 60000 + const zombieThreshold = this.options?.playback.zombieThresholdMs ?? 60000 this._globalUpdater = setInterval(() => { for (const session of this.sessions.values()) { @@ -2543,9 +2594,9 @@ class NodelinkServer extends EventEmitter { if (this._globalUpdater) return const statsSendInterval = Math.max( 1, - this.options?.statsUpdateInterval ?? 30000 + this.options?.playback.statsUpdateInterval ?? 30000 ) - const metricsInterval = this.options?.metrics?.enabled + const metricsInterval = this.options?.api.metrics?.enabled ? 5000 : statsSendInterval @@ -2858,6 +2909,13 @@ if (clusterEnabled && cluster.isPrimary) { await nserver.trackCacheManager?.forceSave() nserver.sourceWorkerManager?.destroy?.() + nserver.workerManager?.destroy?.() + nserver.connectionManager?.destroy?.() + nserver.proxyManager?.destroy?.() + nserver.routePlanner?.dispose?.() + nserver.credentialManager?.destroy?.() + nserver.trackCacheManager?.destroy?.() + await nserver._cleanupWebSocketServer() if ((nserver.server as http.Server)?.listening) { diff --git a/src/lyrics/monochrome.ts b/src/lyrics/monochrome.ts index 0396044c..8d11431a 100644 --- a/src/lyrics/monochrome.ts +++ b/src/lyrics/monochrome.ts @@ -33,13 +33,8 @@ export default class MonochromeLyrics implements LyricsSourceInstance { */ constructor(nodelink: LyricsManagerContext) { this.nodelink = nodelink - const sources = nodelink.options?.sources as - | Record - | undefined - const config = - (sources?.monochrome as unknown as MonochromeSourceConfig) || { - enabled: false - } + const sources = nodelink.options.sources + const config = sources.monochrome const defaultUrls = [ 'https://eu-central.monochrome.tf', diff --git a/src/managers/connectionManager.ts b/src/managers/connectionManager.ts index 42df3b33..3a0267f6 100644 --- a/src/managers/connectionManager.ts +++ b/src/managers/connectionManager.ts @@ -70,7 +70,7 @@ export default class ConnectionManager { constructor(nodelink: ConnectionManagerContext) { this.nodelink = nodelink - this.config = nodelink.options.connection || {} + this.config = nodelink.options.network?.connection || nodelink.options.connection || {} this.interval = null this.status = 'unknown' this.metrics = { timestamp: Date.now() } @@ -106,6 +106,13 @@ export default class ConnectionManager { } } + /** + * Stops the monitor and releases resources. + */ + destroy(): void { + this.stop() + } + /** * Runs a full connectivity check and updates metrics. */ diff --git a/src/managers/credentialManager.ts b/src/managers/credentialManager.ts index 31a21456..26493e52 100644 --- a/src/managers/credentialManager.ts +++ b/src/managers/credentialManager.ts @@ -1,11 +1,9 @@ -import crypto from 'node:crypto' -import fs from 'node:fs/promises' +import type { NodelinkConfig } from '../typings/config/config.types.ts' import type { CredentialEntry, - CredentialManagerStats, - CredentialStorePayload + CredentialManagerStats } from '../typings/modules/credential.types.ts' -import { logger } from '../utils.ts' +import BaseCacheManager from './baseCacheManager.ts' const CREDENTIALS_SALT = 'nodelink-salt' const CREDENTIALS_VERSION = 1 @@ -14,23 +12,12 @@ const DEFAULT_CREDENTIALS_PATH = './.cache/credentials.bin' const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000 type CredentialManagerContext = { - options: Record + options: NodelinkConfig } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) -const getErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : String(error ?? 'Unknown error') - -const getErrorCode = (error: unknown): string | undefined => { - if (!error || typeof error !== 'object' || !('code' in error)) { - return undefined - } - const code = (error as NodeJS.ErrnoException).code - return typeof code === 'string' ? code : undefined -} - /** * Encrypted credential store with TTL support and debounced persistence. * @remarks @@ -44,122 +31,27 @@ const getErrorCode = (error: unknown): string | undefined => { * ``` * @public */ -export default class CredentialManager { +export default class CredentialManager extends BaseCacheManager { private readonly nodelink: CredentialManagerContext - private readonly password: string - private key: Buffer - private legacyKey: Buffer | null - private readonly filePath: string - private readonly tempFilePath: string - private readonly saveDelayMs: number - private credentials: Map> - private saveTimeout: NodeJS.Timeout | null - private cleanupInterval: NodeJS.Timeout | null - private savePromise: Promise | null - private saveQueued: boolean - private lastLoadedAt: number | null - private lastSavedAt: number | null /** * Creates a new credential manager instance. * @param nodelink - NodeLink runtime used to derive the encryption key. */ constructor(nodelink: CredentialManagerContext) { - this.nodelink = nodelink - this.password = this._resolvePassword(nodelink.options) - this.key = this._deriveFastKey(this.password) - this.legacyKey = null - this.filePath = DEFAULT_CREDENTIALS_PATH - this.tempFilePath = `${DEFAULT_CREDENTIALS_PATH}.tmp` - this.saveDelayMs = DEFAULT_SAVE_DELAY_MS - this.credentials = new Map() - this.saveTimeout = null - this.cleanupInterval = setInterval(() => { - const expiredCount = this._purgeExpired() - if (expiredCount > 0) this.save() - }, DEFAULT_CLEANUP_INTERVAL_MS) - this.cleanupInterval.unref?.() - this.savePromise = null - this.saveQueued = false - this.lastLoadedAt = null - this.lastSavedAt = null - } - - /** - * Loads and decrypts credential data from disk. - * @remarks Purges expired entries and persists if cleanup occurs. - */ - async load(): Promise { - try { - const data = await fs.readFile(this.filePath) - if (data.length < 32) return - - let payload: CredentialStorePayload - let migratedFromLegacy = false - - try { - payload = this._decodePayload(data, this.key) - } catch { - const legacyKey = this._getLegacyKey() - payload = this._decodePayload(data, legacyKey) - migratedFromLegacy = true - } - - this.credentials = new Map(Object.entries(payload.entries)) - const expiredCount = this._purgeExpired() - if (expiredCount > 0 || migratedFromLegacy) this.save() - - this.lastLoadedAt = Date.now() - logger( - 'debug', - 'Credentials', - `Loaded ${this.credentials.size} encrypted credentials from disk.` - ) - } catch (error) { - const code = getErrorCode(error) - if (code !== 'ENOENT') { - logger( - 'error', - 'Credentials', - `Failed to decrypt credentials: ${getErrorMessage(error)}` - ) - } - this.credentials = new Map() - } - } - - /** - * Debounces credential persistence to disk. - */ - save(): void { - if (this.saveTimeout) return - - this.saveTimeout = setTimeout(() => { - this.saveTimeout = null - void this.forceSave() - }, this.saveDelayMs) - } - - /** - * Forces credentials to be written immediately. - */ - async forceSave(): Promise { - if (this.saveTimeout) { - clearTimeout(this.saveTimeout) - this.saveTimeout = null - } + const password = CredentialManager._resolvePassword(nodelink.options) + + super({ + name: 'Credentials', + passwordHashKey: password, + salt: CREDENTIALS_SALT, + filePath: DEFAULT_CREDENTIALS_PATH, + saveDelayMs: DEFAULT_SAVE_DELAY_MS, + cleanupIntervalMs: DEFAULT_CLEANUP_INTERVAL_MS, + version: CREDENTIALS_VERSION + }) - try { - this._purgeExpired() - await this._flushSaveQueue() - logger('debug', 'Credentials', 'Force saved credentials to disk.') - } catch (error) { - logger( - 'error', - 'Credentials', - `Failed to force save credentials: ${getErrorMessage(error)}` - ) - } + this.nodelink = nodelink } /** @@ -181,7 +73,7 @@ export default class CredentialManager { const entry = this._getValidEntry(key) if (!entry) return null return { - ...(entry as CredentialEntry) + ...(entry as unknown as CredentialEntry) } } @@ -193,9 +85,10 @@ export default class CredentialManager { */ set(key: string, value: T, ttlMs = 0): void { const now = Date.now() - const current = this.credentials.get(key) + const current = this.cache.get(key) const expiresAt = ttlMs > 0 ? now + ttlMs : null - this.credentials.set(key, { + + this.cache.set(key, { value, createdAt: current?.createdAt ?? now, updatedAt: now, @@ -210,7 +103,7 @@ export default class CredentialManager { * @returns True if an entry was removed. */ delete(key: string): boolean { - const existed = this.credentials.delete(key) + const existed = this.cache.delete(key) if (existed) this.save() return existed } @@ -223,261 +116,28 @@ export default class CredentialManager { return this._getValidEntry(key) !== null } - /** - * Clears all stored credentials. - */ - clear(): void { - if (this.credentials.size === 0) return - this.credentials.clear() - this.save() - } - /** * Returns runtime statistics for credential storage. */ getStats(): CredentialManagerStats { const now = Date.now() - const expiredEntries = this._countExpired(now) + let expiredEntries = 0 + for (const entry of this.cache.values()) { + if (entry.expiresAt && now > entry.expiresAt) expiredEntries++ + } return { - totalEntries: this.credentials.size, + totalEntries: this.cache.size, expiredEntries, lastLoadedAt: this.lastLoadedAt ?? undefined, lastSavedAt: this.lastSavedAt ?? undefined } } - private _resolvePassword(options: Record): string { - const optionsCandidate = options as { server?: unknown } - const serverCandidate = optionsCandidate.server - const server = isRecord(serverCandidate) - ? (serverCandidate as { password?: unknown }) - : null - const password = - server && typeof server.password === 'string' ? server.password : null + private static _resolvePassword(options: NodelinkConfig): string { + const password = options.server?.password if (!password) { throw new Error('CredentialManager requires options.server.password') } return password } - - private _deriveFastKey(password: string): Buffer { - return crypto - .createHash('sha256') - .update(`${CREDENTIALS_SALT}:${password}`) - .digest() - } - - private _getLegacyKey(): Buffer { - if (!this.legacyKey) { - this.legacyKey = crypto.scryptSync(this.password, CREDENTIALS_SALT, 32) - } - - return this.legacyKey - } - - private _getValidEntry(key: string): CredentialEntry | null { - const entry = this.credentials.get(key) - if (!entry) return null - if (entry.expiresAt && Date.now() > entry.expiresAt) { - this.credentials.delete(key) - this.save() - return null - } - return entry - } - - private _purgeExpired(): number { - const now = Date.now() - let expiredCount = 0 - for (const [key, entry] of this.credentials.entries()) { - if (entry.expiresAt && now > entry.expiresAt) { - this.credentials.delete(key) - expiredCount++ - } - } - return expiredCount - } - - private _countExpired(now: number): number { - let expiredCount = 0 - for (const entry of this.credentials.values()) { - if (entry.expiresAt && now > entry.expiresAt) expiredCount++ - } - return expiredCount - } - - private _decodePayload(data: Buffer, key: Buffer): CredentialStorePayload { - const iv = data.subarray(0, 16) - const tag = data.subarray(16, 32) - const encrypted = data.subarray(32) - - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) - decipher.setAuthTag(tag) - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]).toString('utf8') - const parsed = JSON.parse(decrypted) as unknown - return this._normalizePayload(parsed) - } - - private _normalizePayload(raw: unknown): CredentialStorePayload { - const now = Date.now() - if (isRecord(raw)) { - const payloadCandidate = raw as Partial & { - entries?: unknown - savedAt?: unknown - } - const entriesValue = payloadCandidate.entries - if (isRecord(entriesValue)) { - const entries = this._normalizeEntries(entriesValue, now) - const savedAt = - typeof payloadCandidate.savedAt === 'number' - ? payloadCandidate.savedAt - : now - return { - version: CREDENTIALS_VERSION, - savedAt, - entries - } - } - - return { - version: CREDENTIALS_VERSION, - savedAt: now, - entries: this._normalizeEntries(raw, now) - } - } - - return { - version: CREDENTIALS_VERSION, - savedAt: now, - entries: {} - } - } - - private _normalizeEntries( - rawEntries: Record, - fallbackTime: number - ): Record> { - const entries: Record> = {} - - for (const [key, value] of Object.entries(rawEntries)) { - entries[key] = this._normalizeEntry(value, fallbackTime) - } - - return entries - } - - private _normalizeEntry( - rawValue: unknown, - fallbackTime: number - ): CredentialEntry { - if (isRecord(rawValue)) { - const entryCandidate = rawValue as Partial> & { - value?: unknown - } - const value = Object.hasOwn(entryCandidate, 'value') - ? entryCandidate.value - : rawValue - const createdAt = - typeof entryCandidate.createdAt === 'number' - ? entryCandidate.createdAt - : fallbackTime - const updatedAt = - typeof entryCandidate.updatedAt === 'number' - ? entryCandidate.updatedAt - : createdAt - const expiresAt = - typeof entryCandidate.expiresAt === 'number' && - entryCandidate.expiresAt > 0 - ? entryCandidate.expiresAt - : null - - return { - value, - createdAt, - updatedAt, - expiresAt - } - } - - return { - value: rawValue, - createdAt: fallbackTime, - updatedAt: fallbackTime, - expiresAt: null - } - } - - private _buildPayload(): CredentialStorePayload { - return { - version: CREDENTIALS_VERSION, - savedAt: Date.now(), - entries: Object.fromEntries(this.credentials) - } - } - - private async _flushSaveQueue(): Promise { - if (this.savePromise) { - this.saveQueued = true - await this.savePromise - if (this.saveQueued) { - this.saveQueued = false - await this._flushSaveQueue() - } - return - } - - const payload = this._buildPayload() - this.savePromise = this._writeToDisk(payload) - try { - await this.savePromise - } finally { - this.savePromise = null - } - - if (this.saveQueued) { - this.saveQueued = false - await this._flushSaveQueue() - } - } - - private async _writeToDisk(payload: CredentialStorePayload): Promise { - const plainText = JSON.stringify(payload) - const iv = crypto.randomBytes(16) - const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv) - - const encrypted = Buffer.concat([ - cipher.update(plainText, 'utf8'), - cipher.final() - ]) - const tag = cipher.getAuthTag() - const outBuffer = Buffer.concat([iv, tag, encrypted]) - - await fs.mkdir('./.cache', { recursive: true }) - try { - await fs.writeFile(this.tempFilePath, outBuffer) - await fs.rename(this.tempFilePath, this.filePath) - } catch (error) { - logger( - 'debug', - 'Credentials', - `Atomic save failed, falling back to direct write: ${getErrorMessage(error)}` - ) - await fs.writeFile(this.filePath, outBuffer) - try { - await fs.unlink(this.tempFilePath) - } catch (cleanupError) { - logger( - 'debug', - 'Credentials', - `Failed to remove temp credentials file: ${getErrorMessage(cleanupError)}` - ) - } - } - - this.lastSavedAt = payload.savedAt - } } diff --git a/src/managers/dosProtectionManager.ts b/src/managers/dosProtectionManager.ts index ec731e3d..b22e2762 100644 --- a/src/managers/dosProtectionManager.ts +++ b/src/managers/dosProtectionManager.ts @@ -11,6 +11,14 @@ import { logger } from '../utils.ts' type DosProtectionContext = { options?: { dosProtection?: Partial + api?: { + dosProtection?: Partial + [key: string]: unknown + } + security?: { + dosProtection?: Partial + [key: string]: unknown + } } } @@ -58,7 +66,11 @@ export default class DosProtectionManager { */ constructor(nodelink: DosProtectionContext) { this.nodelink = nodelink - this.config = this._resolveConfig(nodelink.options?.dosProtection) + this.config = this._resolveConfig( + nodelink.options?.api?.dosProtection ?? + nodelink.options?.security?.dosProtection ?? + nodelink.options?.dosProtection + ) this.ipRequestCounts = new Map() this.cleanupInterval = setInterval( () => this._cleanup(), diff --git a/src/managers/lyricsManager.ts b/src/managers/lyricsManager.ts index f4de566e..8cac3a8c 100644 --- a/src/managers/lyricsManager.ts +++ b/src/managers/lyricsManager.ts @@ -1,3 +1,4 @@ +import type { NodelinkConfig } from '../typings/config/config.types.ts' import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -83,10 +84,7 @@ export type LyricsLoadResult = */ export interface LyricsManagerContext { /** Server options. */ - options?: Record & { - /** Lyrics source configuration bucket. */ - lyrics?: Record - } + options: NodelinkConfig /** Source manager accessor. */ sources?: { /** Re-resolves a track URI into reliable source metadata. */ diff --git a/src/managers/pluginManager.ts b/src/managers/pluginManager.ts index a9ee010d..891cad52 100644 --- a/src/managers/pluginManager.ts +++ b/src/managers/pluginManager.ts @@ -171,6 +171,13 @@ export default class PluginManager { PluginHookName, Array<(...args: unknown[]) => void> > + /** Tracks which hooks belong to which plugin for cleanup. */ + private readonly pluginHooks: Map< + string, + Set<{ name: PluginHookName; callback: (...args: unknown[]) => void }> + > + /** Tracks the current plugin being initialized to associate hooks correctly. */ + private executingPluginName: string | null = null /** * Creates a new plugin manager instance. @@ -185,6 +192,7 @@ export default class PluginManager { this.pluginsDir = path.join(process.cwd(), 'plugins') this.loadedPlugins = new Map() this.hooks = new Map() + this.pluginHooks = new Map() } /** @@ -221,12 +229,60 @@ export default class PluginManager { name: PluginHookName, callback: (...args: unknown[]) => void ): void { + if (this.executingPluginName) { + if (!this.pluginHooks.has(this.executingPluginName)) { + this.pluginHooks.set(this.executingPluginName, new Set()) + } + this.pluginHooks.get(this.executingPluginName)?.add({ name, callback }) + } + if (!this.hooks.has(name)) { this.hooks.set(name, []) } this.hooks.get(name)?.push(callback) } + /** + * Unloads a plugin by removing its hooks and cache entry. + * @param name - The name of the plugin to unload. + * @public + */ + public unloadPlugin(name: string): void { + const pluginHooks = this.pluginHooks.get(name) + if (pluginHooks) { + for (const { name: hookName, callback } of pluginHooks) { + const list = this.hooks.get(hookName) + if (list) { + const index = list.indexOf(callback) + if (index !== -1) list.splice(index, 1) + } + } + this.pluginHooks.delete(name) + } + + this.loadedPlugins.delete(name) + logger('info', 'PluginManager', `Unloaded plugin: ${name}`) + } + + /** + * Reloads a specific plugin. + * @param name - Name of the plugin. + * @param contextType - Process context. + * @public + */ + public async reloadPlugin( + name: string, + contextType: PluginContextType + ): Promise { + const def = this.config.find((d) => d.name === name) + if (!def) { + throw new Error(`Plugin '${name}' not found in configuration.`) + } + + this.unloadPlugin(name) + await this._loadPlugin(def, contextType, true) + } + /** * Synchronously executes all callbacks registered for a hook. * @param name - The name of the hook to trigger. @@ -416,17 +472,19 @@ export default class PluginManager { * Loads a single plugin definition and executes its entrypoint. * @param def - Plugin definition from config. * @param contextType - Current runtime context identifier. + * @param forceReload - Whether to bypass cache and use timestamp for reloading. * @internal */ private async _loadPlugin( def: PluginDefinition, - contextType: PluginContextType + contextType: PluginContextType, + forceReload = false ): Promise { const { name, source, path: localPath, package: packageName } = def if (!name || name.trim().length === 0) return - if (this.loadedPlugins.has(name)) { + if (!forceReload && this.loadedPlugins.has(name)) { const cached = this.loadedPlugins.get(name) if (!cached) return @@ -517,7 +575,10 @@ export default class PluginManager { if (!entryPoint) return - const fileUrl = pathToFileURL(entryPoint).href + let fileUrl = pathToFileURL(entryPoint).href + if (forceReload) { + fileUrl += `?t=${Date.now()}` + } const importedModule: unknown = await import(fileUrl) const pluginModule = this._coercePluginModule(importedModule) @@ -534,7 +595,12 @@ export default class PluginManager { meta: pluginMeta }) - await this._executePlugin(pluginModule, name, contextType, pluginMeta) + this.executingPluginName = name + try { + await this._executePlugin(pluginModule, name, contextType, pluginMeta) + } finally { + this.executingPluginName = null + } const author = `\x1b[36m${pluginMeta.author}\x1b[0m` const pluginName = `\x1b[1m\x1b[32m${name}\x1b[0m` @@ -545,7 +611,11 @@ export default class PluginManager { const creditString = `[${author}] ${pluginName} ${version}${topic}` - logger('info', 'PluginManager', `Loaded: ${creditString}`) + logger( + 'info', + 'PluginManager', + `${forceReload ? 'Reloaded' : 'Loaded'}: ${creditString}` + ) } catch (error) { const message = error instanceof Error ? error.message : String(error) logger( diff --git a/src/managers/rateLimitManager.ts b/src/managers/rateLimitManager.ts index 83add08f..f9120445 100644 --- a/src/managers/rateLimitManager.ts +++ b/src/managers/rateLimitManager.ts @@ -12,6 +12,14 @@ import { logger } from '../utils.ts' type RateLimitContext = { options?: { rateLimit?: Partial + api?: { + rateLimit?: Partial + [key: string]: unknown + } + security?: { + rateLimit?: Partial + [key: string]: unknown + } } } @@ -69,7 +77,11 @@ export default class RateLimitManager { */ constructor(nodelink: RateLimitContext) { this.nodelink = nodelink - this.config = this._resolveConfig(nodelink.options?.rateLimit) + this.config = this._resolveConfig( + nodelink.options?.api?.rateLimit ?? + nodelink.options?.security?.rateLimit ?? + nodelink.options?.rateLimit + ) this.store = new Map() this.cleanupInterval = setInterval( () => this._cleanup(), diff --git a/src/managers/routePlannerManager.ts b/src/managers/routePlannerManager.ts index 73cc7af3..e247fd22 100644 --- a/src/managers/routePlannerManager.ts +++ b/src/managers/routePlannerManager.ts @@ -1,3 +1,4 @@ +import type { NodelinkConfig } from '../typings/config/config.types.ts' import { logger } from '../utils.ts' /** @@ -21,7 +22,7 @@ interface RoutePlannerIpBlockConfig { interface RoutePlannerConfig { strategy?: RoutePlannerStrategy | string bannedIpCooldown?: number - ipBlocks?: RoutePlannerIpBlockConfig[] + ipBlocks?: Array } /** @@ -40,9 +41,37 @@ interface RoutePlannerBlock { * Minimal NodeLink runtime context used by the route planner manager. * @public */ -type RoutePlannerManagerContext = { - options: Record & { - routePlanner?: RoutePlannerConfig +export type RoutePlannerManagerContext = { + options: NodelinkConfig +} + +const normalizeIpBlocks = ( + blocks: Array | undefined +): RoutePlannerIpBlockConfig[] => { + if (!Array.isArray(blocks)) return [] + return blocks + .map((block) => { + if (typeof block === 'string') return { cidr: block } + if (block && typeof block === 'object' && typeof block.cidr === 'string') { + return block + } + return null + }) + .filter((block): block is RoutePlannerIpBlockConfig => block !== null) +} + +type ResolvedRoutePlannerConfig = Omit & { + ipBlocks?: RoutePlannerIpBlockConfig[] +} + +const resolveConfig = (nodelink: RoutePlannerManagerContext): ResolvedRoutePlannerConfig => { + const cfg = + nodelink.options.network?.routePlanner ?? + nodelink.options.routePlanner ?? + {} + return { + ...cfg, + ipBlocks: normalizeIpBlocks(cfg.ipBlocks) } } @@ -61,7 +90,7 @@ const BIGINT_MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER) */ export default class RoutePlannerManager { public readonly nodelink: RoutePlannerManagerContext - private readonly config: RoutePlannerConfig + private readonly config: ResolvedRoutePlannerConfig public readonly blocks: RoutePlannerBlock[] public readonly bannedIps: Map public readonly bannedBlocks: Map @@ -73,7 +102,7 @@ export default class RoutePlannerManager { */ constructor(nodelink: RoutePlannerManagerContext) { this.nodelink = nodelink - this.config = nodelink.options.routePlanner ?? {} + this.config = resolveConfig(nodelink) this.blocks = [] this.bannedIps = new Map() this.bannedBlocks = new Map() @@ -92,6 +121,13 @@ export default class RoutePlannerManager { return this.blocks } + /** + * Compatibility alias used by shutdown paths. + */ + public dispose(): void { + this.freeAll() + } + /** * Converts an IPv4 or IPv6 address string to bigint form. * @param ip - IP address string. diff --git a/src/managers/sourceManager.ts b/src/managers/sourceManager.ts index 07cb5d33..1ef4916a 100644 --- a/src/managers/sourceManager.ts +++ b/src/managers/sourceManager.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import type { NodelinkConfig, SourcesRegistry } from '../typings/config/config.types.ts' import type { SourceManagerLike, TrackFormat, @@ -16,7 +17,7 @@ import type { TrackStreamResult, TrackUrlResult } from '../typings/sources/source.types.ts' -import { logger } from '../utils.ts' +import { getBestMatch, logger } from '../utils.ts' /** * Context object required by the SourcesManager for operation. @@ -24,32 +25,7 @@ import { logger } from '../utils.ts' */ export interface SourcesManagerContext { /** Global NodeLink configuration options. */ - options: { - /** Map of source-specific configurations. */ - sources?: Record - /** Default source(s) used for keyword searches. */ - defaultSearchSource?: string | string[] - /** List of sources used for multi-source search. */ - unifiedSearchSources?: string[] - /** Maximum number of search results to return. */ - maxSearchResults?: number - /** Maximum track count for album and playlist loading. */ - maxAlbumPlaylistLength?: number - /** Default playback volume for new tracks. */ - defaultVolume?: number - /** Whether to enable advanced source-native seeking. */ - enableHoloTracks?: boolean - /** Whether to fetch supplemental channel metadata. */ - fetchChannelInfo?: boolean - /** Whether to resolve external links within metadata. */ - resolveExternalLinks?: boolean - /** Audio processing configuration. */ - audio?: { - /** Global loudness normalization preference. */ - loudnessNormalizer?: boolean - } - [key: string]: unknown - } + options: NodelinkConfig /** Global statistics manager for metric recording. */ statsManager?: { /** Increments success counter for a specific source. */ @@ -144,13 +120,10 @@ export default class SourcesManager implements SourceManagerLike { ): Promise => { const isYouTube = name === 'youtube' || name.includes('YouTube.ts') const sourceKey = isYouTube ? 'youtube' : name - const youtubeKey = 'youtube' const sourceConfig = this.nodelink.options.sources - const enabled = isYouTube - ? (sourceConfig?.[youtubeKey] as { enabled?: boolean } | undefined) - ?.enabled - : !!sourceConfig?.[sourceKey]?.enabled + const enabled = + sourceConfig[sourceKey as keyof SourcesRegistry]?.enabled if (!enabled) return @@ -206,11 +179,10 @@ export default class SourcesManager implements SourceManagerLike { try { await fs.access(sourcesDir) - const enabledSourceKeys = Object.entries( - this.nodelink.options.sources || {} - ) - .filter(([, cfg]) => !!cfg?.enabled) - .map(([key]) => key.toLowerCase()) + const sources = this.nodelink.options.sources + const enabledSourceKeys = (Object.keys(sources) as Array) + .filter((key) => sources[key]?.enabled) + .map((key) => key.toLowerCase()) const uniqueEnabled = Array.from(new Set(enabledSourceKeys)) const sourceEntries = uniqueEnabled.map((sourceKey) => { @@ -386,11 +358,15 @@ export default class SourcesManager implements SourceManagerLike { * @public */ public async searchWithDefault(query: string): Promise { + const configuredDefaultSource = + this.nodelink.options.search?.defaultSource ?? + this.nodelink.options.defaultSearchSource ?? + 'youtube' const defaultSources = Array.isArray( - this.nodelink.options.defaultSearchSource + configuredDefaultSource ) - ? this.nodelink.options.defaultSearchSource - : [this.nodelink.options.defaultSearchSource] + ? configuredDefaultSource + : [configuredDefaultSource] for (const source of defaultSources) { try { @@ -421,9 +397,11 @@ export default class SourcesManager implements SourceManagerLike { * @public */ public async unifiedSearch(query: string): Promise { - const searchSources = (this.nodelink.options.unifiedSearchSources || [ - 'youtube' - ]) as string[] + const searchSources = ( + this.nodelink.options.search?.unifiedSources ?? + this.nodelink.options.unifiedSearchSources ?? + ['youtube'] + ) as string[] logger( 'debug', 'Sources', @@ -528,13 +506,15 @@ export default class SourcesManager implements SourceManagerLike { * @param track - The normalized track metadata. * @param itag - Optional YouTube-specific itag override. * @param isRecovering - Whether this is a recovery attempt. + * @param isUpscaling - Whether this is an upscale attempt. * @returns A promise resolving to the URL result. * @public */ public async getTrackUrl( track: TrackInfo | TrackInfoExtended, itag?: number, - isRecovering?: boolean + isRecovering?: boolean, + isUpscaling?: boolean ): Promise< TrackUrlResult & { protocol?: string @@ -543,6 +523,49 @@ export default class SourcesManager implements SourceManagerLike { additionalData?: Record } > { + // ISRC Upscale: If track has ISRC and is from YouTube, try high-quality sources first. + if ( + track.isrc && + !isUpscaling && + !isRecovering && + (track.sourceName === 'youtube' || track.sourceName === 'ytmusic') + ) { + const hqSources = ['tidal', 'qobuz', 'applemusic', 'deezer'] + for (const source of hqSources) { + if (!this.sources.has(source)) continue + + try { + const searchResult = await this.search(source, track.isrc) + if ( + searchResult.loadType === 'search' && + Array.isArray(searchResult.data) && + searchResult.data.length > 0 + ) { + const match = getBestMatch(searchResult.data, track) + if (match) { + logger( + 'info', + 'Sources', + `ISRC Upscale: found match for ${track.isrc} on ${source}. Using high-quality source.` + ) + return (await this.getTrackUrl( + match.info, + undefined, + false, + true + )) as any + } + } + } catch (e) { + logger( + 'debug', + 'Sources', + `ISRC Upscale attempt failed for ${source}: ${(e as Error).message}` + ) + } + } + } + const instance = this.sourceMap.get(track.sourceName) if (!instance?.getTrackUrl) { throw new Error( @@ -662,7 +685,7 @@ export default class SourcesManager implements SourceManagerLike { const sources = this.nodelink.options.sources if (sources) { for (const sourceName in sources) { - if (sources[sourceName]?.enabled) { + if (sources[sourceName as keyof SourcesRegistry]?.enabled) { enabledNames.push(sourceName) } } diff --git a/src/managers/statsManager.ts b/src/managers/statsManager.ts index 92207eea..5aa6e159 100644 --- a/src/managers/statsManager.ts +++ b/src/managers/statsManager.ts @@ -1,3 +1,4 @@ +import type { NodelinkConfig } from '../typings/config/config.types.ts' import type { EndpointCounters, SourceStatsEntry, @@ -13,15 +14,8 @@ type PromCounter = InstanceType type PromGauge = InstanceType type PromCollector = ReturnType -export type StatsManagerOptions = { - metrics?: { - enabled?: boolean - } - [key: string]: unknown -} - export type StatsManagerContext = { - options: StatsManagerOptions + options: NodelinkConfig } const MAX_ENDPOINT_ENTRIES = 500 @@ -137,7 +131,7 @@ export default class StatsManager { async initialize(): Promise { if (this.initialized) return - const metricsEnabled = this.nodelink.options.metrics?.enabled ?? false + const metricsEnabled = this.nodelink.options.api.metrics?.enabled ?? false if (!metricsEnabled) { this.initialized = true return @@ -543,6 +537,14 @@ export default class StatsManager { logger('info', 'StatsManager', 'Prometheus metrics initialized.') } + /** + * Returns the Prometheus registry for custom metric registration. + * @public + */ + public getRegistry(): PromRegistry | undefined { + return this.promRegister + } + /** * Returns a deep-copied snapshot of internal counters. */ diff --git a/src/managers/trackCacheManager.ts b/src/managers/trackCacheManager.ts index 63010f3c..7773e414 100644 --- a/src/managers/trackCacheManager.ts +++ b/src/managers/trackCacheManager.ts @@ -1,7 +1,6 @@ -import crypto from 'node:crypto' -import fs from 'node:fs/promises' +import type { NodelinkConfig } from '../typings/config/config.types.ts' import type { TrackCacheEntry } from '../typings/playback/trackCache.types.ts' -import { logger } from '../utils.ts' +import BaseCacheManager from './baseCacheManager.ts' const TRACK_CACHE_SALT = 'nodelink-track-salt' const DEFAULT_CACHE_FILE = './.cache/tracks.bin' @@ -11,23 +10,12 @@ const DEFAULT_MAX_ENTRIES = 5000 const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000 type TrackCacheContext = { - options: Record + options: NodelinkConfig } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) -const getErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : String(error ?? 'Unknown error') - -const getErrorCode = (error: unknown): string | undefined => { - if (!error || typeof error !== 'object' || !('code' in error)) { - return undefined - } - const code = (error as NodeJS.ErrnoException).code - return typeof code === 'string' ? code : undefined -} - /** * Encrypted cache for resolved track metadata and URLs. * @remarks Uses AES-256-GCM and purges expired entries on load and access. @@ -40,121 +28,30 @@ const getErrorCode = (error: unknown): string | undefined => { * ``` * @public */ -export default class TrackCacheManager { +export default class TrackCacheManager extends BaseCacheManager { private readonly nodelink: TrackCacheContext - private readonly password: string - private key: Buffer - private legacyKey: Buffer | null - private readonly filePath: string - private readonly maxEntries: number - private readonly cleanupIntervalMs: number - private cache: Map> - private saveTimeout: NodeJS.Timeout | null - private cleanupInterval: NodeJS.Timeout | null /** * Creates a new track cache manager instance. * @param nodelink - NodeLink runtime context. */ constructor(nodelink: TrackCacheContext) { - this.nodelink = nodelink - this.password = this._resolvePassword(nodelink.options) - this.key = this._deriveFastKey(this.password) - this.legacyKey = null - const cacheOptions = this._resolveCacheOptions(nodelink.options) - this.filePath = DEFAULT_CACHE_FILE - this.maxEntries = cacheOptions.maxEntries - this.cleanupIntervalMs = cacheOptions.cleanupIntervalMs - this.cache = new Map() - this.saveTimeout = null - this.cleanupInterval = setInterval(() => { - const expiredCount = this._purgeExpired() - const evictedCount = this._enforceMaxEntries() - if (expiredCount > 0 || evictedCount > 0) this.save() - }, this.cleanupIntervalMs) - this.cleanupInterval.unref?.() - } - - /** - * Loads cached tracks from disk. - */ - async load(): Promise { - try { - const data = await fs.readFile(this.filePath) - if (data.length < 32) return - - let store: Record> - let migratedFromLegacy = false - - try { - store = this._decodeStore(data, this.key) - } catch { - const legacyKey = this._getLegacyKey() - store = this._decodeStore(data, legacyKey) - migratedFromLegacy = true - } - - this.cache = new Map(Object.entries(store)) - - const expiredCount = this._purgeExpired() - const evictedCount = this._enforceMaxEntries() - if (expiredCount > 0 || evictedCount > 0 || migratedFromLegacy) - this.save() - - logger( - 'debug', - 'TrackCache', - `Loaded ${this.cache.size} cached tracks from disk.` - ) - } catch (error) { - const code = getErrorCode(error) - if (code !== 'ENOENT') { - logger( - 'error', - 'TrackCache', - `Failed to load track cache: ${getErrorMessage(error)}` - ) - } - this.cache = new Map() - } - } - - /** - * Debounces cache persistence. - */ - save(): void { - if (this.saveTimeout) return - - this.saveTimeout = setTimeout(() => { - this.saveTimeout = null - void this.forceSave() - }, DEFAULT_SAVE_DELAY_MS) - } - - /** - * Forces the cache to be written to disk immediately. - */ - async forceSave(): Promise { - try { - const plainText = JSON.stringify(Object.fromEntries(this.cache)) - const iv = crypto.randomBytes(16) - const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv) - - const encrypted = Buffer.concat([ - cipher.update(plainText, 'utf8'), - cipher.final() - ]) - const tag = cipher.getAuthTag() + const password = TrackCacheManager._resolvePassword(nodelink.options) + const cacheOptions = TrackCacheManager._resolveCacheOptions( + nodelink.options + ) + + super({ + name: 'TrackCache', + passwordHashKey: password, + salt: TRACK_CACHE_SALT, + filePath: DEFAULT_CACHE_FILE, + saveDelayMs: DEFAULT_SAVE_DELAY_MS, + cleanupIntervalMs: cacheOptions.cleanupIntervalMs, + maxEntries: cacheOptions.maxEntries + }) - await fs.mkdir('./.cache', { recursive: true }) - await fs.writeFile(this.filePath, Buffer.concat([iv, tag, encrypted])) - } catch (error) { - logger( - 'error', - 'TrackCache', - `Failed to save track cache: ${getErrorMessage(error)}` - ) - } + this.nodelink = nodelink } /** @@ -164,16 +61,8 @@ export default class TrackCacheManager { */ get(source: string, identifier: string): T | null { const key = `${source}:${identifier}` - const entry = this.cache.get(key) - if (!entry) return null - if (entry.expiresAt && Date.now() > entry.expiresAt) { - this.cache.delete(key) - this.save() - return null - } - this.cache.delete(key) - this.cache.set(key, entry) - return entry.value as T + const entry = this._getValidEntry(key) + return entry ? (entry.value as T) : null } /** @@ -190,32 +79,27 @@ export default class TrackCacheManager { ttlMs: number = DEFAULT_TTL_MS ): void { const key = `${source}:${identifier}` - if (this.cache.has(key)) { - this.cache.delete(key) - } + const now = Date.now() + this.cache.set(key, { value, - expiresAt: ttlMs > 0 ? Date.now() + ttlMs : null + createdAt: now, + updatedAt: now, + expiresAt: ttlMs > 0 ? now + ttlMs : null }) + this._enforceMaxEntries() this.save() } - private _resolveCacheOptions(options: Record): { + private static _resolveCacheOptions(options: NodelinkConfig): { maxEntries: number cleanupIntervalMs: number } { - const directCandidate = (options as { trackCache?: unknown }).trackCache - const rootCache = isRecord(directCandidate) ? directCandidate : null - const nestedCandidate = (options as { cache?: unknown }).cache - const nestedCache = isRecord(nestedCandidate) - ? (nestedCandidate as { track?: unknown }).track - : null - const nestedTrackCache = isRecord(nestedCache) ? nestedCache : null - const selected = rootCache ?? nestedTrackCache + const playback = options.playback - const maxEntriesRaw = selected?.maxEntries - const cleanupIntervalRaw = selected?.cleanupIntervalMs + const maxEntriesRaw = playback.maxPlaylistLength + const cleanupIntervalRaw = playback.statsUpdateInterval const maxEntries = typeof maxEntriesRaw === 'number' && Number.isFinite(maxEntriesRaw) @@ -230,112 +114,11 @@ export default class TrackCacheManager { return { maxEntries, cleanupIntervalMs } } - private _resolvePassword(options: Record): string { - const optionsCandidate = options as { server?: unknown } - const serverCandidate = optionsCandidate.server - const server = isRecord(serverCandidate) - ? (serverCandidate as { password?: unknown }) - : null - const password = - server && typeof server.password === 'string' ? server.password : null + private static _resolvePassword(options: NodelinkConfig): string { + const password = options.server?.password if (!password) { throw new Error('TrackCacheManager requires options.server.password') } return password } - - private _deriveFastKey(password: string): Buffer { - return crypto - .createHash('sha256') - .update(`${TRACK_CACHE_SALT}:${password}`) - .digest() - } - - private _getLegacyKey(): Buffer { - if (!this.legacyKey) { - this.legacyKey = crypto.scryptSync(this.password, TRACK_CACHE_SALT, 32) - } - - return this.legacyKey - } - - private _purgeExpired(): number { - const now = Date.now() - let expiredCount = 0 - for (const [key, entry] of this.cache.entries()) { - if (entry.expiresAt && now > entry.expiresAt) { - this.cache.delete(key) - expiredCount++ - } - } - return expiredCount - } - - private _enforceMaxEntries(): number { - if (this.cache.size <= this.maxEntries) return 0 - - let removed = 0 - while (this.cache.size > this.maxEntries) { - const oldestKey = this.cache.keys().next().value - if (!oldestKey) break - this.cache.delete(oldestKey) - removed++ - } - return removed - } - - private _decodeStore( - data: Buffer, - key: Buffer - ): Record> { - const iv = data.subarray(0, 16) - const tag = data.subarray(16, 32) - const encrypted = data.subarray(32) - - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) - decipher.setAuthTag(tag) - - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]).toString('utf8') - const parsed = JSON.parse(decrypted) as unknown - return this._normalizeStore(parsed) - } - - private _normalizeStore( - raw: unknown - ): Record> { - if (!isRecord(raw)) return {} - - const store: Record> = {} - for (const [key, value] of Object.entries(raw)) { - store[key] = this._normalizeEntry(value) - } - return store - } - - private _normalizeEntry(rawValue: unknown): TrackCacheEntry { - if (isRecord(rawValue)) { - const entryCandidate = rawValue as Partial> & { - value?: unknown - } - const value = Object.hasOwn(entryCandidate, 'value') - ? entryCandidate.value - : rawValue - const expiresAt = - typeof entryCandidate.expiresAt === 'number' - ? entryCandidate.expiresAt - : null - return { - value, - expiresAt - } - } - - return { - value: rawValue, - expiresAt: null - } - } } diff --git a/src/playback/hls/HLSHandler.ts b/src/playback/hls/HLSHandler.ts index 1c1b5812..84cf527f 100644 --- a/src/playback/hls/HLSHandler.ts +++ b/src/playback/hls/HLSHandler.ts @@ -71,7 +71,7 @@ export default class HLSHandler extends PassThrough { this.currentUrl = url this.headers = options.headers ?? {} this.localAddress = options.localAddress ?? null - this.proxy = options.proxy ?? null + this.proxy = options.network?.proxy ?? options.proxy ?? null this.onResolveUrl = options.onResolveUrl ?? null this.strategy = options.strategy ?? diff --git a/src/playback/hls/SegmentFetcher.ts b/src/playback/hls/SegmentFetcher.ts index 73777ac0..1fd4979f 100644 --- a/src/playback/hls/SegmentFetcher.ts +++ b/src/playback/hls/SegmentFetcher.ts @@ -75,7 +75,7 @@ export default class SegmentFetcher { constructor(options: SegmentFetcherOptions = {}) { this.headers = options.headers || {} this.localAddress = options.localAddress || null - this.proxy = options.proxy || null + this.proxy = options.network?.proxy || options.proxy || null this.onResolveUrl = options.onResolveUrl || null this.keyMap = new Map() } diff --git a/src/playback/player.ts b/src/playback/player.ts index 35417a76..b5789028 100644 --- a/src/playback/player.ts +++ b/src/playback/player.ts @@ -167,13 +167,13 @@ export class Player { this.session = options.session this.guildId = options.guildId this.volumePercent = this.nodelink.options?.defaultVolume ?? 100 - this.fading = this.nodelink.options?.audio?.fading + this.fading = this.nodelink.options?.playback.audio?.fading this.loudnessNormalizer = - this.nodelink.options?.audio?.loudnessNormalizer ?? false + this.nodelink.options?.playback.audio?.loudnessNormalizer ?? false this.sponsorBlock = { - enabled: this.nodelink.options.sponsorblock?.enabled ?? false, - categories: this.nodelink.options.sponsorblock?.categories ?? [ + enabled: this.nodelink.options.playback.sponsorblock?.enabled ?? false, + categories: this.nodelink.options.playback.sponsorblock?.categories ?? [ 'sponsor', 'selfpromo', 'interaction', @@ -183,10 +183,10 @@ export class Player { 'music_offtopic', 'filler' ], - actionTypes: this.nodelink.options.sponsorblock?.actionTypes ?? ['skip'], + actionTypes: this.nodelink.options.playback.sponsorblock?.actionTypes ?? ['skip'], segments: [], lastSkippedUuid: null, - skipMarginMs: this.nodelink.options.sponsorblock?.skipMarginMs ?? 150 + skipMarginMs: this.nodelink.options.playback.sponsorblock?.skipMarginMs ?? 150 } logger( @@ -227,7 +227,7 @@ export class Player { this.waitEvent = ( event, filter, - timeout = this.nodelink.options.eventTimeoutMs ?? 15000 + timeout = this.nodelink.options.playback.eventTimeoutMs ?? 15000 ) => new Promise((resolve, reject) => { const handler = (_: unknown, payload: unknown) => { @@ -253,7 +253,7 @@ export class Player { } private _getAudioOptions(): AudioOptionsWithTransitions | undefined { - return this.nodelink.options.audio as + return this.nodelink.options.playback.audio as | AudioOptionsWithTransitions | undefined } @@ -274,7 +274,7 @@ export class Player { const { AudioMixer: Mixer } = await import('./processing/AudioMixer.ts') this.audioMixer = new Mixer( - this.nodelink.options?.mix ?? { + this.nodelink.options?.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5, @@ -330,10 +330,13 @@ export class Player { guildId: this.guildId, userId: this.session.userId, channelId: this.voice.channelId || this.guildId, - encryption: this.nodelink.options?.audio?.encryption ?? null + encryption: this.nodelink.options?.playback.audio?.encryption ?? null }) this.connection.stuckTimeout = - Math.max(this.nodelink.options.trackStuckThresholdMs, 30000) + 5000 + Math.max( + this.nodelink.options.playback.trackStuckThresholdMs ?? 10000, + 30000 + ) + 5000 this.connection.on( 'stateChange', (_: VoiceConnectionState | null, s: VoiceConnectionState) => { @@ -481,7 +484,7 @@ export class Player { 'Player', `Track became stuck for guild ${this.guildId}. Triggering immediate recovery.` ) - this._stuckTime = this.nodelink.options.trackStuckThresholdMs + 1 + this._stuckTime = (this.nodelink.options.playback.trackStuckThresholdMs ?? 0) + 1 this._sendUpdate() return } @@ -894,7 +897,7 @@ export class Player { track: PlayerTrack | null ): Promise { if (!track) return null - if (!this.nodelink.options.enableHoloTracks) { + if (!this.nodelink.options.experimental.enableHoloTracks) { return track } @@ -913,8 +916,8 @@ export class Player { )?.resolveHoloTrack if (typeof resolveHoloTrack === 'function') { const holoTrack = await resolveHoloTrack.call(source, track, { - fetchChannelInfo: this.nodelink.options.fetchChannelInfo, - resolveExternalLinks: this.nodelink.options.resolveExternalLinks + fetchChannelInfo: this.nodelink.options.search.fetchChannelInfo, + resolveExternalLinks: this.nodelink.options.search.resolveExternalLinks }) return holoTrack || track } @@ -978,7 +981,7 @@ export class Player { urlData: TrackUrlResult & { protocol?: string; format?: TrackFormat }, startTime?: number ): Promise<{ stream: AudioResource } | { exception: { message: string } }> { - if (this.nodelink.options?.mix?.enabled !== false) { + if (this.nodelink.options?.playback.mix?.enabled !== false) { await this._ensureAudioMixer() } @@ -1125,7 +1128,7 @@ export class Player { } } - const threshold = this.nodelink.options.trackStuckThresholdMs + const threshold = this.nodelink.options.playback.trackStuckThresholdMs ?? 0 if ( threshold > 0 && !this.isUpdatingTrack && @@ -1135,7 +1138,7 @@ export class Player { !this.isPaused ) { if (this._lastPosition === position) { - this._stuckTime += this.nodelink.options.playerUpdateInterval + this._stuckTime += this.nodelink.options.playback.playerUpdateInterval ?? 0 if ( this._stuckTime >= threshold && !this._isRecovering && @@ -1490,7 +1493,7 @@ export class Player { this.sponsorBlock.lastSkippedUuid = null const videoId = this.track.info.identifier - const sbConfig = this.nodelink.options.sponsorblock + const sbConfig = this.nodelink.options.playback.sponsorblock if (this.sponsorBlock.enabled) { logger( @@ -1926,7 +1929,7 @@ export class Player { position: number, endTime?: number ): Promise { - if (this.nodelink.options?.mix?.enabled !== false) { + if (this.nodelink.options?.playback.mix?.enabled !== false) { await this._ensureAudioMixer() } @@ -2666,7 +2669,7 @@ export class Player { await this._ensureAudioMixer() if (!this.audioMixer) throw new Error('AudioMixer not initialized') - const mixConfig = this.nodelink?.options?.mix ?? { + const mixConfig = this.nodelink?.options?.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5 diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index 791bfae9..96de8a8d 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -2399,7 +2399,7 @@ class StreamAudioResource extends BaseAudioResource { this._validateInputStream(stream) const resamplingQuality = - nodelink.options.audio?.resamplingQuality || 'fastest' + nodelink.options.playback.audio?.resamplingQuality || 'fastest' const normalizedType = normalizeFormat(type) this.pipes = [stream] @@ -2607,8 +2607,8 @@ class StreamAudioResource extends BaseAudioResource { type: 's16le', volume, enableAGC, - lookaheadMs: nodelink.options.audio?.lookaheadMs, - gateThresholdLUFS: nodelink.options.audio?.gateThresholdLUFS + lookaheadMs: nodelink.options.playback.audio?.lookaheadMs, + gateThresholdLUFS: nodelink.options.playback.audio?.gateThresholdLUFS }) const fadeTransformer = new FadeTransformer({ type: 's16le', @@ -2628,7 +2628,7 @@ class StreamAudioResource extends BaseAudioResource { const silenceDetector = new SilenceDetector({ sampleRate: AUDIO_CONFIG.sampleRate, channels: AUDIO_CONFIG.channels, - thresholdDb: nodelink.options.audio?.automix?.silenceThresholdDb ?? -40 + thresholdDb: nodelink.options.playback.audio?.automix?.silenceThresholdDb ?? -40 }) const flowController = new FlowController( @@ -2781,8 +2781,8 @@ class StreamAudioResource extends BaseAudioResource { type: 's16le', volume, enableAGC, - lookaheadMs: this.nodelink?.options?.audio?.lookaheadMs, - gateThresholdLUFS: this.nodelink?.options?.audio?.gateThresholdLUFS + lookaheadMs: this.nodelink?.options?.playback.audio?.lookaheadMs, + gateThresholdLUFS: this.nodelink?.options?.playback.audio?.gateThresholdLUFS }) pipeline(pcmStream, volumeTransformer, (err: Error | null): void => { @@ -2995,7 +2995,7 @@ export const createPCMStream = ( filters: FiltersState = {} ): Transform => { const resamplingQuality = - nodelink.options.audio?.resamplingQuality || 'fastest' + nodelink.options.playback.audio?.resamplingQuality || 'fastest' const normalizedType = normalizeFormat(type) const streams: (Readable | Transform)[] = [stream] diff --git a/src/sources/applemusic.ts b/src/sources/applemusic.ts index a4fb68d0..a302a4d0 100644 --- a/src/sources/applemusic.ts +++ b/src/sources/applemusic.ts @@ -373,7 +373,7 @@ export default class AppleMusicSource implements SourceInstance { searchType = 'track' ): Promise { try { - const limit = (this.nodelink.options.maxSearchResults as number) || 10 + const limit = (this.nodelink.options.search.maxResults as number) || 10 const typeMap: Record = { track: 'songs', album: 'albums', diff --git a/src/sources/audius.ts b/src/sources/audius.ts index 5d24cbf1..0b1219b6 100644 --- a/src/sources/audius.ts +++ b/src/sources/audius.ts @@ -251,7 +251,7 @@ export default class AudiusSource implements SourceInstance { */ public async search(query: string): Promise { try { - const limit = this.nodelink.options.maxSearchResults || 10 + const limit = this.nodelink.options.search.maxResults || 10 const endpoint = `/v1/tracks/search?query=${encodeURIComponent(query)}&limit=${limit}` const data = await this._apiRequest(endpoint) diff --git a/src/sources/bandcamp.ts b/src/sources/bandcamp.ts index 77e295b8..d4ca8d58 100644 --- a/src/sources/bandcamp.ts +++ b/src/sources/bandcamp.ts @@ -32,6 +32,9 @@ interface BandcampRuntimeOptions { * Maximum number of tracks returned for search operations. */ maxSearchResults?: number + search?: { + maxResults?: number + } } /** @@ -869,8 +872,8 @@ export default class BandcampSource { * @returns A positive integer limit used when parsing search results. */ private getMaxSearchResults(): number { - const options = this.nodelink.options as BandcampRuntimeOptions - const limit = options.maxSearchResults + const options = this.nodelink.options + const limit = options.search.maxResults return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit diff --git a/src/sources/bilibili.ts b/src/sources/bilibili.ts index 6da285a4..d1c4448b 100644 --- a/src/sources/bilibili.ts +++ b/src/sources/bilibili.ts @@ -210,7 +210,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -302,7 +302,7 @@ export default class BilibiliSource implements SourceInstance { Cookie: cookie, Referer: 'https://search.bilibili.com/' }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) body = searchResponse.body as BilibiliApiResponse @@ -322,7 +322,7 @@ export default class BilibiliSource implements SourceInstance { Cookie: cookie, Referer: 'https://search.bilibili.com/' }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) body = allSearchResponse.body as BilibiliApiResponse @@ -350,7 +350,7 @@ export default class BilibiliSource implements SourceInstance { } const tracks: TrackData[] = [] - const limit = (this.nodelink.options.maxSearchResults as number) || 10 + const limit = (this.nodelink.options.search.maxResults as number) || 10 for (const item of videos.slice(0, limit)) { const durationParts = String(item.duration || '0:0') @@ -412,7 +412,7 @@ export default class BilibiliSource implements SourceInstance { headers: HEADERS, maxRedirects: 3, timeout: 5000, - proxy: this.config.proxy + proxy: this.config.network.proxy }) if (typeof res.body === 'string') { @@ -592,7 +592,7 @@ export default class BilibiliSource implements SourceInstance { const res = await makeRequest(apiUrl, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }) const body = res.body as @@ -635,7 +635,7 @@ export default class BilibiliSource implements SourceInstance { const res = await makeRequest(apiUrl, { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy }) const body = res.body as @@ -710,7 +710,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -752,7 +752,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -791,7 +791,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -827,7 +827,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -891,7 +891,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -986,7 +986,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) const body = res.body as @@ -1011,7 +1011,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -1107,7 +1107,7 @@ export default class BilibiliSource implements SourceInstance { { method: 'GET', headers: { ...HEADERS, Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) const body = res.body as @@ -1138,7 +1138,7 @@ export default class BilibiliSource implements SourceInstance { Referer: 'https://www.bilibili.com/', Cookie: this.buildCookieHeader() }, - proxy: this.config.proxy + proxy: this.config.network.proxy } ) @@ -1218,7 +1218,7 @@ export default class BilibiliSource implements SourceInstance { type: 'mpegts', localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, startTime: (additionalData?.startTime as number) || 0, - proxy: this.config.proxy + proxy: this.config.network.proxy }), type: 'mpegts' } @@ -1228,7 +1228,7 @@ export default class BilibiliSource implements SourceInstance { method: 'GET', headers, streamOnly: true, - proxy: this.config.proxy + proxy: this.config.network.proxy }) if (res.error || !res.stream) diff --git a/src/sources/bluesky.ts b/src/sources/bluesky.ts index 9b962b47..7e9ed692 100644 --- a/src/sources/bluesky.ts +++ b/src/sources/bluesky.ts @@ -39,11 +39,18 @@ interface BlueskySourceConfig { /** * Runtime options subset read by the Bluesky source. */ -interface BlueskyRuntimeOptions { +interface BlueskyRuntimeOptions extends Record { /** * Global search-result limit used by several sources. */ - maxSearchResults?: number + search?: { + maxResults?: number + } + sources?: { + bluesky?: { + maxSearchResults?: number + } + } } /** @@ -317,13 +324,12 @@ export default class BlueskySource { * @param nodelink - Worker runtime used by the source implementation. */ public constructor(nodelink: WorkerNodeLink) { - const options = nodelink.options as BlueskyRuntimeOptions + const options = nodelink.options this.nodelink = nodelink this.config = { maxSearchResults: - typeof options.maxSearchResults === 'number' - ? options.maxSearchResults - : undefined + (options.sources?.bluesky as { maxSearchResults?: number }) + ?.maxSearchResults ?? options.search?.maxResults } this.searchTerms = ['bksearch'] this.patterns = [ diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts index 3e37e7dd..ecabb172 100644 --- a/src/sources/deezer.ts +++ b/src/sources/deezer.ts @@ -79,6 +79,12 @@ interface DeezerRuntimeOptions { * Maximum number of tracks loaded for albums, playlists, and artists. */ maxAlbumPlaylistLength?: number + search?: { + maxResults?: number + } + playback?: { + maxPlaylistLength?: number + } /** * Source-specific configuration map. @@ -1822,7 +1828,7 @@ export default class DeezerSource { * @returns Maximum number of search results to return. */ private getMaxSearchResults(): number { - const limit = this.config.maxSearchResults + const limit = this.config.search?.maxResults return typeof limit === 'number' && limit > 0 ? limit : 10 } @@ -1833,7 +1839,7 @@ export default class DeezerSource { * @returns Maximum collection length for albums, playlists, or artists. */ private getMaxCollectionLength(fallback: number): number { - const limit = this.config.maxAlbumPlaylistLength + const limit = this.config.playback?.maxPlaylistLength return typeof limit === 'number' && limit > 0 ? limit : fallback } diff --git a/src/sources/eternalbox.ts b/src/sources/eternalbox.ts index f98529b8..f0753695 100644 --- a/src/sources/eternalbox.ts +++ b/src/sources/eternalbox.ts @@ -225,7 +225,7 @@ export default class EternalboxSource implements SourceInstance { const limit = this.config.searchResults || - (this.nodelink.options.maxSearchResults as number) || + (this.nodelink.options.search.maxResults as number) || 10 const url = `${this.baseUrl}/api/analysis/search?query=${encodeURIComponent(query)}&results=${limit}` diff --git a/src/sources/gaana.ts b/src/sources/gaana.ts index 30fd46dd..d41ee578 100644 --- a/src/sources/gaana.ts +++ b/src/sources/gaana.ts @@ -3,6 +3,7 @@ import { PassThrough } from 'node:stream' import HLSHandler from '../playback/hls/HLSHandler.ts' import { parse as parsePlaylist } from '../playback/hls/PlaylistParser.ts' import type { HLSSegment } from '../typings/playback/hls.types.ts' +import type { GaanaSourceConfig, NodelinkConfig } from '../typings/config/config.types.ts' import type { SourceResult, TrackInfo, @@ -91,8 +92,8 @@ export default class GaanaSource { public constructor(nodelink: WorkerNodeLink) { this.nodelink = nodelink - const sourceConfig = this.asRecord(this.nodelink.options.sources?.gaana) - this.config = sourceConfig || {} + const sourceConfig = this.nodelink.options.sources.gaana + this.config = sourceConfig this.searchTerms = ['gnsearch', 'gaanasearch'] this.patterns = [ /^@?(?:https?:\/\/)?(?:www\.)?gaana\.com\/(?song|album|playlist|artist)\/(?[\w-]+)(?:[?#].*)?$/ @@ -100,9 +101,9 @@ export default class GaanaSource { this.priority = 70 this.maxSearchResults = - this.asNumber(this.nodelink.options.maxSearchResults) ?? 10 + this.asNumber(this.nodelink.options.search.maxResults) ?? 10 const maxAlbumPlaylistLength = - this.asNumber(this.nodelink.options.maxAlbumPlaylistLength) ?? 100 + this.asNumber(this.nodelink.options.playback.maxPlaylistLength) ?? 100 this.playlistLoadLimit = this.asNumber(this.config.playlistLoadLimit) ?? maxAlbumPlaylistLength this.albumLoadLimit = @@ -1072,7 +1073,7 @@ export default class GaanaSource { password?: string } | undefined { - const proxy = this.asRecord(this.config.proxy) + const proxy = this.asRecord(this.config.network?.proxy) const url = this.asString(proxy?.url) if (!url) return undefined diff --git a/src/sources/googledrive.ts b/src/sources/googledrive.ts index 0ed59436..ea6f373f 100644 --- a/src/sources/googledrive.ts +++ b/src/sources/googledrive.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import { PassThrough, type Readable } from 'node:stream' +import type { GoogleDriveSourceConfig } from '../typings/config/config.types.ts' import type { SourceInstance, SourceResult, @@ -105,10 +106,9 @@ export default class GoogleDriveSource implements SourceInstance { cookiesStr = rawCookies.map((c) => String(c).split(';')[0]).join('; ') } - const driveConfig = this.nodelink.options.sources?.googledrive as - | Record - | undefined - const userCookies = driveConfig?.cookies as string | undefined + const driveConfig = this.nodelink.options.sources + ?.googledrive as GoogleDriveSourceConfig | undefined + const userCookies = driveConfig?.cookies if (userCookies) { cookiesStr = userCookies } diff --git a/src/sources/iheartradio.ts b/src/sources/iheartradio.ts index 3ceac33a..87f82920 100644 --- a/src/sources/iheartradio.ts +++ b/src/sources/iheartradio.ts @@ -45,6 +45,9 @@ interface IheartRuntimeOptions { * Maximum number of search results returned by the source. */ maxSearchResults?: number + search?: { + maxResults?: number + } } /** @@ -334,11 +337,12 @@ export default class IheartradioSource { const options = nodelink.options as IheartRuntimeOptions this.maxSearchResults = - typeof options.maxSearchResults === 'number' && - Number.isInteger(options.maxSearchResults) && - options.maxSearchResults > 0 - ? options.maxSearchResults + typeof options.search?.maxResults === 'number' && + Number.isInteger(options.search?.maxResults) && + options.search?.maxResults > 0 + ? options.search.maxResults : 10 + } /** diff --git a/src/sources/jiosaavn.ts b/src/sources/jiosaavn.ts index e05a83aa..df313a55 100644 --- a/src/sources/jiosaavn.ts +++ b/src/sources/jiosaavn.ts @@ -389,7 +389,7 @@ export default class JioSaavnSource { const { stream, error, statusCode } = await http1makeRequest(url, { method: 'GET', streamOnly: true, - proxy: this.config.proxy + proxy: this.config.network?.proxy }) if (error || statusCode !== 200 || !stream) { @@ -445,7 +445,7 @@ export default class JioSaavnSource { const { body, error, statusCode } = await http1makeRequest(url.toString(), { method: 'GET', headers: HEADERS, - proxy: this.config.proxy + proxy: this.config.network?.proxy }) if (error || statusCode !== 200) { diff --git a/src/sources/lastfm.ts b/src/sources/lastfm.ts index d12b0927..0714c06b 100644 --- a/src/sources/lastfm.ts +++ b/src/sources/lastfm.ts @@ -110,7 +110,7 @@ export default class LastFMSource { getMaxSearchResults(): number { const options = this.nodelink.options - const limit = options.maxSearchResults + const limit = options.search.maxResults return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10 diff --git a/src/sources/lazypytts.ts b/src/sources/lazypytts.ts index 10dc688b..f550bc02 100644 --- a/src/sources/lazypytts.ts +++ b/src/sources/lazypytts.ts @@ -76,7 +76,7 @@ export default class LazyPyTtsSource { const sourceConfig = this.nodelink.options.sources?.lazypytts this.config = sourceConfig && typeof sourceConfig === 'object' - ? (sourceConfig as Record) + ? (sourceConfig as unknown as Record) : {} this.searchTerms = ['lazypytts', 'lazytts'] this.patterns = [/^lazypytts:/i, /^lazytts:/i] diff --git a/src/sources/letrasmus.ts b/src/sources/letrasmus.ts index 1079a949..f23ad224 100644 --- a/src/sources/letrasmus.ts +++ b/src/sources/letrasmus.ts @@ -44,6 +44,9 @@ interface LetrasRuntimeOptions { * Maximum number of search results returned by the source. */ maxSearchResults?: number + search?: { + maxResults?: number + } } /** @@ -386,11 +389,12 @@ export default class LetrasMusSource { const options = nodelink.options as LetrasRuntimeOptions this.maxSearchResults = - typeof options.maxSearchResults === 'number' && - Number.isInteger(options.maxSearchResults) && - options.maxSearchResults > 0 - ? options.maxSearchResults + typeof options.search?.maxResults === 'number' && + Number.isInteger(options.search?.maxResults) && + options.search?.maxResults > 0 + ? options.search.maxResults : 10 + } /** diff --git a/src/sources/mixcloud.ts b/src/sources/mixcloud.ts index 75974e5f..33c0fdd8 100644 --- a/src/sources/mixcloud.ts +++ b/src/sources/mixcloud.ts @@ -279,7 +279,7 @@ export default class MixcloudSource { } }) .filter((track) => track.info.uri.length > 0) - .slice(0, this.config.maxSearchResults || DEFAULT_MAX_RESULTS) + .slice(0, this.config.search.maxResults || DEFAULT_MAX_RESULTS) if (tracks.length === 0) return this.emptyResult() @@ -415,7 +415,7 @@ export default class MixcloudSource { let hasNextPage = true let playlistName = 'Mixcloud Playlist' const maxTracks = - this.config.maxAlbumPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH + this.config.playback.maxPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH while (hasNextPage && tracks.length < maxTracks) { const response = await this._request(queryTemplate(cursor)) @@ -493,7 +493,7 @@ export default class MixcloudSource { let hasNextPage = true let userDisplayName = username const maxTracks = - this.config.maxAlbumPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH + this.config.playback.maxPlaylistLength || DEFAULT_MAX_PLAYLIST_LENGTH while (hasNextPage && tracks.length < maxTracks) { const response = await this._request(queryTemplate(cursor)) diff --git a/src/sources/monochrome.ts b/src/sources/monochrome.ts index 554c1fd5..65c1648d 100644 --- a/src/sources/monochrome.ts +++ b/src/sources/monochrome.ts @@ -56,7 +56,7 @@ class MonochromeSource implements SourceInstance { */ constructor(nodelink: WorkerNodeLink) { this.nodelink = nodelink - const sources = nodelink.options?.sources as + const sources = nodelink.options?.sources as unknown as | Record | undefined this.config = @@ -489,7 +489,7 @@ class MonochromeSource implements SourceInstance { while ( tracks.length < total && tracks.length < - ((this.nodelink.options.maxAlbumPlaylistLength as number) || 1000) + ((this.nodelink.options.playback.maxPlaylistLength as number) || 1000) ) { const res = await this.fetchWithRetry< MonochromeResponse<{ diff --git a/src/sources/netease.ts b/src/sources/netease.ts index 978c6b5b..62bdfd9d 100644 --- a/src/sources/netease.ts +++ b/src/sources/netease.ts @@ -103,7 +103,7 @@ export default class NeteaseSource { ] this.priority = 45 this.searchTerms = ['ntsearch'] - this.maxSearchResults = nodelink.options.maxSearchResults || 10 + this.maxSearchResults = nodelink.options.search.maxResults || 10 } /** diff --git a/src/sources/pandora.ts b/src/sources/pandora.ts index a4ace574..0e634393 100644 --- a/src/sources/pandora.ts +++ b/src/sources/pandora.ts @@ -376,7 +376,7 @@ export default class PandoraSource implements SourceInstance { types: ['TR'], listener: null, start: 0, - count: this.options.maxSearchResults ?? 10, + count: this.options.search.maxResults ?? 10, annotate: true, searchTime: 0, annotationRecipe: 'CLASS_OF_2019' @@ -559,7 +559,7 @@ export default class PandoraSource implements SourceInstance { * @internal */ private getMaxPlaylistLength(): number { - return (this.options.maxAlbumPlaylistLength as number | undefined) ?? 100 + return (this.options.playback.maxPlaylistLength as number | undefined) ?? 100 } /** diff --git a/src/sources/qobuz.ts b/src/sources/qobuz.ts index 7c2389e3..8971c08d 100644 --- a/src/sources/qobuz.ts +++ b/src/sources/qobuz.ts @@ -828,7 +828,7 @@ export default class QobuzSource { * @returns Max search size. */ private getMaxSearchResults(): number { - return this.asNumber(this.config.maxSearchResults) ?? 10 + return this.asNumber(this.config.search.maxResults) ?? 10 } /** @@ -836,7 +836,7 @@ export default class QobuzSource { * @returns Max playlist size. */ private getMaxAlbumPlaylistLength(): number { - return this.asNumber(this.config.maxAlbumPlaylistLength) ?? 100 + return this.asNumber(this.config.playback.maxPlaylistLength) ?? 100 } /** diff --git a/src/sources/shazam.ts b/src/sources/shazam.ts index 10ee27f5..0edd19a9 100644 --- a/src/sources/shazam.ts +++ b/src/sources/shazam.ts @@ -52,6 +52,9 @@ interface ShazamRuntimeOptions { * Global maximum search-result limit used by several sources. */ maxSearchResults?: number + search?: { + maxResults?: number + } /** * Source configuration keyed by source name. @@ -292,7 +295,7 @@ export default class ShazamSource { */ private getMaxSearchResults(): number { const options = this.nodelink.options as ShazamRuntimeOptions - const limit = options.maxSearchResults + const limit = options.search?.maxResults return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit diff --git a/src/sources/songlink.ts b/src/sources/songlink.ts index 71dc665f..ee0c1d88 100644 --- a/src/sources/songlink.ts +++ b/src/sources/songlink.ts @@ -1,3 +1,4 @@ +import type { NodelinkConfig, SourcesRegistry } from '../typings/config/config.types.ts' import type { SourceResult, TrackInfo, @@ -270,7 +271,7 @@ export default class SongLinkSource { _searchType = 'track' ): Promise { try { - const maxSearchRaw = this.nodelink.options.maxSearchResults + const maxSearchRaw = this.nodelink.options.search?.maxResults const limit = typeof maxSearchRaw === 'number' && Number.isFinite(maxSearchRaw) ? maxSearchRaw diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index 080de1e4..d09b0897 100644 --- a/src/sources/soundcloud.ts +++ b/src/sources/soundcloud.ts @@ -215,7 +215,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { const params = new URLSearchParams({ q: searchQuery, client_id: this.clientId ?? '', - limit: String(this.nodelink.options.maxSearchResults ?? 50), + limit: String(this.nodelink.options.search.maxResults ?? 50), offset: '0', linked_partitioning: '1' }) @@ -300,7 +300,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { _processUsers( collection: SoundCloudApiSearchItem[] ): SoundCloudTrackDataResult[] { - const max = this.nodelink.options.maxSearchResults ?? 50 + const max = this.nodelink.options.search.maxResults ?? 50 const users: SoundCloudTrackDataResult[] = [] for (let i = 0; i < collection.length && users.length < max; i++) { @@ -341,7 +341,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { _processAlbums( collection: SoundCloudApiSearchItem[] ): SoundCloudTrackDataResult[] { - const max = this.nodelink.options.maxSearchResults ?? 50 + const max = this.nodelink.options.search.maxResults ?? 50 const albums: SoundCloudTrackDataResult[] = [] for (let i = 0; i < collection.length && albums.length < max; i++) { @@ -382,7 +382,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { _processPlaylists( collection: SoundCloudApiSearchItem[] ): SoundCloudTrackDataResult[] { - const max = this.nodelink.options.maxSearchResults ?? 50 + const max = this.nodelink.options.search.maxResults ?? 50 const playlists: SoundCloudTrackDataResult[] = [] for (let i = 0; i < collection.length && playlists.length < max; i++) { @@ -423,7 +423,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { _processAll( collection: SoundCloudApiSearchItem[] ): SoundCloudTrackDataResult[] { - const max = this.nodelink.options.maxSearchResults ?? 50 + const max = this.nodelink.options.search.maxResults ?? 50 const results: SoundCloudTrackDataResult[] = [] for (let i = 0; i < collection.length && results.length < max; i++) { @@ -585,7 +585,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { } } - const limit = this.nodelink.options.maxAlbumPlaylistLength ?? 100 + const limit = this.nodelink.options.playback.maxPlaylistLength ?? 100 const neededIds = ids.slice(0, Math.max(0, limit - complete.length)) if (neededIds.length > 0) { @@ -638,7 +638,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { _processTracks( collection: SoundCloudApiSearchItem[] ): SoundCloudTrackDataResult[] { - const max = this.nodelink.options.maxSearchResults ?? 50 + const max = this.nodelink.options.search.maxResults ?? 50 const tracks: SoundCloudTrackDataResult[] = [] if (!Array.isArray(collection)) return [] diff --git a/src/sources/spotify.ts b/src/sources/spotify.ts index fcc121fd..51332768 100644 --- a/src/sources/spotify.ts +++ b/src/sources/spotify.ts @@ -1017,7 +1017,7 @@ export default class SpotifySource implements SourceInstance { */ private async _resolveAlbum(id: string): Promise { const maxTracks = - (this.nodelink.options.maxAlbumPlaylistLength as number) || 1000 + (this.nodelink.options.playback.maxPlaylistLength as number) || 1000 const tracks: TrackData[] = [] let name = 'Unknown Album' @@ -1118,7 +1118,7 @@ export default class SpotifySource implements SourceInstance { */ private async _resolvePlaylist(id: string): Promise { const maxTracks = - (this.nodelink.options.maxAlbumPlaylistLength as number) || 1000 + (this.nodelink.options.playback.maxPlaylistLength as number) || 1000 const tracks: TrackData[] = [] let name = 'Unknown Playlist' diff --git a/src/sources/tidal.ts b/src/sources/tidal.ts index d00b5f37..a9f1fc31 100644 --- a/src/sources/tidal.ts +++ b/src/sources/tidal.ts @@ -186,7 +186,7 @@ export default class TidalSource { } try { - const limit = this.asNumber(this.nodelink.options.maxSearchResults) ?? 10 + const limit = this.asNumber(this.nodelink.options.search.maxResults) ?? 10 const data = await this.getJson('search', { query, limit, diff --git a/src/sources/twitter.ts b/src/sources/twitter.ts index a3845f7d..6fa07aad 100644 --- a/src/sources/twitter.ts +++ b/src/sources/twitter.ts @@ -42,6 +42,9 @@ interface TwitterRuntimeOptions { * Global maximum search-result limit used by several sources. */ maxSearchResults?: number + search?: { + maxResults?: number + } } /** @@ -685,7 +688,7 @@ export default class TwitterSource { */ private getMaxSearchResults(): number { const options = this.nodelink.options as TwitterRuntimeOptions - const limit = options.maxSearchResults + const limit = options.search?.maxResults return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit diff --git a/src/sources/vkmusic.ts b/src/sources/vkmusic.ts index a6d14503..3a5cb220 100644 --- a/src/sources/vkmusic.ts +++ b/src/sources/vkmusic.ts @@ -215,7 +215,7 @@ export default class VKMusicSource implements SourceInstance { body: 'version=1&app_id=6287487', disableBodyCompression: true, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }) const body = res.body as { @@ -281,7 +281,7 @@ export default class VKMusicSource implements SourceInstance { try { const res = await this._apiRequest('audio.search', { q: query, - count: String((this.nodelink.options.maxSearchResults as number) || 10), + count: String((this.nodelink.options.search.maxResults as number) || 10), extended: '1' }) @@ -405,7 +405,7 @@ export default class VKMusicSource implements SourceInstance { owner_id: ownerId, extended: '1', count: String( - (this.nodelink.options.maxAlbumPlaylistLength as number) || 100 + (this.nodelink.options.playback.maxPlaylistLength as number) || 100 ) } if (playlistId) params.album_id = playlistId @@ -441,7 +441,7 @@ export default class VKMusicSource implements SourceInstance { try { const res = await http1makeRequest(url, { headers: { 'User-Agent': USER_AGENT, Cookie: this.cookie }, - proxy: this.config.proxy + proxy: this.config.network.proxy }) if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`) @@ -535,7 +535,7 @@ export default class VKMusicSource implements SourceInstance { try { const res = await http1makeRequest(url, { headers: { 'User-Agent': USER_AGENT, Cookie: this.cookie }, - proxy: this.config.proxy + proxy: this.config.network.proxy }) if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`) @@ -790,7 +790,7 @@ export default class VKMusicSource implements SourceInstance { type: 'mpegts', localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, startTime: (additionalData?.startTime as number) || 0, - proxy: this.config.proxy + proxy: this.config.network.proxy }), type: 'mpegts' } @@ -800,7 +800,7 @@ export default class VKMusicSource implements SourceInstance { method: 'GET', streamOnly: true, headers, - proxy: this.config.proxy + proxy: this.config.network.proxy }) if (res.error || !res.stream) @@ -840,7 +840,7 @@ export default class VKMusicSource implements SourceInstance { 'KateMobileAndroid/56 lite-460 (Android 4.4.2; SDK 19; x86; unknown Android SDK built for x86; en)' }, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }) const body = res.body as VKApiResponse diff --git a/src/sources/yandexmusic.ts b/src/sources/yandexmusic.ts index c5144008..6620d50a 100644 --- a/src/sources/yandexmusic.ts +++ b/src/sources/yandexmusic.ts @@ -270,7 +270,7 @@ export default class YandexMusicSource implements SourceInstance { if (!data) return { loadType: 'empty', data: {} } - const limit = (this.nodelink.options.maxSearchResults as number) || 10 + const limit = (this.nodelink.options.search.maxResults as number) || 10 if (searchType === 'album') { const albums = (data.albums?.results || []) @@ -494,7 +494,7 @@ export default class YandexMusicSource implements SourceInstance { streamOnly: true, headers, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }) } @@ -1068,7 +1068,7 @@ export default class YandexMusicSource implements SourceInstance { 'X-Yandex-Music-Client': CLIENT_HEADER }, localAddress: this.nodelink.routePlanner?.getIP?.() || undefined, - proxy: this.config.proxy + proxy: this.config.network.proxy }) if (res.statusCode !== 200) { @@ -1404,7 +1404,7 @@ export default class YandexMusicSource implements SourceInstance { const sm = this.nodelink.sources if (!sm) return false const config = ( - this.nodelink.options.sources as + this.nodelink.options.sources as unknown as | Record | undefined )?.[name] diff --git a/src/typings/index.types.ts b/src/typings/index.types.ts index f0fa8ffd..e86558ac 100644 --- a/src/typings/index.types.ts +++ b/src/typings/index.types.ts @@ -1230,6 +1230,9 @@ export interface ConnectionManagerContext { */ options: { connection?: import('./voice/connection.types.ts').ConnectionConfig + network?: { + connection?: import('./voice/connection.types.ts').ConnectionConfig + } } /** diff --git a/src/typings/meanings/meaning.types.ts b/src/typings/meanings/meaning.types.ts index aa63483d..8e597729 100644 --- a/src/typings/meanings/meaning.types.ts +++ b/src/typings/meanings/meaning.types.ts @@ -1,3 +1,5 @@ +import type { NodelinkConfig } from '../config/config.types.ts' + /** * Minimal track info used by meaning providers. * @public @@ -110,9 +112,7 @@ export interface MeaningSourceInstance { * @public */ export interface MeaningManagerContext { - options: Record & { - meanings?: Record - } + options: NodelinkConfig } /** diff --git a/src/typings/playback/hls.types.ts b/src/typings/playback/hls.types.ts index b4ae4919..fa49dcbb 100644 --- a/src/typings/playback/hls.types.ts +++ b/src/typings/playback/hls.types.ts @@ -241,6 +241,14 @@ export interface HLSHandlerOptions { username?: string password?: string } | null + /** Backward-compatible network block used by some callers. */ + network?: { + proxy?: { + url: string + username?: string + password?: string + } | null + } /** Callback for URL resolution (e.g., signature decryption) */ onResolveUrl?: ((url: string) => Promise) | null /** Fetch strategy: 'segmented' | 'streaming' | 'sequential' */ @@ -282,6 +290,14 @@ export interface SegmentFetcherOptions { username?: string password?: string } | null + /** Backward-compatible network block used by some callers. */ + network?: { + proxy?: { + url: string + username?: string + password?: string + } | null + } /** URL resolution callback */ onResolveUrl?: ((url: string) => Promise) | null } diff --git a/src/typings/playback/player.types.ts b/src/typings/playback/player.types.ts index 5d4e70fa..033a7b9e 100644 --- a/src/typings/playback/player.types.ts +++ b/src/typings/playback/player.types.ts @@ -301,6 +301,47 @@ export interface NodeLinkOptions { autoCleanup?: boolean } connection?: import('../voice/connection.types.ts').ConnectionConfig + search: { + maxResults?: number + defaultSource?: string | string[] + unifiedSources?: string[] + resolveExternalLinks?: boolean + fetchChannelInfo?: boolean + [key: string]: unknown + } + playback: { + maxPlaylistLength?: number + playerUpdateInterval?: number + statsUpdateInterval?: number + trackStuckThresholdMs?: number + eventTimeoutMs?: number + zombieThresholdMs?: number + sponsorblock?: NodeLinkOptions['sponsorblock'] + filters?: { enabled?: Record } + audio?: NodeLinkOptions['audio'] + voiceReceive?: { enabled?: boolean; format?: string } + mix?: NodeLinkOptions['mix'] + [key: string]: unknown + } + experimental: { + enableHoloTracks?: boolean + [key: string]: unknown + } + network: { + proxy?: { + enabled?: boolean + list?: unknown[] + [key: string]: unknown + } + connection?: import('../voice/connection.types.ts').ConnectionConfig + routePlanner?: { + strategy?: string + bannedIpCooldown?: number + ipBlocks?: Array + [key: string]: unknown + } + [key: string]: unknown + } } export type AudioOptionsWithTransitions = NonNullable< @@ -376,7 +417,7 @@ export type LoggerFn = (level: string, ...args: unknown[]) => void * NodeLink runtime context required by the player. */ export interface NodeLink { - options: Partial & { + options: NodeLinkOptions & { trackStuckThresholdMs: number playerUpdateInterval: number } diff --git a/src/typings/sources/source.types.ts b/src/typings/sources/source.types.ts index 4de73796..846498cc 100644 --- a/src/typings/sources/source.types.ts +++ b/src/typings/sources/source.types.ts @@ -5,6 +5,7 @@ import type { Readable } from 'node:stream' import type { Worker as NodeWorker } from 'node:worker_threads' +import type { NodelinkConfig } from '../config/config.types.ts' import type { StatsMetricsPayload, StatsSnapshot, @@ -544,16 +545,7 @@ export interface SourceInstance { */ export interface WorkerNodeLink { /** Configuration options */ - options: Record & { - sources?: Record - audio?: { - loudnessNormalizer?: boolean - resamplingQuality?: string - } - metrics?: { - enabled?: boolean - } - } + options: NodelinkConfig /** Logger function */ logger: LoggerFn /** Source manager instance */ @@ -568,6 +560,8 @@ export interface WorkerNodeLink { trackCacheManager?: TrackCacheManager /** Route planner manager instance */ routePlanner?: RoutePlannerManager + /** Proxy manager instance */ + proxyManager?: import('../../managers/proxyManager.ts').default /** Stats manager instance */ statsManager?: StatsManager /** Global plugin manager for hook execution. */ @@ -819,7 +813,7 @@ export interface RoutePlannerManager { /** Initialize route planner */ initialize?: () => Promise /** Dispose route planner */ - dispose?: () => Promise + dispose?: () => void | Promise /** Get an IP address */ getIP?: () => string | null | undefined } diff --git a/src/typings/sources/youtube.types.ts b/src/typings/sources/youtube.types.ts index 728c130b..414c6df6 100644 --- a/src/typings/sources/youtube.types.ts +++ b/src/typings/sources/youtube.types.ts @@ -194,11 +194,43 @@ export interface YouTubeContext { */ platform?: string + /** + * Operating system name identifier. + * @example 'Windows', 'Android', 'iOS', 'Linux' + */ + osName?: string + + /** + * Operating system version string. + * @example '10.0', '13', '14.1' + */ + osVersion?: string + /** * User agent string sent with API requests. * @example 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)...' */ userAgent?: string + + /** + * Client form factor. + */ + clientFormFactor?: string + + /** + * User interface theme. + */ + userInterfaceTheme?: string + + /** + * Browser name. + */ + browserName?: string + + /** + * Browser version string. + */ + browserVersion?: string } } diff --git a/src/typings/utils.types.ts b/src/typings/utils.types.ts index c73c0f9e..944e0db8 100644 --- a/src/typings/utils.types.ts +++ b/src/typings/utils.types.ts @@ -119,6 +119,10 @@ export interface HttpRequestOptions { maxRetries?: number /** Proxy settings for the request. */ proxy?: HttpProxyConfig + /** Backward-compatible network block used by some callers. */ + network?: { + proxy?: HttpProxyConfig + } } /** diff --git a/src/utils.ts b/src/utils.ts index ea494fef..46f47061 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1687,7 +1687,7 @@ async function makeRequest( return http1makeRequest(urlString, options) } - if (options.proxy) { + if (options.network?.proxy) { return http1makeRequest(urlString, options) } @@ -2140,11 +2140,11 @@ function applyEnvOverrides( * @returns Best matching candidate or null. * @public */ -function getBestMatch( - list: BestMatchCandidate[], +export function getBestMatch( + list: T[], original: BestMatchTrackInfo, options: BestMatchOptions = {} -): BestMatchCandidate | null { +): T | null { const { durationTolerance = 0.15, allowExplicit = true } = options const normalize = (str: string): string => { @@ -2396,7 +2396,6 @@ export { encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, - getBestMatch, getGitInfo, getStats, getVersion, diff --git a/src/workers/main.ts b/src/workers/main.ts index 96547814..7b8d000f 100644 --- a/src/workers/main.ts +++ b/src/workers/main.ts @@ -18,6 +18,7 @@ import RoutePlannerManager from '../managers/routePlannerManager.ts' import SourceManager from '../managers/sourceManager.ts' import StatsManager from '../managers/statsManager.ts' import TrackCacheManager from '../managers/trackCacheManager.ts' +import { migrateConfig } from '../modules/config/configMigration.ts' import { getWebmOpusProfilerStats } from '../playback/demuxers/WebmOpus.ts' import { bufferPool } from '../playback/structs/BufferPool.ts' import type { NodelinkConfig as NodeLinkConfig } from '../typings/config/config.types.ts' @@ -106,13 +107,60 @@ try { let config: NodeLinkConfig const resolveRootConfigUrl = (fileName: string): string => pathToFileURL(resolvePath(process.cwd(), fileName)).href -try { - config = (await import(resolveRootConfigUrl('config.js'))) - .default as unknown as NodeLinkConfig -} catch { - config = (await import(resolveRootConfigUrl('config.default.js'))) - .default as unknown as NodeLinkConfig +const resolveConfigExport = ( + importedModule: Record, + fileName: string +): Record => { + const candidate = ( + importedModule as { + default?: unknown + config?: unknown + } + ).default ?? ( + importedModule as { + default?: unknown + config?: unknown + } + ).config + + if ( + candidate && + typeof candidate === 'object' && + Object.keys(candidate as Record).length > 0 + ) { + return candidate as Record + } + + throw new Error( + `[ERROR] Config: ${fileName} must export a non-empty configuration object (default export or named "config").` + ) } + +const loadConfig = async (): Promise => { + const candidates = ['config.ts', 'config.js', 'config.default.ts', 'config.default.js'] + + for (const fileName of candidates) { + try { + const module = await import(resolveRootConfigUrl(fileName)) + const raw = resolveConfigExport(module as Record, fileName) + return migrateConfig(raw) as NodeLinkConfig + } catch (error) { + const err = error as { code?: string; message?: string } + const isNotFound = + err.code === 'ERR_MODULE_NOT_FOUND' || + err.code === 'ENOENT' || + err.message?.includes('Cannot find module') + if (isNotFound) continue + throw error + } + } + + throw new Error( + '[ERROR] Config: Failed to load configuration (config.ts/config.js/config.default.ts/config.default.js).' + ) +} + +config = await loadConfig() applyEnvOverrides(config as unknown as Record) const HIBERNATION_ENABLED = config.cluster?.hibernation?.enabled !== false @@ -1547,8 +1595,8 @@ const nodelink: WorkerNodeLink = { } as unknown as WorkerNodeLink const createdVoiceRelay = createVoiceRelay({ - enabled: config.voiceReceive?.enabled, - format: config.voiceReceive?.format, + enabled: config.playback.voiceReceive?.enabled, + format: config.playback.voiceReceive?.format, sendFrame: (frame: Buffer) => sendEventBinaryFrame(8, frame), logger }) @@ -1590,13 +1638,13 @@ function startTimers(hibernating = false): void { const updateInterval = hibernating ? 60000 - : (config?.playerUpdateInterval ?? 5000) + : (config?.playback.playerUpdateInterval ?? 5000) const statsInterval = hibernating ? 120000 - : config?.metrics?.enabled + : config?.api.metrics?.enabled ? 5000 - : (config?.statsUpdateInterval ?? 30000) - const zombieThreshold = config?.zombieThresholdMs ?? 60000 + : (config?.playback.statsUpdateInterval ?? 30000) + const zombieThreshold = config?.playback.zombieThresholdMs ?? 60000 playerUpdateTimer = setInterval(() => { if (!process.connected) return diff --git a/src/workers/source.ts b/src/workers/source.ts index 5541a91b..8695d300 100644 --- a/src/workers/source.ts +++ b/src/workers/source.ts @@ -17,7 +17,9 @@ import { import type PluginManager from '../managers/pluginManager.ts' import type { PluginManagerContext } from '../managers/pluginManager.ts' +import { migrateConfig } from '../modules/config/configMigration.ts' +import type { NodelinkConfig } from '../typings/config/config.types.ts' import type { NodeLink } from '../typings/playback/player.types.ts' import type { FrameType, @@ -100,11 +102,55 @@ if (isMainThread) { * @internal */ async function loadConfig(): Promise> { - try { - return (await import(resolveRootConfigUrl('config.js'))).default - } catch { - return (await import(resolveRootConfigUrl('config.default.js'))).default + const resolveConfigExport = ( + importedModule: Record, + fileName: string + ): Record => { + const candidate = ( + importedModule as { + default?: unknown + config?: unknown + } + ).default ?? ( + importedModule as { + default?: unknown + config?: unknown + } + ).config + + if ( + candidate && + typeof candidate === 'object' && + Object.keys(candidate as Record).length > 0 + ) { + return candidate as Record + } + + throw new Error( + `[ERROR] Config: ${fileName} must export a non-empty configuration object (default export or named "config").` + ) + } + + const candidates = ['config.ts', 'config.js', 'config.default.ts', 'config.default.js'] + for (const fileName of candidates) { + try { + const module = await import(resolveRootConfigUrl(fileName)) + const raw = resolveConfigExport(module as Record, fileName) + return migrateConfig(raw) + } catch (error) { + const err = error as { code?: string; message?: string } + const isNotFound = + err.code === 'ERR_MODULE_NOT_FOUND' || + err.code === 'ENOENT' || + err.message?.includes('Cannot find module') + if (isNotFound) continue + throw error + } } + + throw new Error( + '[ERROR] Config: Failed to load configuration (config.ts/config.js/config.default.ts/config.default.js).' + ) } const config = await loadConfig() @@ -120,7 +166,7 @@ if (isMainThread) { const nodelink: Pick = { - options: config, + options: config as unknown as NodelinkConfig, logger: utils.logger, pluginManager: null as unknown as PluginManager } From 75a77890e8c21c3daad5d4574c6fef61005a4419 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:23:44 -0400 Subject: [PATCH 24/58] update: implement configuration migration and cache management --- src/managers/baseCacheManager.ts | 419 ++++++++++++++++ src/managers/proxyManager.ts | 552 +++++++++++++++++++++ src/modules/config/configMigration.test.ts | 187 +++++++ src/modules/config/configMigration.ts | 231 +++++++++ 4 files changed, 1389 insertions(+) create mode 100644 src/managers/baseCacheManager.ts create mode 100644 src/managers/proxyManager.ts create mode 100644 src/modules/config/configMigration.test.ts create mode 100644 src/modules/config/configMigration.ts diff --git a/src/managers/baseCacheManager.ts b/src/managers/baseCacheManager.ts new file mode 100644 index 00000000..3daefde5 --- /dev/null +++ b/src/managers/baseCacheManager.ts @@ -0,0 +1,419 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import { logger } from '../utils.ts' + +export type BaseCacheEntry = { + value: T + createdAt: number + updatedAt: number + expiresAt: number | null +} + +export type BaseCachePayload = { + version: number + savedAt: number + entries: Record> +} + +export interface BaseCacheOptions { + name: string + passwordHashKey: string + salt: string + filePath: string + saveDelayMs?: number + cleanupIntervalMs?: number + maxEntries?: number + version?: number +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error ?? 'Unknown error') + +const getErrorCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object' || !('code' in error)) { + return undefined + } + const code = (error as NodeJS.ErrnoException).code + return typeof code === 'string' ? code : undefined +} + +/** + * Generic encrypted cache file store using AES-256-GCM. + * Handles atomic saves, TTL purging, and max entry eviction. + */ +export default abstract class BaseCacheManager { + protected readonly cacheName: string + private readonly passwordHashKey: string + private readonly salt: string + private key: Buffer + private legacyKey: Buffer | null + protected readonly filePath: string + protected readonly tempFilePath: string + protected readonly saveDelayMs: number + protected readonly cleanupIntervalMs: number + protected readonly maxEntries: number + protected readonly version: number + + protected cache: Map> + private saveTimeout: NodeJS.Timeout | null + private cleanupInterval: NodeJS.Timeout | null + private savePromise: Promise | null + private saveQueued: boolean + + protected lastLoadedAt: number | null + protected lastSavedAt: number | null + + constructor(options: BaseCacheOptions) { + this.cacheName = options.name + this.passwordHashKey = options.passwordHashKey + this.salt = options.salt + this.filePath = options.filePath + this.tempFilePath = `${options.filePath}.tmp` + this.saveDelayMs = options.saveDelayMs ?? 5000 + this.cleanupIntervalMs = options.cleanupIntervalMs ?? 60000 + this.maxEntries = options.maxEntries ?? 0 + this.version = options.version ?? 1 + + this.key = this._deriveFastKey(this.passwordHashKey) + this.legacyKey = null + this.cache = new Map() + + this.saveTimeout = null + this.savePromise = null + this.saveQueued = false + this.lastLoadedAt = null + this.lastSavedAt = null + + this.cleanupInterval = setInterval(() => { + const expiredCount = this._purgeExpired() + const evictedCount = this._enforceMaxEntries() + if (expiredCount > 0 || evictedCount > 0) this.save() + }, this.cleanupIntervalMs) + this.cleanupInterval.unref?.() + } + + async load(): Promise { + try { + const data = await fs.readFile(this.filePath) + if (data.length < 32) return + + let payload: BaseCachePayload + let migratedFromLegacy = false + + try { + payload = this._decodePayload(data, this.key) + } catch { + const legacyKey = this._getLegacyKey() + payload = this._decodePayload(data, legacyKey) + migratedFromLegacy = true + } + + this.cache = new Map(Object.entries(payload.entries)) + + const expiredCount = this._purgeExpired() + const evictedCount = this._enforceMaxEntries() + if (expiredCount > 0 || evictedCount > 0 || migratedFromLegacy) + this.save() + + this.lastLoadedAt = Date.now() + logger( + 'debug', + this.cacheName, + `Loaded ${this.cache.size} entries from disk.` + ) + } catch (error) { + const code = getErrorCode(error) + if (code !== 'ENOENT') { + logger( + 'error', + this.cacheName, + `Failed to load cache: ${getErrorMessage(error)}` + ) + } + this.cache = new Map() + } + } + + save(): void { + if (this.saveTimeout) return + + this.saveTimeout = setTimeout(() => { + this.saveTimeout = null + void this.forceSave() + }, this.saveDelayMs) + } + + async forceSave(): Promise { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout) + this.saveTimeout = null + } + + try { + this._purgeExpired() + this._enforceMaxEntries() + await this._flushSaveQueue() + logger('debug', this.cacheName, 'Force saved to disk.') + } catch (error) { + logger( + 'error', + this.cacheName, + `Failed to force save: ${getErrorMessage(error)}` + ) + } + } + + clear(): void { + if (this.cache.size === 0) return + this.cache.clear() + this.save() + } + + /** + * Cleans up internal timers and resources. + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + if (this.saveTimeout) { + clearTimeout(this.saveTimeout) + this.saveTimeout = null + } + } + + protected _getValidEntry(key: string): BaseCacheEntry | null { + const entry = this.cache.get(key) + if (!entry) return null + if (entry.expiresAt && Date.now() > entry.expiresAt) { + this.cache.delete(key) + this.save() + return null + } + + if (this.maxEntries > 0) { + this.cache.delete(key) + this.cache.set(key, entry) + } + + return entry + } + + private _deriveFastKey(password: string): Buffer { + return crypto + .createHash('sha256') + .update(`${this.salt}:${password}`) + .digest() + } + + private _getLegacyKey(): Buffer { + if (!this.legacyKey) { + this.legacyKey = crypto.scryptSync(this.passwordHashKey, this.salt, 32) + } + return this.legacyKey + } + + private _purgeExpired(): number { + const now = Date.now() + let expiredCount = 0 + for (const [key, entry] of this.cache.entries()) { + if (entry.expiresAt && now > entry.expiresAt) { + this.cache.delete(key) + expiredCount++ + } + } + return expiredCount + } + + protected _enforceMaxEntries(): number { + if (this.maxEntries <= 0 || this.cache.size <= this.maxEntries) return 0 + + let removed = 0 + while (this.cache.size > this.maxEntries) { + const oldestKey = this.cache.keys().next().value + if (!oldestKey) break + this.cache.delete(oldestKey) + removed++ + } + return removed + } + + private _decodePayload(data: Buffer, key: Buffer): BaseCachePayload { + const iv = data.subarray(0, 16) + const tag = data.subarray(16, 32) + const encrypted = data.subarray(32) + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]).toString('utf8') + const parsed = JSON.parse(decrypted) as unknown + return this._normalizePayload(parsed) + } + + private _normalizePayload(raw: unknown): BaseCachePayload { + const now = Date.now() + + if (isRecord(raw) && !('version' in raw) && !('entries' in raw)) { + return { + version: this.version, + savedAt: now, + entries: this._normalizeEntries(raw, now) + } + } + + if (isRecord(raw)) { + const payloadCandidate = raw as Partial> & { + entries?: unknown + savedAt?: unknown + } + const entriesValue = payloadCandidate.entries + if (isRecord(entriesValue)) { + return { + version: payloadCandidate.version ?? this.version, + savedAt: + typeof payloadCandidate.savedAt === 'number' + ? payloadCandidate.savedAt + : now, + entries: this._normalizeEntries(entriesValue, now) + } + } + } + + return { + version: this.version, + savedAt: now, + entries: {} + } + } + + private _normalizeEntries( + rawEntries: Record, + fallbackTime: number + ): Record> { + const entries: Record> = {} + for (const [key, value] of Object.entries(rawEntries)) { + entries[key] = this._normalizeEntry(value, fallbackTime) + } + return entries + } + + private _normalizeEntry( + rawValue: unknown, + fallbackTime: number + ): BaseCacheEntry { + if (isRecord(rawValue)) { + const entryCandidate = rawValue as Partial> & { + value?: unknown + } + const value = Object.hasOwn(entryCandidate, 'value') + ? entryCandidate.value + : rawValue + + const createdAt = + typeof entryCandidate.createdAt === 'number' + ? entryCandidate.createdAt + : fallbackTime + const updatedAt = + typeof entryCandidate.updatedAt === 'number' + ? entryCandidate.updatedAt + : createdAt + const expiresAt = + typeof entryCandidate.expiresAt === 'number' && + entryCandidate.expiresAt > 0 + ? entryCandidate.expiresAt + : null + + return { + value: value as T, + createdAt, + updatedAt, + expiresAt + } + } + + return { + value: rawValue as T, + createdAt: fallbackTime, + updatedAt: fallbackTime, + expiresAt: null + } + } + + private _buildPayload(): BaseCachePayload { + return { + version: this.version, + savedAt: Date.now(), + entries: Object.fromEntries(this.cache) + } + } + + private async _flushSaveQueue(): Promise { + if (this.savePromise) { + this.saveQueued = true + await this.savePromise + if (this.saveQueued) { + this.saveQueued = false + await this._flushSaveQueue() + } + return + } + + const payload = this._buildPayload() + this.savePromise = this._writeToDisk(payload) + try { + await this.savePromise + } finally { + this.savePromise = null + } + + if (this.saveQueued) { + this.saveQueued = false + await this._flushSaveQueue() + } + } + + private async _writeToDisk(payload: BaseCachePayload): Promise { + const plainText = JSON.stringify(payload) + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv) + + const encrypted = Buffer.concat([ + cipher.update(plainText, 'utf8'), + cipher.final() + ]) + const tag = cipher.getAuthTag() + const outBuffer = Buffer.concat([iv, tag, encrypted]) + + await fs.mkdir('./.cache', { recursive: true }) + try { + await fs.writeFile(this.tempFilePath, outBuffer) + await fs.rename(this.tempFilePath, this.filePath) + } catch (error) { + logger( + 'debug', + this.cacheName, + `Atomic save failed, falling back to direct write: ${getErrorMessage(error)}` + ) + await fs.writeFile(this.filePath, outBuffer) + try { + await fs.unlink(this.tempFilePath) + } catch (cleanupError) { + logger( + 'debug', + this.cacheName, + `Failed to remove temp cache file: ${getErrorMessage(cleanupError)}` + ) + } + } + + this.lastSavedAt = payload.savedAt + } +} diff --git a/src/managers/proxyManager.ts b/src/managers/proxyManager.ts new file mode 100644 index 00000000..987459ad --- /dev/null +++ b/src/managers/proxyManager.ts @@ -0,0 +1,552 @@ +import { logger } from '../utils.ts' + +export type ProxyProtocol = 'http' | 'https' | 'socks4' | 'socks5' + +export interface ProxyTarget { + id: string + url: string + protocol: ProxyProtocol + type: 'forward' | 'reverse' + username?: string + password?: string + host: string + port: number +} + +export interface ProxyHealthState { + status: 'UP' | 'DOWN' | 'COOLDOWN' + consecutiveFailures: number + totalFailures: number + totalSuccesses: number + activeConnections: number + movingAverageLatency: number + lastFailureAt: number | null + lastSuccessAt: number | null + cooldownEndsAt: number | null +} + +export interface ProxySnapshot { + target: ProxyTarget + state: ProxyHealthState +} + +export type ProxyStrategy = + | 'RoundRobin' + | 'LeastConnections' + | 'LowestLatency' + | 'WeightedHealth' + +export interface ProxyManagerConfig { + enabled: boolean + strategy: ProxyStrategy + disabledSources: string[] + maxConsecutiveFailures: number + cooldownDurationMs: number + latencyWeight: number +} + +const DEFAULT_CONFIG: ProxyManagerConfig = { + enabled: false, + strategy: 'WeightedHealth', + disabledSources: [], + maxConsecutiveFailures: 3, + cooldownDurationMs: 60 * 1000 * 5, // 5 minutes + latencyWeight: 0.2 // EMA alpha +} + +const isRecord = (val: unknown): val is Record => + typeof val === 'object' && val !== null && !Array.isArray(val) + +export default class ProxyManager { + private readonly config: ProxyManagerConfig + private readonly targets: Map + private readonly states: Map + private roundRobinIndex: number = 0 + + constructor(options: Record) { + this.targets = new Map() + this.states = new Map() + + const network = isRecord(options.network) ? options.network : {} + const rawConfig = isRecord(network.proxy) ? network.proxy : {} + this.config = this.parseConfig(rawConfig) + + this.extractTargets(rawConfig, options) + } + + /** + * Evaluates if a given source is allowed to use proxies. + */ + public isEnabledForSource(sourceName: string): boolean { + if (!this.config.enabled) return false + if (this.config.disabledSources.includes(sourceName.toLowerCase())) + return false + return this.targets.size > 0 + } + + /** + * Selects the most optimal proxy based on the configured Load Balancing strategy. + * Employs a Circuit Breaker pattern to avoid dead proxies. + * + * @param sourceName - The identifier of the requesting source (e.g. 'youtube', 'bilibili') + * @returns A safe snapshot of the selected proxy, or `undefined` if none available. + */ + public getBestProxy(sourceName: string): ProxySnapshot | undefined { + if (!this.isEnabledForSource(sourceName)) return undefined + + this.processCooldowns() + + const availableIds = Array.from(this.states.entries()) + .filter(([_, state]) => state.status === 'UP') + .map(([id]) => id) + + if (availableIds.length === 0) { + logger( + 'warn', + 'ProxyManager', + `No healthy proxies available for source '${sourceName}'. All proxies are DOWN or in COOLDOWN.` + ) + return undefined + } + + let selectedId: string | undefined + + switch (this.config.strategy) { + case 'RoundRobin': + selectedId = this.selectRoundRobin(availableIds) + break + case 'LeastConnections': + selectedId = this.selectLeastConnections(availableIds) + break + case 'LowestLatency': + selectedId = this.selectLowestLatency(availableIds) + break + case 'WeightedHealth': + selectedId = this.selectWeightedHealth(availableIds) + break + default: + selectedId = this.selectWeightedHealth(availableIds) + } + + if (!selectedId) return undefined + + const state = this.states.get(selectedId) + const target = this.targets.get(selectedId) + + if (!state || !target) return undefined + + state.activeConnections++ + + return { + target: { ...target }, + state: { ...state } + } + } + + /** + * Reports the resolution of a request utilizing a proxy. + * Updates health metrics, calculates exponential moving average latency, + * and trips the circuit breaker if thresholds are met. + */ + public report( + proxyUrl: string, + success: boolean, + statusCode: number, + latencyMs: number = 0 + ): void { + const id = this.findIdByUrl(proxyUrl) + if (!id) return + + const state = this.states.get(id) + if (!state) return + + if (state.activeConnections > 0) { + state.activeConnections-- + } + + if (success) { + this.handleSuccess(state, latencyMs) + } else { + this.handleFailure(state, statusCode) + } + } + + /** + * Allows adding dynamic proxies in runtime. + */ + public addProxy(url: string, protocol: ProxyProtocol = 'http'): void { + try { + const parsed = this.parseProxyUrl(url, protocol) + if (!this.targets.has(parsed.id)) { + this.targets.set(parsed.id, parsed) + this.states.set(parsed.id, this.createInitialState()) + logger( + 'info', + 'ProxyManager', + `Dynamically added proxy: ${parsed.host}:${parsed.port}` + ) + } + } catch (err) { + logger( + 'error', + 'ProxyManager', + `Failed to add proxy ${url}: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + + /** + * Cleans up proxy manager resources. + */ + public destroy(): void { + this.targets.clear() + this.states.clear() + } + + private handleSuccess(state: ProxyHealthState, latencyMs: number): void { + state.totalSuccesses++ + state.consecutiveFailures = 0 + state.lastSuccessAt = Date.now() + + if (latencyMs > 0) { + if (state.movingAverageLatency === 0) { + state.movingAverageLatency = latencyMs + } else { + const alpha = this.config.latencyWeight + state.movingAverageLatency = + alpha * latencyMs + (1 - alpha) * state.movingAverageLatency + } + } + + if (state.status === 'DOWN' || state.status === 'COOLDOWN') { + state.status = 'UP' + state.cooldownEndsAt = null + } + } + + private handleFailure(state: ProxyHealthState, statusCode: number): void { + state.totalFailures++ + state.consecutiveFailures++ + state.lastFailureAt = Date.now() + + // Status code severity analysis + let failureWeight = 1 + if (statusCode === 403 || statusCode === 429) { + failureWeight = 3 // Severe rate limit or ban + } else if (statusCode >= 500) { + failureWeight = 2 // Server side error + } + + state.consecutiveFailures += failureWeight - 1 + + if (state.consecutiveFailures >= this.config.maxConsecutiveFailures) { + this.tripCircuitBreaker(state) + } + } + + private tripCircuitBreaker(state: ProxyHealthState): void { + state.status = 'COOLDOWN' + state.cooldownEndsAt = Date.now() + this.config.cooldownDurationMs + logger( + 'warn', + 'ProxyManager', + `Circuit breaker tripped for proxy. Cooling down for ${this.config.cooldownDurationMs / 1000}s.` + ) + } + + private processCooldowns(): void { + const now = Date.now() + for (const state of this.states.values()) { + if ( + state.status === 'COOLDOWN' && + state.cooldownEndsAt && + now >= state.cooldownEndsAt + ) { + // Half-open state: transition back to UP to test it. If it fails once, it will trip again quickly. + state.status = 'UP' + state.cooldownEndsAt = null + // We reduce consecutive failures so it gets one more chance before tripping again + state.consecutiveFailures = Math.max( + 0, + this.config.maxConsecutiveFailures - 1 + ) + logger( + 'info', + 'ProxyManager', + 'Proxy exited cooldown and is entering half-open state.' + ) + } + } + } + + private selectRoundRobin(availableIds: string[]): string { + if (this.roundRobinIndex >= availableIds.length) { + this.roundRobinIndex = 0 + } + const selected = availableIds[this.roundRobinIndex] + this.roundRobinIndex = (this.roundRobinIndex + 1) % availableIds.length + return selected || availableIds[0] || '' + } + + private selectLeastConnections(availableIds: string[]): string { + return availableIds.reduce((prevId, currId) => { + const prevConnections = this.states.get(prevId)?.activeConnections ?? 0 + const currConnections = this.states.get(currId)?.activeConnections ?? 0 + return currConnections < prevConnections ? currId : prevId + }) + } + + private selectLowestLatency(availableIds: string[]): string { + return availableIds.reduce((prevId, currId) => { + const prevLatency = + this.states.get(prevId)?.movingAverageLatency || Infinity + const currLatency = + this.states.get(currId)?.movingAverageLatency || Infinity + + // If both are 0 (untested), fallback to random or first + if (prevLatency === Infinity && currLatency === Infinity) return prevId + + return currLatency < prevLatency ? currId : prevId + }) + } + + private selectWeightedHealth(availableIds: string[]): string { + // Sort by a composite score: (failures * high_penalty) + (active_connections * medium_penalty) + latency + const sorted = [...availableIds].sort((a, b) => { + const stateA = this.states.get(a) + const stateB = this.states.get(b) + + if (!stateA || !stateB) return 0 + + const scoreA = + stateA.consecutiveFailures * 1000 + + stateA.activeConnections * 100 + + (stateA.movingAverageLatency || 500) + + const scoreB = + stateB.consecutiveFailures * 1000 + + stateB.activeConnections * 100 + + (stateB.movingAverageLatency || 500) + + return scoreA - scoreB + }) + + // Pick from the top 3 randomly to distribute load while still preferring healthy proxies + const poolSize = Math.min(3, sorted.length) + const randomIndex = Math.floor(Math.random() * poolSize) + return sorted[randomIndex] || availableIds[0] || '' + } + + private findIdByUrl(url: string): string | undefined { + for (const [id, target] of this.targets.entries()) { + if (target.url === url) return id + } + return undefined + } + + private parseConfig(raw: Record): ProxyManagerConfig { + const disabledSourcesCandidate = Array.isArray(raw.disabledSources) + ? raw.disabledSources + .filter((s) => typeof s === 'string') + .map((s) => (s as string).toLowerCase()) + : DEFAULT_CONFIG.disabledSources + + let strategy = DEFAULT_CONFIG.strategy + if (typeof raw.strategy === 'string') { + const s = raw.strategy as ProxyStrategy + if ( + [ + 'RoundRobin', + 'LeastConnections', + 'LowestLatency', + 'WeightedHealth' + ].includes(s) + ) { + strategy = s + } + } + + return { + enabled: + typeof raw.enabled === 'boolean' ? raw.enabled : DEFAULT_CONFIG.enabled, + strategy, + disabledSources: disabledSourcesCandidate, + maxConsecutiveFailures: + typeof raw.maxConsecutiveFailures === 'number' + ? raw.maxConsecutiveFailures + : DEFAULT_CONFIG.maxConsecutiveFailures, + cooldownDurationMs: + typeof raw.cooldownDurationMs === 'number' + ? raw.cooldownDurationMs + : DEFAULT_CONFIG.cooldownDurationMs, + latencyWeight: + typeof raw.latencyWeight === 'number' + ? raw.latencyWeight + : DEFAULT_CONFIG.latencyWeight + } + } + + private extractTargets( + rawConfig: Record, + fullOptions: Record + ): void { + // 1. Check global proxy object (single) + if (typeof rawConfig.url === 'string' && rawConfig.url.length > 0) { + try { + const parsed = this.parseProxyUrl( + rawConfig.url, + (rawConfig.protocol as string) || 'http', + rawConfig.username as string, + rawConfig.password as string, + rawConfig.type as string + ) + this.targets.set(parsed.id, parsed) + } catch (err) { + logger( + 'error', + 'ProxyManager', + `Invalid global proxy URL: ${getErrorMessage(err)}` + ) + } + } + + // 2. Check global proxy list + if (Array.isArray(rawConfig.list)) { + for (const item of rawConfig.list) { + try { + if (typeof item === 'string') { + const parsed = this.parseProxyUrl(item) + this.targets.set(parsed.id, parsed) + } else if (isRecord(item) && typeof item.url === 'string') { + const parsed = this.parseProxyUrl( + item.url, + item.protocol as string, + item.username as string, + item.password as string, + item.type as string + ) + this.targets.set(parsed.id, parsed) + } + } catch (err) { + logger( + 'error', + 'ProxyManager', + `Invalid proxy in list: ${getErrorMessage(err)}` + ) + } + } + } + + // 3. Fallback: Migrate old YouTube proxies into the global pool if global list is empty + if (this.targets.size === 0) { + const sources = isRecord(fullOptions.sources) ? fullOptions.sources : {} + const yt = isRecord(sources.youtube) ? sources.youtube : {} + + if (Array.isArray(yt.proxies)) { + for (const item of yt.proxies) { + try { + if (typeof item === 'string') { + const parsed = this.parseProxyUrl(item) + this.targets.set(parsed.id, parsed) + } else if (isRecord(item) && typeof item.url === 'string') { + const parsed = this.parseProxyUrl( + item.url, + undefined, + undefined, + undefined, + item.type as string + ) + this.targets.set(parsed.id, parsed) + } + } catch (err) { + logger( + 'error', + 'ProxyManager', + `Invalid youtube fallback proxy: ${getErrorMessage(err)}` + ) + } + } + } + + // If we inherited proxies from youtube, implicitly enable the global manager + if (this.targets.size > 0 && typeof rawConfig.enabled !== 'boolean') { + this.config.enabled = true + logger( + 'info', + 'ProxyManager', + `Automatically enabled global ProxyManager using inherited YouTube proxies. (${this.targets.size} found)` + ) + } + } + + for (const id of this.targets.keys()) { + this.states.set(id, this.createInitialState()) + } + } + + private parseProxyUrl( + urlString: string, + defaultProtocol = 'http', + explicitUsername?: string, + explicitPassword?: string, + explicitType?: string + ): ProxyTarget { + let toParse = urlString + if (!toParse.includes('://')) { + toParse = `${defaultProtocol}://${toParse}` + } + + const url = new URL(toParse) + + let protocol = url.protocol.replace(':', '') as ProxyProtocol + if (!['http', 'https', 'socks4', 'socks5'].includes(protocol)) { + protocol = 'http' + } + + const username = + explicitUsername || + (url.username ? decodeURIComponent(url.username) : undefined) + const password = + explicitPassword || + (url.password ? decodeURIComponent(url.password) : undefined) + const host = url.hostname + const port = url.port + ? parseInt(url.port, 10) + : protocol === 'https' + ? 443 + : 80 + const typeStr = explicitType === 'reverse' ? 'reverse' : 'forward' + + const id = `${protocol}://${host}:${port}` + + return { + id, + url: toParse, + protocol, + type: typeStr as 'forward' | 'reverse', + host, + port, + username, + password + } + } + + private createInitialState(): ProxyHealthState { + return { + status: 'UP', + consecutiveFailures: 0, + totalFailures: 0, + totalSuccesses: 0, + activeConnections: 0, + movingAverageLatency: 0, + lastFailureAt: null, + lastSuccessAt: null, + cooldownEndsAt: null + } + } +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} diff --git a/src/modules/config/configMigration.test.ts b/src/modules/config/configMigration.test.ts new file mode 100644 index 00000000..b8e51a14 --- /dev/null +++ b/src/modules/config/configMigration.test.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import defaultConfig from '../../../config.default.ts' +import type { NodelinkConfig } from '../../typings/config/config.types.ts' +import { migrateConfig, toTsLiteral } from './configMigration.ts' + +test('migrateConfig maps legacy flat config to hierarchical structure', () => { + const legacy = { + server: { + host: '0.0.0.0', + port: 3000, + password: 'legacy-pass', + useBunServer: false + }, + connection: { + interval: 12345, + timeout: 6789, + thresholds: { bad: 1, average: 5 }, + logAllChecks: true + }, + routePlanner: { + strategy: 'RotateOnBan', + bannedIpCooldown: 1000, + ipBlocks: ['1.1.1.0/24'] + }, + maxSearchResults: 25, + defaultSearchSource: ['youtube'], + unifiedSearchSources: ['youtube', 'soundcloud'], + resolveExternalLinks: true, + fetchChannelInfo: true, + maxAlbumPlaylistLength: 777, + playerUpdateInterval: 3000, + statsUpdateInterval: 15000, + trackStuckThresholdMs: 45000, + eventTimeoutMs: 3333, + zombieThresholdMs: 99999, + sponsorblock: { + enabled: true, + api: 'x', + categories: [], + actionTypes: ['skip'], + skipMarginMs: 1 + }, + filters: { enabled: { tremolo: true } }, + audio: { quality: 'high' }, + voiceReceive: { enabled: true, format: 'opus' }, + mix: { + enabled: true, + defaultVolume: 0.8, + maxLayersMix: 5, + autoCleanup: true + }, + enableTrackStreamEndpoint: true, + enableLoadStreamEndpoint: true, + dosProtection: { enabled: true }, + rateLimit: { enabled: true }, + metrics: { enabled: true }, + enableHoloTracks: true, + cluster: { + commandTimeout: 6000, + fastCommandTimeout: 4000 + } + } + + const migrated = migrateConfig( + legacy as Record + ) as unknown as NodelinkConfig + + assert.equal(migrated.search.maxResults, 25) + assert.deepEqual(migrated.search.defaultSource, ['youtube']) + assert.deepEqual(migrated.search.unifiedSources, ['youtube', 'soundcloud']) + assert.equal(migrated.search.resolveExternalLinks, true) + assert.equal(migrated.search.fetchChannelInfo, true) + + assert.equal(migrated.playback.maxPlaylistLength, 777) + assert.equal(migrated.playback.playerUpdateInterval, 3000) + assert.equal(migrated.playback.statsUpdateInterval, 15000) + assert.equal(migrated.playback.trackStuckThresholdMs, 45000) + assert.equal(migrated.playback.eventTimeoutMs, 3333) + assert.equal(migrated.playback.zombieThresholdMs, 99999) + assert.equal(migrated.playback.voiceReceive.enabled, true) + + assert.equal(migrated.api.enableTrackStreamEndpoint, true) + assert.equal(migrated.api.enableLoadStreamEndpoint, true) + assert.equal(migrated.experimental.enableHoloTracks, true) + + assert.deepEqual(migrated.network.connection.interval, 12345) + assert.deepEqual(migrated.network.routePlanner.ipBlocks, ['1.1.1.0/24']) + + assert.equal(migrated.cluster.timeouts?.heavyMs, 6000) + assert.equal(migrated.cluster.timeouts?.fastMs, 4000) + assert.equal(migrated.server.password, 'legacy-pass') +}) + +test('migrateConfig preserves explicit hierarchical values over legacy duplicates', () => { + const hybrid = { + server: { password: 'server-password' }, + trustProxy: true, + maxSearchResults: 10, + search: { maxResults: 50 }, + cluster: { + commandTimeout: 6000, + fastCommandTimeout: 4000, + timeouts: { heavyMs: 9000, fastMs: 7000 } + }, + network: { + proxy: { + enabled: true, + strategy: 'RoundRobin', + retries: 1, + timeout: 1, + shuffleOnStart: false, + list: [] + } + } + } + + const migrated = migrateConfig( + hybrid as Record + ) as unknown as NodelinkConfig + + assert.equal(migrated.server.password, 'server-password') + assert.equal(migrated.search.maxResults, 50) + assert.equal(migrated.cluster.timeouts?.heavyMs, 9000) + assert.equal(migrated.cluster.timeouts?.fastMs, 7000) + assert.equal(migrated.network.proxy.enabled, true) +}) + +test('migrateConfig fills required compatibility defaults', () => { + const minimalLegacy = { + server: { password: 'abc' }, + cluster: { commandTimeout: 1000, fastCommandTimeout: 2000 } + } + + const migrated = migrateConfig( + minimalLegacy as Record + ) as unknown as NodelinkConfig + assert.equal(migrated.server.password, 'abc') + assert.equal(migrated.cluster.timeouts?.heavyMs, 1000) + assert.equal(migrated.cluster.timeouts?.fastMs, 2000) + assert.equal(migrated.network.proxy.enabled, false) + assert.deepEqual(migrated.network.proxy.list, []) +}) + +test('migrateConfig keeps vanilla default config valid', () => { + const migrated = migrateConfig( + defaultConfig as unknown as Record + ) as unknown as NodelinkConfig + + assert.equal(migrated.server.host, defaultConfig.server.host) + assert.equal(migrated.search.maxResults, defaultConfig.search.maxResults) + assert.equal( + migrated.playback.playerUpdateInterval, + defaultConfig.playback.playerUpdateInterval + ) + assert.equal( + migrated.api.enableTrackStreamEndpoint, + defaultConfig.api.enableTrackStreamEndpoint + ) + assert.equal(migrated.server.password, defaultConfig.server.password) +}) + +test('toTsLiteral generates idiomatic TypeScript formatting', () => { + const input = { + a: 1, + 'b-c': 'val', + d: [1, { e: true }], + f: null, + g: "'quotes'" + } + + const expected = `{ + a: 1, + 'b-c': 'val', + d: [ + 1, + { + e: true + } + ], + f: null, + g: '\\'quotes\\'' +}` + + assert.equal(toTsLiteral(input as Record), expected) +}) diff --git a/src/modules/config/configMigration.ts b/src/modules/config/configMigration.ts new file mode 100644 index 00000000..1c9a5de4 --- /dev/null +++ b/src/modules/config/configMigration.ts @@ -0,0 +1,231 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import type { JsonValue } from '../../typings/config/config.types.ts' + +/** + * Migrates old flat configuration structures to the new hierarchical NodeLink schema. + */ +export function migrateConfig( + oldConfig: Record +): Record { + const newConfig: Record = JSON.parse( + JSON.stringify(oldConfig) + ) + + const migrationMap: Record = { + host: 'server.host', + port: 'server.port', + password: 'server.password', + useBunServer: 'server.useBunServer', + trustProxy: 'trustProxy', + connection: 'connection', + proxy: 'network.proxy', + routePlanner: 'network.routePlanner', + maxSearchResults: 'search.maxResults', + defaultSearchSource: 'search.defaultSource', + unifiedSearchSources: 'search.unifiedSources', + resolveExternalLinks: 'search.resolveExternalLinks', + fetchChannelInfo: 'search.fetchChannelInfo', + maxAlbumPlaylistLength: 'playback.maxPlaylistLength', + playerUpdateInterval: 'playback.playerUpdateInterval', + statsUpdateInterval: 'playback.statsUpdateInterval', + trackStuckThresholdMs: 'playback.trackStuckThresholdMs', + eventTimeoutMs: 'playback.eventTimeoutMs', + zombieThresholdMs: 'playback.zombieThresholdMs', + sponsorblock: 'playback.sponsorblock', + filters: 'playback.filters', + audio: 'playback.audio', + voiceReceive: 'playback.voiceReceive', + mix: 'playback.mix', + enableTrackStreamEndpoint: 'api.enableTrackStreamEndpoint', + enableLoadStreamEndpoint: 'api.enableLoadStreamEndpoint', + dosProtection: 'dosProtection', + rateLimit: 'rateLimit', + metrics: 'metrics', + enableHoloTracks: 'experimental.enableHoloTracks', + commandTimeout: 'cluster.timeouts.heavyMs', + fastCommandTimeout: 'cluster.timeouts.fastMs' + } + + for (const [oldKey, newPath] of Object.entries(migrationMap)) { + if (Object.hasOwn(oldConfig, oldKey)) { + const value: unknown | undefined = oldConfig[oldKey] + if (value === undefined) continue + + // Do not overwrite already-migrated or explicit hierarchical values. + if (getDeepValue(newConfig, newPath) !== undefined) { + continue + } + + setDeepValue(newConfig, newPath, value) + } + } + + const cluster = newConfig.cluster as Record | undefined + const network = newConfig.network as Record | undefined + const clusterTimeouts = cluster?.timeouts as + | Record + | undefined + + const connection = getDeepValue(newConfig, 'connection') + if (connection) { + setDeepValue(newConfig, 'network.connection', connection) + } + + const metrics = getDeepValue(newConfig, 'metrics') + if (metrics) { + setDeepValue(newConfig, 'api.metrics', metrics) + } + + if ( + typeof cluster?.commandTimeout === 'number' && + typeof clusterTimeouts?.heavyMs !== 'number' + ) { + setDeepValue( + newConfig, + 'cluster.timeouts.heavyMs', + cluster.commandTimeout as JsonValue + ) + } + + if ( + typeof cluster?.fastCommandTimeout === 'number' && + typeof clusterTimeouts?.fastMs !== 'number' + ) { + setDeepValue( + newConfig, + 'cluster.timeouts.fastMs', + cluster.fastCommandTimeout as JsonValue + ) + } + + if (!network?.proxy || typeof network.proxy !== 'object') { + setDeepValue(newConfig, 'network.proxy', { + enabled: false, + strategy: 'RoundRobin', + retries: 3, + timeout: 10000, + shuffleOnStart: true, + list: [] + }) + } + + return newConfig +} + +function setDeepValue( + obj: Record, + path: string, + value: unknown +): void { + const parts = path.split('.') + if (parts.length === 0) return + let current = obj + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + if (!part) continue + if (!current[part] || typeof current[part] !== 'object') { + current[part] = {} + } + current = current[part] as Record + } + const leaf = parts[parts.length - 1] + if (!leaf) return + current[leaf] = value +} + +function getDeepValue( + obj: Record, + path: string +): unknown | undefined { + const parts = path.split('.') + let current: unknown = obj + for (const part of parts) { + if ( + !current || + typeof current !== 'object' || + Array.isArray(current) || + !(part in current) + ) { + return undefined + } + const next: unknown | undefined = (current as Record)[part] + if (next === undefined) return undefined + current = next + } + return current +} + +/** + * Converts a JavaScript object to a TypeScript literal string. + * Uses single quotes and avoids quoting keys when possible. + * + * @param obj - The object to convert. + * @param indent - Current indentation level. + * @returns A formatted string representation of the object. + * @internal + */ +export function toTsLiteral(obj: unknown, indent = 0): string { + const spaces = ' '.repeat(indent) + const nextSpaces = ' '.repeat(indent + 2) + + if (obj === null) return 'null' + if (typeof obj === 'string') return `'${obj.replace(/'/g, "\\'")}'` + if (typeof obj !== 'object') return String(obj) + + if (Array.isArray(obj)) { + if (obj.length === 0) return '[]' + const items = obj + .map((v) => toTsLiteral(v, indent + 2)) + .join(`,\n${nextSpaces}`) + return `[\n${nextSpaces}${items}\n${spaces}]` + } + + const keys = Object.keys(obj) + if (keys.length === 0) return '{}' + + const props = keys + .map((key) => { + const val: unknown | undefined = (obj as Record)[key] + if (val === undefined) return null + const value = toTsLiteral(val, indent + 2) + const validKey = /^[a-z_$][a-z0-9_$]*$/i.test(key) ? key : `'${key}'` + return `${validKey}: ${value}` + }) + .filter((v) => v !== null) + .join(`,\n${nextSpaces}`) + + return `{\n${nextSpaces}${props}\n${spaces}}` +} + +/** + * Persists a configuration object to config.ts if it was loaded from a legacy + * source or the default template. + * + * @param config - The migrated configuration object. + * @param fileName - The name of the source file. + */ +export async function persistConfig( + config: Record, + fileName: string +): Promise { + const isLegacy = fileName.endsWith('.js') + const isDefault = fileName.startsWith('config.default') + + if (!isLegacy && !isDefault) return + + try { + const configTsPath = path.resolve(process.cwd(), 'config.ts') + const literal = toTsLiteral(config) + const content = `import type { NodelinkConfig } from './src/typings/config/config.types.ts'\n\nexport const config: NodelinkConfig = ${literal}\n\nexport default config\n` + + await fs.writeFile(configTsPath, content, 'utf-8') + console.log( + `[INFO] Config: Automatically updated local config.ts from ${fileName}` + ) + } catch (err) { + console.warn( + `[WARN] Config: Failed to persist updated configuration: ${err instanceof Error ? err.message : String(err)}` + ) + } +} From 2dd037873f4e0f70689329ba63b5fdbf688ff694 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:24:32 -0400 Subject: [PATCH 25/58] improve: apply code quality fixes and project-wide linting --- .gitignore | 2 +- src/api/loadTracks.ts | 6 +- .../sessions.id.players.id.sponsorblock.ts | 69 +++++++-- src/index.ts | 146 ++++++++++++------ src/lyrics/monochrome.ts | 3 +- src/managers/connectionManager.ts | 3 +- src/managers/credentialManager.ts | 2 +- src/managers/dosProtectionManager.ts | 4 +- src/managers/lyricsManager.ts | 2 +- src/managers/playerManager.ts | 22 +-- src/managers/rateLimitManager.ts | 4 +- src/managers/routePlannerManager.ts | 15 +- src/managers/sourceManager.ts | 42 +++-- src/managers/statsManager.ts | 2 +- src/managers/trackCacheManager.ts | 3 +- src/playback/player.ts | 16 +- src/playback/processing/streamProcessor.ts | 6 +- src/sources/bandcamp.ts | 13 -- src/sources/bluesky.ts | 17 -- src/sources/gaana.ts | 4 +- src/sources/googledrive.ts | 5 +- src/sources/http.ts | 18 ++- src/sources/iheartradio.ts | 1 - src/sources/letrasmus.ts | 1 - src/sources/pandora.ts | 4 +- src/sources/shazam.ts | 3 +- src/sources/songlink.ts | 16 +- src/sources/vkmusic.ts | 4 +- src/typings/sources/source.types.ts | 2 +- src/typings/sources/youtube.types.ts | 6 + src/utils.ts | 13 +- src/workers/main.ts | 46 ++++-- src/workers/source.ts | 43 ++++-- 33 files changed, 335 insertions(+), 208 deletions(-) diff --git a/.gitignore b/.gitignore index 2d45f857..e042b72c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ !LICENSE !package.json !README.md -!config.default.js +!config.default.ts !biome.json !.husky !.husky/** diff --git a/src/api/loadTracks.ts b/src/api/loadTracks.ts index f470bbd9..a738c730 100644 --- a/src/api/loadTracks.ts +++ b/src/api/loadTracks.ts @@ -100,7 +100,11 @@ function parseIdentifier( return { kind: 'unifiedSearch', query: groups.query } } if (groups.source.toLowerCase() === 'isrc') { - return { kind: 'search', source: normalizeDefaultSearchSource(defaultSearchSource), query: groups.query } + return { + kind: 'search', + source: normalizeDefaultSearchSource(defaultSearchSource), + query: groups.query + } } return { kind: 'search', source: groups.source, query: groups.query } } diff --git a/src/api/sessions.id.players.id.sponsorblock.ts b/src/api/sessions.id.players.id.sponsorblock.ts index fbc3f241..0f37c20c 100644 --- a/src/api/sessions.id.players.id.sponsorblock.ts +++ b/src/api/sessions.id.players.id.sponsorblock.ts @@ -1,3 +1,4 @@ +import type { PlayerCommandResponse } from '../managers/playerManager.ts' import type { ApiNodelinkServer, ApiRequest, @@ -10,7 +11,7 @@ import type { PlayerSponsorBlockState, SponsorBlockSegment } from '../typings/playback/player.types.ts' -import { logger, sendErrorResponse } from '../utils.ts' +import { sendErrorResponse } from '../utils.ts' /** * Minimal player manager contract required by the SponsorBlock route. @@ -19,7 +20,9 @@ interface SponsorBlockPlayerManager { /** * Returns current SponsorBlock state for a player. */ - getSponsorBlock: (guildId: string) => PlayerSponsorBlockState + getSponsorBlock: ( + guildId: string + ) => Promise /** * Updates SponsorBlock settings for a player. @@ -29,7 +32,7 @@ interface SponsorBlockPlayerManager { updates: Partial< Omit > - ) => void + ) => Promise /** * Overrides SponsorBlock segments for a player. @@ -37,12 +40,14 @@ interface SponsorBlockPlayerManager { setSponsorBlockSegments: ( guildId: string, segments: SponsorBlockSegment[] - ) => void + ) => Promise /** * Clears SponsorBlock state for a player. */ - clearSponsorBlock: (guildId: string) => void + clearSponsorBlock: ( + guildId: string + ) => Promise } /** @@ -156,7 +161,7 @@ async function handleGetSponsorBlock( } try { - const state = session.players.getSponsorBlock(pathParams.guildId) + const state = await session.players.getSponsorBlock(pathParams.guildId) sendResponse(req, res, state, 200) } catch (error) { const errorMessage = @@ -202,16 +207,32 @@ async function handlePatchSponsorBlock( } try { - session.players.updateSponsorBlock(pathParams.guildId, { - enabled: body.enabled, - categories: body.categories, - actionTypes: body.actionTypes, - skipMarginMs: body.skipMarginMs - }) + const result = await session.players.updateSponsorBlock( + pathParams.guildId, + { + enabled: body.enabled, + categories: body.categories, + actionTypes: body.actionTypes, + skipMarginMs: body.skipMarginMs + } + ) + + if (result && 'status' in result && result.status !== 200) { + sendErrorResponse( + req, + res, + (result.status as number) || 500, + 'Internal Server Error', + result.message || 'Operation failed', + req.url || '' + ) + return + } + sendResponse( req, res, - session.players.getSponsorBlock(pathParams.guildId), + await session.players.getSponsorBlock(pathParams.guildId), 200 ) } catch (error) { @@ -258,11 +279,27 @@ async function handlePostSponsorBlock( } try { - session.players.setSponsorBlockSegments(pathParams.guildId, body.segments) + const result = await session.players.setSponsorBlockSegments( + pathParams.guildId, + body.segments + ) + + if (result && 'status' in result && result.status !== 200) { + sendErrorResponse( + req, + res, + (result.status as number) || 500, + 'Internal Server Error', + result.message || 'Operation failed', + req.url || '' + ) + return + } + sendResponse( req, res, - session.players.getSponsorBlock(pathParams.guildId), + await session.players.getSponsorBlock(pathParams.guildId), 200 ) } catch (error) { @@ -295,7 +332,7 @@ async function handleDeleteSponsorBlock( } try { - session.players.clearSponsorBlock(pathParams.guildId) + await session.players.clearSponsorBlock(pathParams.guildId) res.writeHead(204) res.end() } catch (error) { diff --git a/src/index.ts b/src/index.ts index da15e2b1..e9c3e174 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,17 @@ import cluster from 'node:cluster' import { EventEmitter } from 'node:events' -import fs from 'node:fs' import http from 'node:http' import { resolve as resolvePath } from 'node:path' import { pathToFileURL } from 'node:url' import WebSocketServer from '@performanc/pwsl-server' - -import { migrateConfig } from './modules/config/configMigration.ts' -import ProxyManager from './managers/proxyManager.ts' +import type ProxyManager from './managers/proxyManager.ts' import RoutePlannerManager from './managers/routePlannerManager.ts' import SessionManager from './managers/sessionManager.ts' import StatsManager from './managers/statsManager.ts' +import { + migrateConfig, + persistConfig +} from './modules/config/configMigration.ts' import { applyEnvOverrides, checkForUpdates, @@ -41,7 +42,10 @@ import type SourceWorkerManager from './managers/sourceWorkerManager.ts' import type TrackCacheManager from './managers/trackCacheManager.ts' import type WorkerManager from './managers/workerManager.ts' import type { ApiMiddlewareExtension } from './typings/api/api.types.ts' -import type { NodelinkConfig } from './typings/config/config.types.ts' +import type { + JsonValue, + NodelinkConfig +} from './typings/config/config.types.ts' import type { AudioInterceptorExtension, BunSocketData, @@ -299,17 +303,19 @@ const loadConfig = async (): Promise => { importedModule: Record, fileName: string ): Record => { - const candidate = ( - importedModule as { - default?: unknown - config?: unknown - } - ).default ?? ( - importedModule as { - default?: unknown - config?: unknown - } - ).config + const candidate = + ( + importedModule as { + default?: unknown + config?: unknown + } + ).default ?? + ( + importedModule as { + default?: unknown + config?: unknown + } + ).config if ( candidate && @@ -324,41 +330,95 @@ const loadConfig = async (): Promise => { ) } - const candidates = [ - 'config.ts', - 'config.js', - 'config.default.ts', - 'config.default.js' - ] - - for (const fileName of candidates) { + /** + * Loads and merges configuration files. + * Prioritizes config.ts/js, falls back to config.default.ts/js. + * @returns Migrated and merged configuration object. + */ + const loadAndMerge = async (): Promise => { + // 1. Load defaults (Mandatory) + let baseConfig: Record = {} + let defaultFileName = 'config.default.ts' try { - const module = await import(resolveRootConfigUrl(fileName)) - const imported = resolveConfigExport( + const module = await import(resolveRootConfigUrl('config.default.ts')) + baseConfig = resolveConfigExport( module as Record, - fileName + 'config.default.ts' ) - if (fileName.startsWith('config.default')) { - console.log(`[INFO] Config: Loaded fallback configuration from ${fileName}`) + } catch { + try { + const module = await import(resolveRootConfigUrl('config.default.js')) + baseConfig = resolveConfigExport( + module as Record, + 'config.default.js' + ) + defaultFileName = 'config.default.js' + } catch { + throw new Error( + '[ERROR] Config: Base configuration (config.default.ts/js) not found.' + ) + } + } + + // 2. Try to load user configuration + let userConfig: Record = {} + let userFileName: string | null = null + const userCandidates = ['config.ts', 'config.js'] + + for (const fileName of userCandidates) { + try { + const module = await import(resolveRootConfigUrl(fileName)) + userConfig = resolveConfigExport( + module as Record, + fileName + ) + userFileName = fileName + console.log(`[INFO] Config: Loaded user configuration from ${fileName}`) + break + } catch (e) { + const error = e as ConfigLoadError + const isNotFound = + error.code === 'ERR_MODULE_NOT_FOUND' || + error.code === 'ENOENT' || + error.message?.includes('Cannot find module') + if (isNotFound) continue + throw e + } + } + + if (!userFileName) { + console.log(`[INFO] Config: No user configuration found. Using defaults.`) + } + + // 3. Merge user config over defaults + // We do a deep merge for 'sources', 'lyrics', 'meanings', 'pluginConfig' + // and a shallow merge for the rest to keep it simple but functional. + const merged = { ...baseConfig } + for (const [key, value] of Object.entries(userConfig)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + baseConfig[key] && + typeof baseConfig[key] === 'object' && + !Array.isArray(baseConfig[key]) + ) { + merged[key] = { + ...(baseConfig[key] as Record), + ...(value as Record) + } } else { - console.log(`[INFO] Config: Loaded configuration from ${fileName}`) + merged[key] = value } - return migrateConfig(imported) as NodelinkConfig - } catch (e) { - const error = e as ConfigLoadError - const isNotFound = - error.code === 'ERR_MODULE_NOT_FOUND' || - error.code === 'ENOENT' || - error.message?.includes('Cannot find module') - if (isNotFound) continue - throw e } + + const migrated = migrateConfig(merged as Record) + await persistConfig(migrated, userFileName ?? defaultFileName) + + return migrated as unknown as NodelinkConfig } - console.error( - '[ERROR] Config: Failed to load configuration (config.ts/config.js/config.default.ts/config.default.js).' - ) - throw new Error('No valid configuration file found.') + return await loadAndMerge() } config = await loadConfig() diff --git a/src/lyrics/monochrome.ts b/src/lyrics/monochrome.ts index 8d11431a..cb11ddf7 100644 --- a/src/lyrics/monochrome.ts +++ b/src/lyrics/monochrome.ts @@ -7,8 +7,7 @@ import type { } from '../managers/lyricsManager.ts' import type { InstanceHealth, - MonochromeLyricsResponse, - MonochromeSourceConfig + MonochromeLyricsResponse } from '../typings/sources/monochrome.types.ts' import { logger, makeRequest } from '../utils.ts' diff --git a/src/managers/connectionManager.ts b/src/managers/connectionManager.ts index 3a0267f6..0033c096 100644 --- a/src/managers/connectionManager.ts +++ b/src/managers/connectionManager.ts @@ -70,7 +70,8 @@ export default class ConnectionManager { constructor(nodelink: ConnectionManagerContext) { this.nodelink = nodelink - this.config = nodelink.options.network?.connection || nodelink.options.connection || {} + this.config = + nodelink.options.network?.connection || nodelink.options.connection || {} this.interval = null this.status = 'unknown' this.metrics = { timestamp: Date.now() } diff --git a/src/managers/credentialManager.ts b/src/managers/credentialManager.ts index 26493e52..6590cfa3 100644 --- a/src/managers/credentialManager.ts +++ b/src/managers/credentialManager.ts @@ -15,7 +15,7 @@ type CredentialManagerContext = { options: NodelinkConfig } -const isRecord = (value: unknown): value is Record => +const _isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) /** diff --git a/src/managers/dosProtectionManager.ts b/src/managers/dosProtectionManager.ts index b22e2762..6e20c19d 100644 --- a/src/managers/dosProtectionManager.ts +++ b/src/managers/dosProtectionManager.ts @@ -68,8 +68,8 @@ export default class DosProtectionManager { this.nodelink = nodelink this.config = this._resolveConfig( nodelink.options?.api?.dosProtection ?? - nodelink.options?.security?.dosProtection ?? - nodelink.options?.dosProtection + nodelink.options?.security?.dosProtection ?? + nodelink.options?.dosProtection ) this.ipRequestCounts = new Map() this.cleanupInterval = setInterval( diff --git a/src/managers/lyricsManager.ts b/src/managers/lyricsManager.ts index 8cac3a8c..b82f666f 100644 --- a/src/managers/lyricsManager.ts +++ b/src/managers/lyricsManager.ts @@ -1,8 +1,8 @@ -import type { NodelinkConfig } from '../typings/config/config.types.ts' import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { alignLyrics } from '../modules/lyricsAligner.ts' +import type { NodelinkConfig } from '../typings/config/config.types.ts' import type { SourceResult } from '../typings/sources/source.types.ts' import { logger } from '../utils.ts' diff --git a/src/managers/playerManager.ts b/src/managers/playerManager.ts index 2673c369..06c9b7ea 100644 --- a/src/managers/playerManager.ts +++ b/src/managers/playerManager.ts @@ -106,7 +106,11 @@ interface CreatePlayerResult { * Generic player command response payload for cluster commands. * @public */ -interface PlayerCommandResponse extends Record { +export interface PlayerCommandResponse extends Record { + /** HTTP-like status code. */ + status?: number + /** Success/failure message or error details. */ + message?: string playerNotFound?: boolean } @@ -892,11 +896,11 @@ export default class PlayerManager { /** * Returns current SponsorBlock state for a player. */ - getSponsorBlock( + async getSponsorBlock( guildId: string - ): PlayerSponsorBlockState | PlayerCommandResponse { + ): Promise { if (this.isCluster) { - return this.runClusterPlayerCommand(guildId, 'getSponsorBlock', []) as any + return this.runClusterPlayerCommand(guildId, 'getSponsorBlock', []) } const player = this.getLocalPlayerOrThrow(this.getPlayerKey(guildId)) @@ -915,7 +919,7 @@ export default class PlayerManager { if (this.isCluster) { return this.runClusterPlayerCommand(guildId, 'updateSponsorBlock', [ updates - ]) as any + ]) } const player = this.getLocalPlayerOrThrow(this.getPlayerKey(guildId)) @@ -933,7 +937,7 @@ export default class PlayerManager { if (this.isCluster) { return this.runClusterPlayerCommand(guildId, 'setSponsorBlockSegments', [ segments - ]) as any + ]) } const player = this.getLocalPlayerOrThrow(this.getPlayerKey(guildId)) @@ -948,11 +952,7 @@ export default class PlayerManager { guildId: string ): Promise { if (this.isCluster) { - return this.runClusterPlayerCommand( - guildId, - 'clearSponsorBlock', - [] - ) as any + return this.runClusterPlayerCommand(guildId, 'clearSponsorBlock', []) } const player = this.getLocalPlayerOrThrow(this.getPlayerKey(guildId)) diff --git a/src/managers/rateLimitManager.ts b/src/managers/rateLimitManager.ts index f9120445..dd426af7 100644 --- a/src/managers/rateLimitManager.ts +++ b/src/managers/rateLimitManager.ts @@ -79,8 +79,8 @@ export default class RateLimitManager { this.nodelink = nodelink this.config = this._resolveConfig( nodelink.options?.api?.rateLimit ?? - nodelink.options?.security?.rateLimit ?? - nodelink.options?.rateLimit + nodelink.options?.security?.rateLimit ?? + nodelink.options?.rateLimit ) this.store = new Map() this.cleanupInterval = setInterval( diff --git a/src/managers/routePlannerManager.ts b/src/managers/routePlannerManager.ts index e247fd22..9651e3c8 100644 --- a/src/managers/routePlannerManager.ts +++ b/src/managers/routePlannerManager.ts @@ -52,7 +52,11 @@ const normalizeIpBlocks = ( return blocks .map((block) => { if (typeof block === 'string') return { cidr: block } - if (block && typeof block === 'object' && typeof block.cidr === 'string') { + if ( + block && + typeof block === 'object' && + typeof block.cidr === 'string' + ) { return block } return null @@ -64,11 +68,10 @@ type ResolvedRoutePlannerConfig = Omit & { ipBlocks?: RoutePlannerIpBlockConfig[] } -const resolveConfig = (nodelink: RoutePlannerManagerContext): ResolvedRoutePlannerConfig => { - const cfg = - nodelink.options.network?.routePlanner ?? - nodelink.options.routePlanner ?? - {} +const resolveConfig = ( + nodelink: RoutePlannerManagerContext +): ResolvedRoutePlannerConfig => { + const cfg = nodelink.options.network.routePlanner return { ...cfg, ipBlocks: normalizeIpBlocks(cfg.ipBlocks) diff --git a/src/managers/sourceManager.ts b/src/managers/sourceManager.ts index 1ef4916a..eea19cc7 100644 --- a/src/managers/sourceManager.ts +++ b/src/managers/sourceManager.ts @@ -1,7 +1,12 @@ import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import type { NodelinkConfig, SourcesRegistry } from '../typings/config/config.types.ts' +import type { + FeatureToggle, + NodelinkConfig, + SourceConfigBase, + SourcesRegistry +} from '../typings/config/config.types.ts' import type { SourceManagerLike, TrackFormat, @@ -122,8 +127,7 @@ export default class SourcesManager implements SourceManagerLike { const sourceKey = isYouTube ? 'youtube' : name const sourceConfig = this.nodelink.options.sources - const enabled = - sourceConfig[sourceKey as keyof SourcesRegistry]?.enabled + const enabled = sourceConfig[sourceKey as keyof SourcesRegistry]?.enabled if (!enabled) return @@ -180,8 +184,14 @@ export default class SourcesManager implements SourceManagerLike { await fs.access(sourcesDir) const sources = this.nodelink.options.sources - const enabledSourceKeys = (Object.keys(sources) as Array) - .filter((key) => sources[key]?.enabled) + const enabledSourceKeys = Object.keys(sources) + .filter((key) => { + const config = sources[key] as + | SourceConfigBase + | FeatureToggle + | undefined + return config?.enabled + }) .map((key) => key.toLowerCase()) const uniqueEnabled = Array.from(new Set(enabledSourceKeys)) @@ -358,13 +368,8 @@ export default class SourcesManager implements SourceManagerLike { * @public */ public async searchWithDefault(query: string): Promise { - const configuredDefaultSource = - this.nodelink.options.search?.defaultSource ?? - this.nodelink.options.defaultSearchSource ?? - 'youtube' - const defaultSources = Array.isArray( - configuredDefaultSource - ) + const configuredDefaultSource = this.nodelink.options.search.defaultSource + const defaultSources = Array.isArray(configuredDefaultSource) ? configuredDefaultSource : [configuredDefaultSource] @@ -397,11 +402,7 @@ export default class SourcesManager implements SourceManagerLike { * @public */ public async unifiedSearch(query: string): Promise { - const searchSources = ( - this.nodelink.options.search?.unifiedSources ?? - this.nodelink.options.unifiedSearchSources ?? - ['youtube'] - ) as string[] + const searchSources = this.nodelink.options.search.unifiedSources logger( 'debug', 'Sources', @@ -548,12 +549,7 @@ export default class SourcesManager implements SourceManagerLike { 'Sources', `ISRC Upscale: found match for ${track.isrc} on ${source}. Using high-quality source.` ) - return (await this.getTrackUrl( - match.info, - undefined, - false, - true - )) as any + return await this.getTrackUrl(match.info, undefined, false, true) } } } catch (e) { diff --git a/src/managers/statsManager.ts b/src/managers/statsManager.ts index 5aa6e159..54c786c1 100644 --- a/src/managers/statsManager.ts +++ b/src/managers/statsManager.ts @@ -1,4 +1,3 @@ -import type { NodelinkConfig } from '../typings/config/config.types.ts' import type { EndpointCounters, SourceStatsEntry, @@ -6,6 +5,7 @@ import type { StatsSnapshot, WorkerMetricsPayload } from '../typings/api/stats.types.ts' +import type { NodelinkConfig } from '../typings/config/config.types.ts' import { logger } from '../utils.ts' type PromClientModule = typeof import('prom-client') diff --git a/src/managers/trackCacheManager.ts b/src/managers/trackCacheManager.ts index 7773e414..32334137 100644 --- a/src/managers/trackCacheManager.ts +++ b/src/managers/trackCacheManager.ts @@ -1,5 +1,4 @@ import type { NodelinkConfig } from '../typings/config/config.types.ts' -import type { TrackCacheEntry } from '../typings/playback/trackCache.types.ts' import BaseCacheManager from './baseCacheManager.ts' const TRACK_CACHE_SALT = 'nodelink-track-salt' @@ -13,7 +12,7 @@ type TrackCacheContext = { options: NodelinkConfig } -const isRecord = (value: unknown): value is Record => +const _isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) /** diff --git a/src/playback/player.ts b/src/playback/player.ts index b5789028..253da1f5 100644 --- a/src/playback/player.ts +++ b/src/playback/player.ts @@ -183,10 +183,13 @@ export class Player { 'music_offtopic', 'filler' ], - actionTypes: this.nodelink.options.playback.sponsorblock?.actionTypes ?? ['skip'], + actionTypes: this.nodelink.options.playback.sponsorblock?.actionTypes ?? [ + 'skip' + ], segments: [], lastSkippedUuid: null, - skipMarginMs: this.nodelink.options.playback.sponsorblock?.skipMarginMs ?? 150 + skipMarginMs: + this.nodelink.options.playback.sponsorblock?.skipMarginMs ?? 150 } logger( @@ -484,7 +487,8 @@ export class Player { 'Player', `Track became stuck for guild ${this.guildId}. Triggering immediate recovery.` ) - this._stuckTime = (this.nodelink.options.playback.trackStuckThresholdMs ?? 0) + 1 + this._stuckTime = + (this.nodelink.options.playback.trackStuckThresholdMs ?? 0) + 1 this._sendUpdate() return } @@ -917,7 +921,8 @@ export class Player { if (typeof resolveHoloTrack === 'function') { const holoTrack = await resolveHoloTrack.call(source, track, { fetchChannelInfo: this.nodelink.options.search.fetchChannelInfo, - resolveExternalLinks: this.nodelink.options.search.resolveExternalLinks + resolveExternalLinks: + this.nodelink.options.search.resolveExternalLinks }) return holoTrack || track } @@ -1138,7 +1143,8 @@ export class Player { !this.isPaused ) { if (this._lastPosition === position) { - this._stuckTime += this.nodelink.options.playback.playerUpdateInterval ?? 0 + this._stuckTime += + this.nodelink.options.playback.playerUpdateInterval ?? 0 if ( this._stuckTime >= threshold && !this._isRecovering && diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index 96de8a8d..68c6fa66 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -2628,7 +2628,8 @@ class StreamAudioResource extends BaseAudioResource { const silenceDetector = new SilenceDetector({ sampleRate: AUDIO_CONFIG.sampleRate, channels: AUDIO_CONFIG.channels, - thresholdDb: nodelink.options.playback.audio?.automix?.silenceThresholdDb ?? -40 + thresholdDb: + nodelink.options.playback.audio?.automix?.silenceThresholdDb ?? -40 }) const flowController = new FlowController( @@ -2782,7 +2783,8 @@ class StreamAudioResource extends BaseAudioResource { volume, enableAGC, lookaheadMs: this.nodelink?.options?.playback.audio?.lookaheadMs, - gateThresholdLUFS: this.nodelink?.options?.playback.audio?.gateThresholdLUFS + gateThresholdLUFS: + this.nodelink?.options?.playback.audio?.gateThresholdLUFS }) pipeline(pcmStream, volumeTransformer, (err: Error | null): void => { diff --git a/src/sources/bandcamp.ts b/src/sources/bandcamp.ts index d4ca8d58..d8866b8c 100644 --- a/src/sources/bandcamp.ts +++ b/src/sources/bandcamp.ts @@ -24,19 +24,6 @@ const SEARCH_ARTWORK_REGEX = /
\s* { - /** - * Global search-result limit used by several sources. - */ - search?: { - maxResults?: number - } - sources?: { - bluesky?: { - maxSearchResults?: number - } - } -} - /** * Service entry returned by a DID document or PLC directory response. */ diff --git a/src/sources/gaana.ts b/src/sources/gaana.ts index d41ee578..2c064255 100644 --- a/src/sources/gaana.ts +++ b/src/sources/gaana.ts @@ -2,8 +2,8 @@ import { createDecipheriv } from 'node:crypto' import { PassThrough } from 'node:stream' import HLSHandler from '../playback/hls/HLSHandler.ts' import { parse as parsePlaylist } from '../playback/hls/PlaylistParser.ts' +import type { GaanaSourceConfig } from '../typings/config/config.types.ts' import type { HLSSegment } from '../typings/playback/hls.types.ts' -import type { GaanaSourceConfig, NodelinkConfig } from '../typings/config/config.types.ts' import type { SourceResult, TrackInfo, @@ -43,7 +43,7 @@ export default class GaanaSource { /** * Gaana source configuration block. */ - public readonly config: Record + public readonly config: GaanaSourceConfig /** * Search aliases handled by this source. diff --git a/src/sources/googledrive.ts b/src/sources/googledrive.ts index ea6f373f..a8e804dd 100644 --- a/src/sources/googledrive.ts +++ b/src/sources/googledrive.ts @@ -106,8 +106,9 @@ export default class GoogleDriveSource implements SourceInstance { cookiesStr = rawCookies.map((c) => String(c).split(';')[0]).join('; ') } - const driveConfig = this.nodelink.options.sources - ?.googledrive as GoogleDriveSourceConfig | undefined + const driveConfig = this.nodelink.options.sources?.googledrive as + | GoogleDriveSourceConfig + | undefined const userCookies = driveConfig?.cookies if (userCookies) { cookiesStr = userCookies diff --git a/src/sources/http.ts b/src/sources/http.ts index 0dc4b65e..8fc8652c 100644 --- a/src/sources/http.ts +++ b/src/sources/http.ts @@ -262,12 +262,18 @@ export default class HttpSource { method: 'HEAD', headers: requestHeaders }).catch((err) => { - logger('warn', 'HTTP Source', `HEAD request failed for URL: ${url} with error: ${err.message} - falling back to GET request (this is expected for some servers that do not support HEAD)`); - return { error: err }; - }); + logger( + 'warn', + 'HTTP Source', + `HEAD request failed for URL: ${url} with error: ${err.message} - falling back to GET request (this is expected for some servers that do not support HEAD)` + ) + return { error: err } + }) const headContentType = headerToString( - ((data as HttpRequestResult).headers as Record)?.['content-type'] + ((data as HttpRequestResult).headers as Record)?.[ + 'content-type' + ] ) const headOk = !data.error && @@ -304,7 +310,9 @@ export default class HttpSource { } } - const headers = (data as HttpRequestResult).headers as HttpResponseHeaders | undefined + const headers = (data as HttpRequestResult).headers as + | HttpResponseHeaders + | undefined const contentType = headerToString( (headers as Record)?.['content-type'] ) diff --git a/src/sources/iheartradio.ts b/src/sources/iheartradio.ts index 87f82920..4c3e5636 100644 --- a/src/sources/iheartradio.ts +++ b/src/sources/iheartradio.ts @@ -342,7 +342,6 @@ export default class IheartradioSource { options.search?.maxResults > 0 ? options.search.maxResults : 10 - } /** diff --git a/src/sources/letrasmus.ts b/src/sources/letrasmus.ts index f23ad224..7d497aee 100644 --- a/src/sources/letrasmus.ts +++ b/src/sources/letrasmus.ts @@ -394,7 +394,6 @@ export default class LetrasMusSource { options.search?.maxResults > 0 ? options.search.maxResults : 10 - } /** diff --git a/src/sources/pandora.ts b/src/sources/pandora.ts index 0e634393..bca5b8e9 100644 --- a/src/sources/pandora.ts +++ b/src/sources/pandora.ts @@ -559,7 +559,9 @@ export default class PandoraSource implements SourceInstance { * @internal */ private getMaxPlaylistLength(): number { - return (this.options.playback.maxPlaylistLength as number | undefined) ?? 100 + return ( + (this.options.playback.maxPlaylistLength as number | undefined) ?? 100 + ) } /** diff --git a/src/sources/shazam.ts b/src/sources/shazam.ts index 0edd19a9..5e554a8c 100644 --- a/src/sources/shazam.ts +++ b/src/sources/shazam.ts @@ -374,7 +374,8 @@ export default class ShazamSource { const { body, statusCode, error } = await http1makeRequest(url, { headers: { - "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"" + 'sec-ch-ua': + '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"' } }) if (error || statusCode !== 200) { diff --git a/src/sources/songlink.ts b/src/sources/songlink.ts index ee0c1d88..afc097de 100644 --- a/src/sources/songlink.ts +++ b/src/sources/songlink.ts @@ -1,4 +1,7 @@ -import type { NodelinkConfig, SourcesRegistry } from '../typings/config/config.types.ts' +import type { + SonglinkSourceConfig, + SourcesRegistry +} from '../typings/config/config.types.ts' import type { SourceResult, TrackInfo, @@ -70,7 +73,7 @@ export default class SongLinkSource { /** * Raw source configuration block. */ - public readonly config: Record + public readonly config: SonglinkSourceConfig /** * Search aliases supported by this source. @@ -137,11 +140,7 @@ export default class SongLinkSource { } ) { this.nodelink = nodelink - const sourceConfig = this.nodelink.options.sources?.songlink - this.config = - sourceConfig && typeof sourceConfig === 'object' - ? (sourceConfig as Record) - : {} + this.config = this.nodelink.options.sources.songlink this.searchTerms = ['slsearch'] this.patterns = [SONG_LINK_PATTERN] this.priority = 95 @@ -696,7 +695,8 @@ export default class SongLinkSource { * @returns True when source can be used. */ private _isSourceAvailable(sourceName: string): boolean { - const sourceConfig = this.nodelink.options.sources?.[sourceName] + const sourceConfig = + this.nodelink.options.sources[sourceName as keyof SourcesRegistry] if (!sourceConfig?.enabled) return false return !!this.nodelink.sources.getSource(sourceName) } diff --git a/src/sources/vkmusic.ts b/src/sources/vkmusic.ts index 3a5cb220..a71e62b0 100644 --- a/src/sources/vkmusic.ts +++ b/src/sources/vkmusic.ts @@ -281,7 +281,9 @@ export default class VKMusicSource implements SourceInstance { try { const res = await this._apiRequest('audio.search', { q: query, - count: String((this.nodelink.options.search.maxResults as number) || 10), + count: String( + (this.nodelink.options.search.maxResults as number) || 10 + ), extended: '1' }) diff --git a/src/typings/sources/source.types.ts b/src/typings/sources/source.types.ts index 846498cc..a729b7fc 100644 --- a/src/typings/sources/source.types.ts +++ b/src/typings/sources/source.types.ts @@ -5,12 +5,12 @@ import type { Readable } from 'node:stream' import type { Worker as NodeWorker } from 'node:worker_threads' -import type { NodelinkConfig } from '../config/config.types.ts' import type { StatsMetricsPayload, StatsSnapshot, WorkerMetricsPayload } from '../api/stats.types.ts' +import type { NodelinkConfig } from '../config/config.types.ts' import type { CredentialEntry, CredentialManagerStats diff --git a/src/typings/sources/youtube.types.ts b/src/typings/sources/youtube.types.ts index 414c6df6..1ad86286 100644 --- a/src/typings/sources/youtube.types.ts +++ b/src/typings/sources/youtube.types.ts @@ -200,6 +200,12 @@ export interface YouTubeContext { */ osName?: string + /** + * UTC offset in minutes relative to GMT. + * @example 0, -180, 120 + */ + utcOffsetMinutes?: number + /** * Operating system version string. * @example '10.0', '13', '14.1' diff --git a/src/utils.ts b/src/utils.ts index 46f47061..77fd314a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2347,7 +2347,10 @@ async function fetchSponsorBlockSegments( } const videoMatch = ( - result.body as Array<{ videoID: string; segments: any[] }> + result.body as Array<{ + videoID: string + segments: Array + }> ).find((entry) => entry.videoID === videoId) if (!videoMatch?.segments) { @@ -2366,13 +2369,13 @@ async function fetchSponsorBlockSegments( ) return videoMatch.segments.map((s) => ({ - uuid: s.UUID, - start: Math.round(s.segment[0] * 1000), - end: Math.round(s.segment[1] * 1000), + uuid: s.uuid, + start: Math.round(s.start * 1000), + end: Math.round(s.end * 1000), category: s.category, actionType: s.actionType, votes: s.votes, - locked: s.locked === 1, + locked: s.locked, videoDuration: Math.round(s.videoDuration * 1000), description: s.description || '' })) diff --git a/src/workers/main.ts b/src/workers/main.ts index 7b8d000f..c6454775 100644 --- a/src/workers/main.ts +++ b/src/workers/main.ts @@ -21,7 +21,10 @@ import TrackCacheManager from '../managers/trackCacheManager.ts' import { migrateConfig } from '../modules/config/configMigration.ts' import { getWebmOpusProfilerStats } from '../playback/demuxers/WebmOpus.ts' import { bufferPool } from '../playback/structs/BufferPool.ts' -import type { NodelinkConfig as NodeLinkConfig } from '../typings/config/config.types.ts' +import type { + JsonValue, + NodelinkConfig as NodeLinkConfig +} from '../typings/config/config.types.ts' import type { NodeLink, TrackInfoExtended @@ -111,17 +114,19 @@ const resolveConfigExport = ( importedModule: Record, fileName: string ): Record => { - const candidate = ( - importedModule as { - default?: unknown - config?: unknown - } - ).default ?? ( - importedModule as { - default?: unknown - config?: unknown - } - ).config + const candidate = + ( + importedModule as { + default?: unknown + config?: unknown + } + ).default ?? + ( + importedModule as { + default?: unknown + config?: unknown + } + ).config if ( candidate && @@ -137,13 +142,24 @@ const resolveConfigExport = ( } const loadConfig = async (): Promise => { - const candidates = ['config.ts', 'config.js', 'config.default.ts', 'config.default.js'] + const candidates = [ + 'config.ts', + 'config.js', + 'config.default.ts', + 'config.default.js' + ] for (const fileName of candidates) { try { const module = await import(resolveRootConfigUrl(fileName)) - const raw = resolveConfigExport(module as Record, fileName) - return migrateConfig(raw) as NodeLinkConfig + const raw = resolveConfigExport( + module as Record, + fileName + ) + + return migrateConfig( + raw as Record + ) as unknown as NodeLinkConfig } catch (error) { const err = error as { code?: string; message?: string } const isNotFound = diff --git a/src/workers/source.ts b/src/workers/source.ts index 8695d300..f810e056 100644 --- a/src/workers/source.ts +++ b/src/workers/source.ts @@ -19,7 +19,10 @@ import type PluginManager from '../managers/pluginManager.ts' import type { PluginManagerContext } from '../managers/pluginManager.ts' import { migrateConfig } from '../modules/config/configMigration.ts' -import type { NodelinkConfig } from '../typings/config/config.types.ts' +import type { + JsonValue, + NodelinkConfig +} from '../typings/config/config.types.ts' import type { NodeLink } from '../typings/playback/player.types.ts' import type { FrameType, @@ -106,17 +109,19 @@ if (isMainThread) { importedModule: Record, fileName: string ): Record => { - const candidate = ( - importedModule as { - default?: unknown - config?: unknown - } - ).default ?? ( - importedModule as { - default?: unknown - config?: unknown - } - ).config + const candidate = + ( + importedModule as { + default?: unknown + config?: unknown + } + ).default ?? + ( + importedModule as { + default?: unknown + config?: unknown + } + ).config if ( candidate && @@ -131,12 +136,20 @@ if (isMainThread) { ) } - const candidates = ['config.ts', 'config.js', 'config.default.ts', 'config.default.js'] + const candidates = [ + 'config.ts', + 'config.js', + 'config.default.ts', + 'config.default.js' + ] for (const fileName of candidates) { try { const module = await import(resolveRootConfigUrl(fileName)) - const raw = resolveConfigExport(module as Record, fileName) - return migrateConfig(raw) + const raw = resolveConfigExport( + module as Record, + fileName + ) + return migrateConfig(raw as Record) } catch (error) { const err = error as { code?: string; message?: string } const isNotFound = From 2126718f4fe8ed776239e902effcba3c0eb30527 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:25:12 -0400 Subject: [PATCH 26/58] update: compile source and update build artifacts --- dist/config.default.js | 940 +++++----- dist/src/api/loadTracks.js | 6 +- .../sessions.id.players.id.sponsorblock.js | 20 +- dist/src/index.js | 101 +- dist/src/lyrics/monochrome.js | 6 +- dist/src/managers/baseCacheManager.js | 321 ++++ dist/src/managers/configValidationManager.js | 202 +- dist/src/managers/connectionManager.js | 3 +- dist/src/managers/credentialManager.js | 9 +- dist/src/managers/dosProtectionManager.js | 4 +- dist/src/managers/playerManager.js | 2 +- dist/src/managers/proxyManager.js | 378 ++++ dist/src/managers/rateLimitManager.js | 4 +- dist/src/managers/routePlannerManager.js | 31 +- dist/src/managers/sourceManager.js | 31 +- dist/src/managers/trackCacheManager.js | 22 +- dist/src/modules/config/configMigration.js | 185 ++ .../modules/config/configMigration.test.js | 154 ++ dist/src/playback/hls/HLSHandler.js | 2 +- dist/src/playback/hls/SegmentFetcher.js | 2 +- dist/src/playback/player.js | 14 +- dist/src/sources/bluesky.js | 7 +- dist/src/sources/deezer.js | 4 +- dist/src/sources/gaana.js | 6 +- dist/src/sources/iheartradio.js | 6 +- dist/src/sources/jiosaavn.js | 4 +- dist/src/sources/letrasmus.js | 6 +- dist/src/sources/monochrome.js | 2 +- dist/src/sources/pandora.js | 2 +- dist/src/sources/shazam.js | 4 +- dist/src/sources/songlink.js | 10 +- dist/src/sources/twitter.js | 2 +- dist/src/sources/youtube/CipherManager.js | 3 +- dist/src/sources/youtube/YouTube.js | 1669 ++++++++++++++--- dist/src/sources/youtube/common.js | 58 +- dist/src/utils.js | 14 +- dist/src/workers/main.js | 45 +- dist/src/workers/source.js | 38 +- 38 files changed, 3449 insertions(+), 868 deletions(-) create mode 100644 dist/src/managers/baseCacheManager.js create mode 100644 dist/src/managers/proxyManager.js create mode 100644 dist/src/modules/config/configMigration.js create mode 100644 dist/src/modules/config/configMigration.test.js diff --git a/dist/config.default.js b/dist/config.default.js index 2581b081..3b64b89f 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -1,47 +1,50 @@ -export default { +export const config = { server: { host: '0.0.0.0', port: 3000, password: 'youshallnotpass', - useBunServer: false // set to true to use Bun.serve websocket (experimental) + useBunServer: false }, cluster: { - enabled: true, // active cluster (or use env CLUSTER_ENABLED) - workers: 0, // 0 => uses os.cpus().length, or specify a number (1 = 2 processes total: master + 1 worker) - minWorkers: 1, // Minimum workers to keep alive (improves availability during bursts) + enabled: true, + workers: 0, + minWorkers: 1, runtime: { - workerMaxOldSpaceMb: 0, // 0 disables override; set >0 to pass --max-old-space-size to playback workers - workerExposeGc: false, // If true, adds --expose-gc to playback workers - workerExecArgv: [], // Extra Node.js args for playback workers (e.g. ['--trace-gc']) - sourceWorkerMaxOldSpaceMb: 0, // 0 disables override; set >0 to pass --max-old-space-size to source workers - sourceWorkerExposeGc: false, // If true, adds --expose-gc to source workers - sourceWorkerExecArgv: [] // Extra Node.js args for source workers + workerMaxOldSpaceMb: 0, + workerExposeGc: false, + workerExecArgv: [], + sourceWorkerMaxOldSpaceMb: 0, + sourceWorkerExposeGc: false, + sourceWorkerExecArgv: [] }, specializedSourceWorker: { - enabled: true, // If true, source loading (search, lyrics, etc.) is delegated to dedicated workers to prevent voice worker lag - count: 1, // Number of separate process clusters for source operations - microWorkers: 2, // Number of worker threads per process cluster - tasksPerWorker: 32, // Number of parallel tasks each micro-worker can handle before queuing - silentLogs: true // If true, micro-workers will only log warnings and errors - }, - commandTimeout: 6000, // Timeout for heavy operations like loadTracks (6s) - fastCommandTimeout: 4000, // Timeout for player commands like play/pause (4s) - maxRetries: 2, // Number of retry attempts on timeout or worker failure + enabled: true, + count: 1, + microWorkers: 2, + tasksPerWorker: 32, + silentLogs: true + }, + timeouts: { + heavyMs: 6000, + fastMs: 4000 + }, + commandTimeout: 6000, + fastCommandTimeout: 4000, + maxRetries: 2, hibernation: { enabled: true, timeoutMs: 1200000 }, scaling: { - //scaling configurations - maxPlayersPerWorker: 20, // Reference capacity for utilization calculation - targetUtilization: 0.7, // Target utilization for scaling up/down - scaleUpThreshold: 0.75, // Utilization threshold to scale up - scaleDownThreshold: 0.3, // Utilization threshold to scale down - checkIntervalMs: 5000, // Interval to check for scaling needs - idleWorkerTimeoutMs: 60000, // Time in ms an idle worker should wait before being removed - queueLengthScaleUpFactor: 5, // How many commands in queue per active worker trigger scale up - lagPenaltyLimit: 60, // Event loop lag threshold (ms) to penalize worker cost - cpuPenaltyLimit: 0.85 // CPU usage threshold (85% of a core) to force scale up + maxPlayersPerWorker: 20, + targetUtilization: 0.7, + scaleUpThreshold: 0.75, + scaleDownThreshold: 0.3, + checkIntervalMs: 5000, + idleWorkerTimeoutMs: 60000, + queueLengthScaleUpFactor: 5, + lagPenaltyLimit: 60, + cpuPenaltyLimit: 0.85 }, endpoint: { patchEnabled: true, @@ -73,135 +76,377 @@ export default { }, connection: { logAllChecks: false, - interval: 300000, // 5 minutes - timeout: 10000, // 10 seconds + interval: 300000, + timeout: 10000, thresholds: { - bad: 1, // Mbps - average: 5 // Mbps + bad: 1, + average: 5 } }, - maxSearchResults: 10, - maxAlbumPlaylistLength: 100, - playerUpdateInterval: 2000, - statsUpdateInterval: 30000, - trackStuckThresholdMs: 10000, - eventTimeoutMs: 15000, - zombieThresholdMs: 60000, - enableHoloTracks: false, - enableTrackStreamEndpoint: false, - enableLoadStreamEndpoint: false, - resolveExternalLinks: false, - fetchChannelInfo: false, - sponsorblock: { - enabled: false, - api: 'https://sponsor.ajay.app', - categories: [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'preview', - 'music_offtopic', - 'filler' - ], - actionTypes: ['skip'], - skipMarginMs: 150 + rateLimit: { + enabled: true, + maxEntries: 10000, + global: { + maxRequests: 1000, + timeWindowMs: 60000 + }, + perIp: { + maxRequests: 100, + timeWindowMs: 10000 + }, + perUserId: { + maxRequests: 50, + timeWindowMs: 5000 + }, + perGuildId: { + maxRequests: 20, + timeWindowMs: 5000 + }, + ignorePaths: [], + ignore: { + userIds: [], + guildIds: [], + ips: [] + } }, - filters: { - enabled: { - tremolo: true, - vibrato: true, - lowpass: true, - highpass: true, - rotation: true, - karaoke: true, - distortion: true, - channelMix: true, - equalizer: true, - chorus: true, - compressor: true, - echo: true, - phaser: true, - timescale: true + dosProtection: { + enabled: true, + thresholds: { + burstRequests: 50, + timeWindowMs: 10000 + }, + mitigation: { + delayMs: 500, + blockDurationMs: 300000 + }, + ignore: { + userIds: [], + guildIds: [], + ips: [] } }, - defaultSearchSource: ['youtube', 'soundcloud'], - unifiedSearchSources: ['youtube', 'soundcloud'], - sources: { - vkmusic: { - enabled: true, - userToken: '', // (optional) get from vk in browser devtools -> reqs POST /?act=web_token HTTP/2 - headers -> response -> access_token - userCookie: '', // (required without userToken) get from vk in browser devtools -> reqs POST /?act=web_token HTTP/2 - headers -> request -> cookie (copy full cookie header) - proxy: { - url: '', - username: '', - password: '' + trustProxy: false, + network: { + proxy: { + enabled: false, + strategy: 'RoundRobin', + retries: 3, + timeout: 10000, + shuffleOnStart: true, + list: [] + }, + connection: { + logAllChecks: false, + interval: 300000, + timeout: 10000, + thresholds: { + bad: 1, + average: 5 } }, - amazonmusic: { - enabled: true + routePlanner: { + strategy: 'RotateOnBan', + bannedIpCooldown: 600000, + ipBlocks: [] + } + }, + search: { + maxResults: 10, + defaultSource: ['youtube', 'soundcloud'], + unifiedSources: ['youtube', 'soundcloud'], + resolveExternalLinks: false, + fetchChannelInfo: false + }, + playback: { + maxPlaylistLength: 100, + playerUpdateInterval: 2000, + statsUpdateInterval: 30000, + trackStuckThresholdMs: 10000, + eventTimeoutMs: 15000, + zombieThresholdMs: 60000, + sponsorblock: { + enabled: false, + api: 'https://sponsor.ajay.app', + categories: [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'preview', + 'music_offtopic', + 'filler' + ], + actionTypes: ['skip'], + skipMarginMs: 150 + }, + filters: { + enabled: { + tremolo: true, + vibrato: true, + lowpass: true, + highpass: true, + rotation: true, + karaoke: true, + distortion: true, + channelMix: true, + equalizer: true, + chorus: true, + compressor: true, + echo: true, + phaser: true, + timescale: true + } }, - bluesky: { - enabled: true + audio: { + quality: 'high', + encryption: 'aead_xchacha20_poly1305_rtpsize', + resamplingQuality: 'best', + loudnessNormalizer: false, + lookaheadMs: 5, + gateThresholdLUFS: -60, + fading: { + enabled: false, + trackStart: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + trackEnd: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + trackStop: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + seek: { + duration: 0, + curve: 'linear', + type: 'volume' + }, + pause: { + duration: 0, + curve: 'sinusoidal', + type: 'tape' + }, + resume: { + duration: 0, + curve: 'sinusoidal', + type: 'tape' + }, + ducking: { + enabled: false, + duration: 0, + targetVolume: 0.3, + curve: 'linear' + } + }, + crossfade: { + enabled: false, + duration: 0, + curve: 'sinusoidal', + mode: 'preload', + minBufferMs: 250, + bufferMs: 0 + } }, - anghami: { + voiceReceive: { enabled: false, - cookies: '' // Optional: Useful for accessing restricted or private content + format: 'opus' }, - rss: { - enabled: true - }, - songlink: { + mix: { enabled: true, - apiKey: '', - userCountry: 'US', - songIfSingle: true, - useApi: true, - useScrapeFallback: true, - preferredPlatforms: [ - 'spotify', - 'appleMusic', - 'youtubeMusic', - 'youtube', - 'deezer', - 'tidal', - 'amazonMusic', + defaultVolume: 0.8, + maxLayersMix: 5, + autoCleanup: true + } + }, + api: { + enableTrackStreamEndpoint: false, + enableLoadStreamEndpoint: false, + metrics: { + enabled: true, + authorization: { + type: 'Bearer', + username: 'admin', + password: '' + } + } + }, + experimental: { + enableHoloTracks: false + }, + sources: { + youtube: { + enabled: true, + allowItag: [], + targetItag: null, + getOAuthToken: false, + hl: 'en', + gl: 'US', + proxies: [], + fallbackSources: [ 'soundcloud', + 'deezer', + 'jiosaavn', + 'qobuz', + 'gaana', + 'vkmusic', + 'yandexmusic', + 'audiomack', 'bandcamp', 'audius', - 'audiomack', - 'pandora', - 'itunes', - 'amazonStore' + 'mixcloud', + 'bilibili', + 'bluesky', + 'nicovideo' ], - fallbackToAny: true + clients: { + search: ['Android'], + playback: [ + 'AndroidVR', + 'TV', + 'TVCast', + 'WebEmbedded', + 'WebParentTools', + 'Web', + 'IOS' + ], + resolve: [ + 'AndroidVR', + 'TV', + 'TVCast', + 'WebEmbedded', + 'WebParentTools', + 'IOS', + 'Web' + ], + settings: { + TV: { + refreshToken: [''] + } + } + }, + cipher: { + url: 'https://cipher.kikkia.dev/api', + token: null + } }, - mixcloud: { - enabled: true + spotify: { + enabled: true, + clientId: '', + clientSecret: '', + externalAuthUrl: 'http://get.1lucas1apk.fun/spotify/gettoken', + market: 'US', + playlistLoadLimit: 1, + playlistPageLoadConcurrency: 10, + albumLoadLimit: 1, + albumPageLoadConcurrency: 5, + allowExplicit: true, + allowLocalFiles: false, + sp_dc: '' }, - audiomack: { - enabled: true + applemusic: { + enabled: true, + mediaApiToken: 'token_here', + market: 'US', + playlistLoadLimit: 0, + albumLoadLimit: 0, + playlistPageLoadConcurrency: 5, + albumPageLoadConcurrency: 5, + allowExplicit: true + }, + vkmusic: { + enabled: true, + userToken: '', + userCookie: '', + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + } }, deezer: { - // arl: '', - // decryptionKey: '', - enabled: true + enabled: true, + arl: '', + decryptionKey: '' }, - bandcamp: { - enabled: true + tidal: { + enabled: true, + token: 'token_here', + countryCode: 'US', + playlistLoadLimit: 2, + playlistPageLoadConcurrency: 5, + hifiApis: [''], + hifiQualities: ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'] }, - soundcloud: { - enabled: true - // clientId: "" + jiosaavn: { + enabled: true, + playlistLoadLimit: 50, + artistLoadLimit: 20, + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + }, + secretKey: '38346591' }, - local: { + gaana: { enabled: true, - basePath: './local-music/' + streamQuality: 'high', + playlistLoadLimit: 100, + albumLoadLimit: 100, + artistLoadLimit: 100, + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + } }, - http: { + yandexmusic: { enabled: true, - userAgent: '' // Optional: defaults to NodeLink/ (https://github.com/PerformanC/NodeLink) + accessToken: '', + allowUnavailable: false, + allowExplicit: true, + artistLoadLimit: 1, + albumLoadLimit: 1, + playlistLoadLimit: 1, + proxy: { + url: '', + username: '', + password: '' + }, + network: { + proxy: { + url: '', + username: '', + password: '' + } + } }, eternalbox: { enabled: true, @@ -211,7 +456,7 @@ export default { includeAnalysis: true, includeAnalysisSummary: true, eternalStream: true, - cacheMaxBytes: 20 * 1024 * 1024, + cacheMaxBytes: 20971520, maxBranches: 4, maxBranchThreshold: 75, branchThresholdStart: 10, @@ -235,29 +480,34 @@ export default { maxReconnects: 0, reconnectDelayMs: 1000 }, - 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 - }, - iheartradio: { - enabled: true - }, - telegram: { - enabled: true - }, - shazam: { + songlink: { enabled: true, - allowExplicit: true + apiKey: '', + userCountry: 'US', + songIfSingle: true, + useApi: true, + useScrapeFallback: true, + preferredPlatforms: [ + 'spotify', + 'appleMusic', + 'youtubeMusic', + 'youtube', + 'deezer', + 'tidal', + 'amazonMusic', + 'soundcloud', + 'bandcamp', + 'audius', + 'audiomack', + 'pandora', + 'itunes', + 'amazonStore' + ], + fallbackToAny: true }, - bilibili: { + http: { enabled: true, - sessdata: '' // Optional, improves access to some videos (premium and 4k+) - }, - genius: { - enabled: true - }, - pinterest: { - enabled: true + userAgent: '' }, flowery: { enabled: true, @@ -274,167 +524,116 @@ export default { maxTextLength: 3000, enforceConfig: false }, - jiosaavn: { - enabled: true, - playlistLoadLimit: 50, - artistLoadLimit: 20, - proxy: { - url: '', - username: '', - password: '' - } - // "secretKey": "38346591" // Optional, defaults to standard key + pipertts: { + enabled: false, + url: 'http://localhost:5000', + voice: 'en_US-lessac-medium', + speaker: 0, + speaker_id: '', + length_scale: 1.0, + noise_scale: 0.667, + noise_w_scale: 0.8 }, - gaana: { + audius: { enabled: true, - streamQuality: 'high', + appName: '', + apiKey: '', + apiSecret: '', playlistLoadLimit: 100, - albumLoadLimit: 100, - artistLoadLimit: 100, - proxy: { - url: '', // The HTTP/HTTPS proxy to use - username: '', // Optional username - password: '' // Optional password - } + albumLoadLimit: 100 }, - 'google-tts': { + qobuz: { enabled: true, - language: 'en-US' + userToken: '', + formatId: '5', + allowExplicit: true }, - // Piper TTS Configuration - // This source uses an external Piper TTS HTTP server. - // You can find the Piper HTTP server repository here: - // https://github.com/OHF-Voice/piper1-gpl/tree/main?tab=readme-ov-file - pipertts: { - enabled: false, // Disabled by default. Enable it to use Piper TTS. - url: 'http://localhost:5000' // URL of your Piper TTS server - // Optional settings (defaults from Piper): - // voice: 'en_US-lessac-medium', - // speaker: 0, - // length_scale: 1.0, - // noise_scale: 0.667, - // noise_w_scale: 0.8 + monochrome: { + enabled: true, + instances: [], + streamingInstances: [], + quality: 'HI_RES_LOSSLESS' }, - youtube: { + pandora: { enabled: true, - allowItag: [], // additional itags for audio streams, e.g., [140, 141] - targetItag: null, // force a specific itag for audio streams, overriding the quality option - getOAuthToken: false, - hl: 'en', - gl: 'US', - proxies: [ - /* { - url: "http://proxy1:port", - username: "username", - password: "password" - }, - { - url: "http://proxy2:port" - } */ - ], - fallbackSources: [ - 'soundcloud', - 'deezer', - 'jiosaavn', - 'qobuz', - 'gaana', - 'vkmusic', - 'yandexmusic', - 'audiomack', - 'bandcamp', - 'audius', - 'mixcloud', - 'bilibili', - 'bluesky', - 'nicovideo' - ], // Internal fallback chain when YouTube stream URL fails - clients: { - search: ['Android'], // Clients used for searching tracks - playback: [ - 'AndroidVR', - 'TV', - 'TVCast', - 'WebEmbedded', - 'WebParentTools', - 'Web', - 'IOS' - ], // Clients used for playback/streaming - resolve: [ - 'AndroidVR', - 'TV', - 'TVCast', - 'WebEmbedded', - 'WebParentTools', - 'IOS', - 'Web' - ], // Clients used for resolving detailed track information (channel, external links, etc.) - settings: { - TV: { - refreshToken: [''] // You can use a string "token" or an array ["token1", "token2"] for rotation/fallback - } - } - }, - cipher: { - url: 'https://cipher.kikkia.dev/api', - token: null - } + csrfToken: '', + remoteTokenUrl: 'https://get.1lucas1apk.fun/pandora/gettoken' }, - instagram: { + amazonmusic: { + enabled: true, + playlistLoadLimit: 0, + albumLoadLimit: 0 + }, + bluesky: { + enabled: true, + maxSearchResults: 10 + }, + anghami: { + enabled: false, + cookies: '' + }, + rss: { enabled: true }, - kwai: { + mixcloud: { enabled: true }, - twitch: { + audiomack: { enabled: true }, - spotify: { + bandcamp: { + enabled: true + }, + soundcloud: { enabled: true, - clientId: '', - clientSecret: '', - externalAuthUrl: 'http://get.1lucas1apk.fun/spotify/gettoken', // URL to external token provider (e.g. http://localhost:8080/api/token - use https://github.com/topi314/spotify-tokener or https://github.com/1Lucas1apk/gettoken) - market: 'US', - playlistLoadLimit: 1, // 0 means no limit (loads all tracks), 1 = 100 tracks, 2 = 100 and so on! - playlistPageLoadConcurrency: 10, // How many pages to load simultaneously - albumLoadLimit: 1, // 0 means no limit (loads all tracks), 1 = 50 tracks, 2 = 100 tracks, etc. - albumPageLoadConcurrency: 5, // How many pages to load simultaneously - allowExplicit: true, // If true plays the explicit version of the song, If false plays the Non-Explicit version of the song. Normal songs are not affected. - allowLocalFiles: false, // If true, Spotify playlist local files are kept as placeholder tracks so they can still be searched and played through fallback sources. - sp_dc: '' // fot getting mobile token (optional) get from spotify in browser devtools -> Application -> Cookies -> sp_dc (required for canvas) + clientId: '' }, - applemusic: { + local: { enabled: true, - mediaApiToken: 'token_here', //manually | or "token_here" to get a token automatically - market: 'US', - playlistLoadLimit: 0, - albumLoadLimit: 0, - playlistPageLoadConcurrency: 5, - albumPageLoadConcurrency: 5, - allowExplicit: true + basePath: './local-music/' }, - audius: { + vimeo: { + enabled: true + }, + iheartradio: { + enabled: true + }, + telegram: { + enabled: true + }, + shazam: { enabled: true, - appName: '', - apiKey: '', // go to https://audius.co/settings and create an app and paste the app name and api stuff into here. - apiSecret: '', - playlistLoadLimit: 100, - albumLoadLimit: 100 + allowExplicit: true }, - tidal: { + bilibili: { enabled: true, - 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 - hifiApis: [''], // optional, but required for direflct streaming, artist resolving host: https://github.com/binimum/hifi-api/ - hifiQualities: ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'] //tried sequentially until one works (only used if hifiApis is set) + sessdata: '', + network: { + proxy: { + url: '', + username: '', + password: '' + } + } }, - pandora: { + genius: { + enabled: true + }, + pinterest: { + enabled: true + }, + 'google-tts': { enabled: true, - // Optional, setting this manually can help unblocking countries (since pandora is US only.). May need to be updated periodically. - // fetching manually: use a vpn connected to US, go on pandora.com, open devtools, Network tab, first request to appear and copy the 2nd csrfToken= value. - // csrfToken: '', - remoteTokenUrl: 'https://get.1lucas1apk.fun/pandora/gettoken' // URL to a remote provider that returns { success: true, authToken: "...", csrfToken: "...", expires_in_seconds: ... } //https://github.com/1Lucas1apk/gettoken + language: 'en-US' + }, + instagram: { + enabled: true + }, + kwai: { + enabled: true + }, + twitch: { + enabled: true }, nicovideo: { enabled: true @@ -448,15 +647,9 @@ export default { twitter: { enabled: true }, - qobuz: { - enabled: true, - userToken: '', // (optional) get from play.qobuz.com in browser devtools -> Application -> Local Storage -> localuser -> token - formatId: '5', // 5 = MP3 320kbps, 6 = FLAC (requires Studio subscription), 27 = Hi-Res FLAC - allowExplicit: true - }, lastfm: { enabled: true, - apiKey: '' // You can get the api key from: https://www.last.fm/api/account/create + apiKey: '' }, netease: { enabled: true @@ -464,29 +657,9 @@ export default { letrasmus: { enabled: true }, - yandexmusic: { - enabled: true, - accessToken: '', - allowUnavailable: false, - allowExplicit: true, - artistLoadLimit: 1, // 0 = no limit, 1 = 10 tracks, 2 = 20 tracks, etc. - albumLoadLimit: 1, // 0 = no limit, 1 = 50 tracks, 2 = 100 tracks, etc. - playlistLoadLimit: 1, // 0 = no limit, 1 = 100 tracks, 2 = 200 tracks, etc. - proxy: { - url: '', - username: '', - password: '' - } - }, - monochrome: { - enabled: true, - instances: [], // (optional) list of API instances - streamingInstances: [], // (optional) list of streaming instances - qobuzInstances: [], // (optional) list of Qobuz API instances - quality: 'HIGH' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW - }, googledrive: { - enabled: true + enabled: true, + cookies: '' }, tiktok: { enabled: true @@ -502,7 +675,6 @@ export default { }, musixmatch: { enabled: true - // signatureSecret: '' }, deezer: { enabled: true @@ -531,128 +703,12 @@ export default { enabled: true } }, - audio: { - quality: 'high', // high, medium, low, lowest - encryption: 'aead_xchacha20_poly1305_rtpsize', // aead_aes256_gcm_rtpsize or aead_xchacha20_poly1305_rtpsize - resamplingQuality: 'best', // best, medium, fastest, zero order holder, linear - loudnessNormalizer: false, // Enable/disable AGC globally - lookaheadMs: 5, // Limiter lookahead buffer in milliseconds - gateThresholdLUFS: -60, // Silence threshold for AGC gate - fading: { - enabled: false, // Master switch for all fades - // type meanings: - // volume = only amplitude fades, tape = pitch/speed ramps, both = simultaneous fade and ramp, scratch = physical vinyl simulation - // curve meanings: - // linear = constant rate, exponential = slow start then faster, sinusoidal = smooth s-curve, start/wash/stop/random/baby = scratch specific movements - trackStart: { - // Effect when a new track begins - duration: 0, // ms - curve: 'linear', - type: 'volume' // volume, tape, both - }, - trackEnd: { - // Effect triggered automatically before track finishes - duration: 0, - curve: 'linear', - type: 'volume' - }, - trackStop: { - // Effect when manually stopping or skipping - duration: 0, - curve: 'linear', - type: 'volume' - }, - seek: { - // Effect applied after a seek operation - duration: 0, - curve: 'linear', - type: 'volume' - }, - pause: { - // Effect applied when pausing playback - duration: 0, - curve: 'sinusoidal', - type: 'tape' - }, - resume: { - // Effect applied when resuming from pause - duration: 0, - curve: 'sinusoidal', - type: 'tape' - }, - ducking: { - // Partial fade out for overlay events (e.g., TTS, notifications) - enabled: false, - duration: 0, // ms - targetVolume: 0.3, // Volume multiplier (0.3 = 30%) - curve: 'linear' - } - }, - crossfade: { - enabled: false, - duration: 0, // Crossfade duration in milliseconds - curve: 'sinusoidal', // linear | sine | sinusoidal - mode: 'preload', // preload or stream - minBufferMs: 250, // Minimum buffered PCM before crossfade starts - bufferMs: 0 // 0 = auto (use duration) - } - }, - voiceReceive: { - enabled: false, - format: 'opus' // pcm_s16le, opus - }, - routePlanner: { - strategy: 'RotateOnBan', // RotateOnBan, RoundRobin, LoadBalance - bannedIpCooldown: 600000, // 10 minutes - ipBlocks: [] - }, - rateLimit: { - enabled: true, - global: { - maxRequests: 1000, - timeWindowMs: 60000 // 1 minute - }, - perIp: { - maxRequests: 100, - timeWindowMs: 10000 // 10 seconds - }, - perUserId: { - maxRequests: 50, - timeWindowMs: 5000 // 5 seconds - }, - perGuildId: { - maxRequests: 20, - timeWindowMs: 5000 // 5 seconds - }, - ignorePaths: [], - ignore: { - userIds: [], - guildIds: [], - ips: [] - } - }, - dosProtection: { - enabled: true, - thresholds: { - burstRequests: 50, - timeWindowMs: 10000 // 10 seconds - }, - mitigation: { - delayMs: 500, - blockDurationMs: 300000 // 5 minutes - }, - ignore: { - userIds: [], - guildIds: [], - ips: [] - } - }, metrics: { enabled: true, authorization: { - type: 'Bearer', // Bearer or Basic. + type: 'Bearer', username: 'admin', - password: '' // If empty, uses server.password + password: '' } }, mix: { @@ -661,11 +717,7 @@ export default { maxLayersMix: 5, autoCleanup: true }, - plugins: [ - /* { - name: 'nodelink-sample-plugin', - source: 'local' - } */ - ], + plugins: [], pluginConfig: {} }; +export default config; diff --git a/dist/src/api/loadTracks.js b/dist/src/api/loadTracks.js index 7076b934..fd2bfbb2 100644 --- a/dist/src/api/loadTracks.js +++ b/dist/src/api/loadTracks.js @@ -68,7 +68,11 @@ function parseIdentifier(identifier, defaultSearchSource) { return { kind: 'unifiedSearch', query: groups.query }; } if (groups.source.toLowerCase() === 'isrc') { - return { kind: 'search', source: normalizeDefaultSearchSource(defaultSearchSource), query: groups.query }; + return { + kind: 'search', + source: normalizeDefaultSearchSource(defaultSearchSource), + query: groups.query + }; } return { kind: 'search', source: groups.source, query: groups.query }; } diff --git a/dist/src/api/sessions.id.players.id.sponsorblock.js b/dist/src/api/sessions.id.players.id.sponsorblock.js index 2aae63c6..cf7d1340 100644 --- a/dist/src/api/sessions.id.players.id.sponsorblock.js +++ b/dist/src/api/sessions.id.players.id.sponsorblock.js @@ -44,7 +44,7 @@ async function handleGetSponsorBlock(req, res, pathParams, runtime, sendResponse return; } try { - const state = session.players.getSponsorBlock(pathParams.guildId); + const state = await session.players.getSponsorBlock(pathParams.guildId); sendResponse(req, res, state, 200); } catch (error) { @@ -67,13 +67,17 @@ async function handlePatchSponsorBlock(req, res, pathParams, runtime, sendRespon return; } try { - session.players.updateSponsorBlock(pathParams.guildId, { + const result = await session.players.updateSponsorBlock(pathParams.guildId, { enabled: body.enabled, categories: body.categories, actionTypes: body.actionTypes, skipMarginMs: body.skipMarginMs }); - sendResponse(req, res, session.players.getSponsorBlock(pathParams.guildId), 200); + if (result && 'status' in result && result.status !== 200) { + sendErrorResponse(req, res, result.status || 500, 'Internal Server Error', result.message || 'Operation failed', req.url || ''); + return; + } + sendResponse(req, res, await session.players.getSponsorBlock(pathParams.guildId), 200); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Player not found'; @@ -95,8 +99,12 @@ async function handlePostSponsorBlock(req, res, pathParams, runtime, sendRespons return; } try { - session.players.setSponsorBlockSegments(pathParams.guildId, body.segments); - sendResponse(req, res, session.players.getSponsorBlock(pathParams.guildId), 200); + const result = await session.players.setSponsorBlockSegments(pathParams.guildId, body.segments); + if (result && 'status' in result && result.status !== 200) { + sendErrorResponse(req, res, result.status || 500, 'Internal Server Error', result.message || 'Operation failed', req.url || ''); + return; + } + sendResponse(req, res, await session.players.getSponsorBlock(pathParams.guildId), 200); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Player not found'; @@ -113,7 +121,7 @@ async function handleDeleteSponsorBlock(req, res, pathParams, runtime) { return; } try { - session.players.clearSponsorBlock(pathParams.guildId); + await session.players.clearSponsorBlock(pathParams.guildId); res.writeHead(204); res.end(); } diff --git a/dist/src/index.js b/dist/src/index.js index e0d8ff9b..3796a7dc 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -12,10 +12,10 @@ import http from 'node:http'; import { resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import WebSocketServer from '@performanc/pwsl-server'; -import { migrateConfig } from "./modules/config/configMigration.js"; import RoutePlannerManager from "./managers/routePlannerManager.js"; import SessionManager from "./managers/sessionManager.js"; import StatsManager from "./managers/statsManager.js"; +import { migrateConfig, persistConfig } from "./modules/config/configMigration.js"; import { applyEnvOverrides, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, getGitInfo, getStats, getVersion, initLogger, logger, parseClient, verifyDiscordID } from "./utils.js"; import 'dotenv/config'; import { GatewayEvents, MINIMUM_NODE_VERSION } from "./constants.js"; @@ -157,27 +157,89 @@ const getTrackCacheManagerClass = async () => { let config; const loadConfig = async () => { const resolveRootConfigUrl = (fileName) => pathToFileURL(resolvePath(process.cwd(), fileName)).href; - try { - const imported = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.ts')))).default; - console.log('[INFO] Config: Loaded configuration from config.ts'); - return migrateConfig(imported); - } - catch (e) { - const error = e; - // Handle various error formats for missing modules across different runtimes (Node, Bun, ts-node, tsx) - if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ENOENT' || error.message?.includes('Cannot find module')) { + const resolveConfigExport = (importedModule, fileName) => { + const candidate = importedModule.default ?? + importedModule.config; + if (candidate && + typeof candidate === 'object' && + Object.keys(candidate).length > 0) { + return candidate; + } + throw new Error(`[ERROR] Config: ${fileName} must export a non-empty configuration object (default export or named "config").`); + }; + /** + * Loads and merges configuration files. + * Prioritizes config.ts/js, falls back to config.default.ts/js. + * @returns Migrated and merged configuration object. + */ + const loadAndMerge = async () => { + // 1. Load defaults (Mandatory) + let baseConfig = {}; + let defaultFileName = 'config.default.ts'; + try { + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.ts'))); + baseConfig = resolveConfigExport(module, 'config.default.ts'); + } + catch { try { - const imported = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.ts')))).default; - console.log('[INFO] Config: Loaded fallback configuration from config.default.ts'); - return migrateConfig(imported); + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.js'))); + baseConfig = resolveConfigExport(module, 'config.default.js'); + defaultFileName = 'config.default.js'; } - catch (e2) { - console.error('[ERROR] Config: Failed to load configuration (config.ts or config.default.ts).'); - throw e2; + catch { + throw new Error('[ERROR] Config: Base configuration (config.default.ts/js) not found.'); } } - throw e; - } + // 2. Try to load user configuration + let userConfig = {}; + let userFileName = null; + const userCandidates = ['config.ts', 'config.js']; + for (const fileName of userCandidates) { + try { + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl(fileName))); + userConfig = resolveConfigExport(module, fileName); + userFileName = fileName; + console.log(`[INFO] Config: Loaded user configuration from ${fileName}`); + break; + } + catch (e) { + const error = e; + const isNotFound = error.code === 'ERR_MODULE_NOT_FOUND' || + error.code === 'ENOENT' || + error.message?.includes('Cannot find module'); + if (isNotFound) + continue; + throw e; + } + } + if (!userFileName) { + console.log(`[INFO] Config: No user configuration found. Using defaults.`); + } + // 3. Merge user config over defaults + // We do a deep merge for 'sources', 'lyrics', 'meanings', 'pluginConfig' + // and a shallow merge for the rest to keep it simple but functional. + const merged = { ...baseConfig }; + for (const [key, value] of Object.entries(userConfig)) { + if (value && + typeof value === 'object' && + !Array.isArray(value) && + baseConfig[key] && + typeof baseConfig[key] === 'object' && + !Array.isArray(baseConfig[key])) { + merged[key] = { + ...baseConfig[key], + ...value + }; + } + else { + merged[key] = value; + } + } + const migrated = migrateConfig(merged); + await persistConfig(migrated, userFileName ?? defaultFileName); + return migrated; + }; + return await loadAndMerge(); }; config = await loadConfig(); // Apply environment variable overrides after config is loaded @@ -367,6 +429,7 @@ class NodelinkServer extends EventEmitter { this._sourceInitPromise = this._initSources(isClusterPrimary, options); this.routePlanner = new RoutePlannerManager(this); memoryTrace('constructor:after-route-planner'); + this.proxyManager = null; this.credentialManager = null; memoryTrace('constructor:after-credential-manager'); this.trackCacheManager = null; @@ -1904,7 +1967,7 @@ process.on('unhandledRejection', (reason, promise) => { }); if (clusterEnabled && cluster.isPrimary) { if (config.sources?.youtube?.getOAuthToken) { - // dynamicly import OAuth (if enabled) + // dynamically import OAuth (if enabled) const OAuth = (await import("./sources/youtube/OAuth.js").catch((e) => { logger('error', 'youtube', `\x1b[1m\x1b[31mOAuth class not found Error: ${e.message}\x1b[0m`); process.exit(1); diff --git a/dist/src/lyrics/monochrome.js b/dist/src/lyrics/monochrome.js index 34c5de90..ad5ab847 100644 --- a/dist/src/lyrics/monochrome.js +++ b/dist/src/lyrics/monochrome.js @@ -18,10 +18,8 @@ export default class MonochromeLyrics { */ constructor(nodelink) { this.nodelink = nodelink; - const sources = nodelink.options?.sources; - const config = sources?.monochrome || { - enabled: false - }; + const sources = nodelink.options.sources; + const config = sources.monochrome; const defaultUrls = [ 'https://eu-central.monochrome.tf', 'https://us-west.monochrome.tf', diff --git a/dist/src/managers/baseCacheManager.js b/dist/src/managers/baseCacheManager.js new file mode 100644 index 00000000..4eab1aad --- /dev/null +++ b/dist/src/managers/baseCacheManager.js @@ -0,0 +1,321 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import { logger } from "../utils.js"; +const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); +const getErrorMessage = (error) => error instanceof Error ? error.message : String(error ?? 'Unknown error'); +const getErrorCode = (error) => { + if (!error || typeof error !== 'object' || !('code' in error)) { + return undefined; + } + const code = error.code; + return typeof code === 'string' ? code : undefined; +}; +/** + * Generic encrypted cache file store using AES-256-GCM. + * Handles atomic saves, TTL purging, and max entry eviction. + */ +export default class BaseCacheManager { + cacheName; + passwordHashKey; + salt; + key; + legacyKey; + filePath; + tempFilePath; + saveDelayMs; + cleanupIntervalMs; + maxEntries; + version; + cache; + saveTimeout; + cleanupInterval; + savePromise; + saveQueued; + lastLoadedAt; + lastSavedAt; + constructor(options) { + this.cacheName = options.name; + this.passwordHashKey = options.passwordHashKey; + this.salt = options.salt; + this.filePath = options.filePath; + this.tempFilePath = `${options.filePath}.tmp`; + this.saveDelayMs = options.saveDelayMs ?? 5000; + this.cleanupIntervalMs = options.cleanupIntervalMs ?? 60000; + this.maxEntries = options.maxEntries ?? 0; + this.version = options.version ?? 1; + this.key = this._deriveFastKey(this.passwordHashKey); + this.legacyKey = null; + this.cache = new Map(); + this.saveTimeout = null; + this.savePromise = null; + this.saveQueued = false; + this.lastLoadedAt = null; + this.lastSavedAt = null; + this.cleanupInterval = setInterval(() => { + const expiredCount = this._purgeExpired(); + const evictedCount = this._enforceMaxEntries(); + if (expiredCount > 0 || evictedCount > 0) + this.save(); + }, this.cleanupIntervalMs); + this.cleanupInterval.unref?.(); + } + async load() { + try { + const data = await fs.readFile(this.filePath); + if (data.length < 32) + return; + let payload; + let migratedFromLegacy = false; + try { + payload = this._decodePayload(data, this.key); + } + catch { + const legacyKey = this._getLegacyKey(); + payload = this._decodePayload(data, legacyKey); + migratedFromLegacy = true; + } + this.cache = new Map(Object.entries(payload.entries)); + const expiredCount = this._purgeExpired(); + const evictedCount = this._enforceMaxEntries(); + if (expiredCount > 0 || evictedCount > 0 || migratedFromLegacy) + this.save(); + this.lastLoadedAt = Date.now(); + logger('debug', this.cacheName, `Loaded ${this.cache.size} entries from disk.`); + } + catch (error) { + const code = getErrorCode(error); + if (code !== 'ENOENT') { + logger('error', this.cacheName, `Failed to load cache: ${getErrorMessage(error)}`); + } + this.cache = new Map(); + } + } + save() { + if (this.saveTimeout) + return; + this.saveTimeout = setTimeout(() => { + this.saveTimeout = null; + void this.forceSave(); + }, this.saveDelayMs); + } + async forceSave() { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + try { + this._purgeExpired(); + this._enforceMaxEntries(); + await this._flushSaveQueue(); + logger('debug', this.cacheName, 'Force saved to disk.'); + } + catch (error) { + logger('error', this.cacheName, `Failed to force save: ${getErrorMessage(error)}`); + } + } + clear() { + if (this.cache.size === 0) + return; + this.cache.clear(); + this.save(); + } + /** + * Cleans up internal timers and resources. + */ + destroy() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + } + _getValidEntry(key) { + const entry = this.cache.get(key); + if (!entry) + return null; + if (entry.expiresAt && Date.now() > entry.expiresAt) { + this.cache.delete(key); + this.save(); + return null; + } + if (this.maxEntries > 0) { + this.cache.delete(key); + this.cache.set(key, entry); + } + return entry; + } + _deriveFastKey(password) { + return crypto + .createHash('sha256') + .update(`${this.salt}:${password}`) + .digest(); + } + _getLegacyKey() { + if (!this.legacyKey) { + this.legacyKey = crypto.scryptSync(this.passwordHashKey, this.salt, 32); + } + return this.legacyKey; + } + _purgeExpired() { + const now = Date.now(); + let expiredCount = 0; + for (const [key, entry] of this.cache.entries()) { + if (entry.expiresAt && now > entry.expiresAt) { + this.cache.delete(key); + expiredCount++; + } + } + return expiredCount; + } + _enforceMaxEntries() { + if (this.maxEntries <= 0 || this.cache.size <= this.maxEntries) + return 0; + let removed = 0; + while (this.cache.size > this.maxEntries) { + const oldestKey = this.cache.keys().next().value; + if (!oldestKey) + break; + this.cache.delete(oldestKey); + removed++; + } + return removed; + } + _decodePayload(data, key) { + const iv = data.subarray(0, 16); + const tag = data.subarray(16, 32); + const encrypted = data.subarray(32); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]).toString('utf8'); + const parsed = JSON.parse(decrypted); + return this._normalizePayload(parsed); + } + _normalizePayload(raw) { + const now = Date.now(); + if (isRecord(raw) && !('version' in raw) && !('entries' in raw)) { + return { + version: this.version, + savedAt: now, + entries: this._normalizeEntries(raw, now) + }; + } + if (isRecord(raw)) { + const payloadCandidate = raw; + const entriesValue = payloadCandidate.entries; + if (isRecord(entriesValue)) { + return { + version: payloadCandidate.version ?? this.version, + savedAt: typeof payloadCandidate.savedAt === 'number' + ? payloadCandidate.savedAt + : now, + entries: this._normalizeEntries(entriesValue, now) + }; + } + } + return { + version: this.version, + savedAt: now, + entries: {} + }; + } + _normalizeEntries(rawEntries, fallbackTime) { + const entries = {}; + for (const [key, value] of Object.entries(rawEntries)) { + entries[key] = this._normalizeEntry(value, fallbackTime); + } + return entries; + } + _normalizeEntry(rawValue, fallbackTime) { + if (isRecord(rawValue)) { + const entryCandidate = rawValue; + const value = Object.hasOwn(entryCandidate, 'value') + ? entryCandidate.value + : rawValue; + const createdAt = typeof entryCandidate.createdAt === 'number' + ? entryCandidate.createdAt + : fallbackTime; + const updatedAt = typeof entryCandidate.updatedAt === 'number' + ? entryCandidate.updatedAt + : createdAt; + const expiresAt = typeof entryCandidate.expiresAt === 'number' && + entryCandidate.expiresAt > 0 + ? entryCandidate.expiresAt + : null; + return { + value: value, + createdAt, + updatedAt, + expiresAt + }; + } + return { + value: rawValue, + createdAt: fallbackTime, + updatedAt: fallbackTime, + expiresAt: null + }; + } + _buildPayload() { + return { + version: this.version, + savedAt: Date.now(), + entries: Object.fromEntries(this.cache) + }; + } + async _flushSaveQueue() { + if (this.savePromise) { + this.saveQueued = true; + await this.savePromise; + if (this.saveQueued) { + this.saveQueued = false; + await this._flushSaveQueue(); + } + return; + } + const payload = this._buildPayload(); + this.savePromise = this._writeToDisk(payload); + try { + await this.savePromise; + } + finally { + this.savePromise = null; + } + if (this.saveQueued) { + this.saveQueued = false; + await this._flushSaveQueue(); + } + } + async _writeToDisk(payload) { + const plainText = JSON.stringify(payload); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv); + const encrypted = Buffer.concat([ + cipher.update(plainText, 'utf8'), + cipher.final() + ]); + const tag = cipher.getAuthTag(); + const outBuffer = Buffer.concat([iv, tag, encrypted]); + await fs.mkdir('./.cache', { recursive: true }); + try { + await fs.writeFile(this.tempFilePath, outBuffer); + await fs.rename(this.tempFilePath, this.filePath); + } + catch (error) { + logger('debug', this.cacheName, `Atomic save failed, falling back to direct write: ${getErrorMessage(error)}`); + await fs.writeFile(this.filePath, outBuffer); + try { + await fs.unlink(this.tempFilePath); + } + catch (cleanupError) { + logger('debug', this.cacheName, `Failed to remove temp cache file: ${getErrorMessage(cleanupError)}`); + } + } + this.lastSavedAt = payload.savedAt; + } +} diff --git a/dist/src/managers/configValidationManager.js b/dist/src/managers/configValidationManager.js index c20f626a..cc90e97c 100644 --- a/dist/src/managers/configValidationManager.js +++ b/dist/src/managers/configValidationManager.js @@ -1,5 +1,5 @@ -import { validator } from "../validators.js"; import { logger } from "../utils.js"; +import { validator } from "../validators.js"; /** * Validates the NodeLink configuration object using a schema-based approach. * Supports the new hierarchical configuration structure. @@ -19,48 +19,81 @@ export default class ConfigValidationManager { type: 'object', props: { host: { type: 'string', default: '0.0.0.0' }, - port: { type: 'number', integer: true, min: 1, max: 65535, default: 3000 }, - engine: { type: 'string', enum: ['default', 'bun'], default: 'default' } + port: { + type: 'number', + integer: true, + min: 1, + max: 65535, + default: 3000 + }, + engine: { + type: 'string', + enum: ['default', 'bun'], + default: 'default', + optional: true + }, + password: { type: 'string', min: 1, optional: true }, + useBunServer: { type: 'boolean', default: false, optional: true } } }, - security: { + cluster: { type: 'object', props: { - password: { type: 'string', min: 1 }, - trustProxy: { type: 'boolean', default: false }, - dosProtection: { - type: 'object', - props: { - enabled: { type: 'boolean', default: true }, - thresholds: { type: 'object' }, - mitigation: { type: 'object' } - } - }, - rateLimit: { + enabled: { type: 'boolean', default: true }, + workers: { type: 'number', integer: true, min: 0 }, + minWorkers: { type: 'number', integer: true, min: 0, default: 1 }, + timeouts: { type: 'object', + optional: true, props: { - enabled: { type: 'boolean', default: true }, - global: { type: 'object' }, - perIp: { type: 'object' } + heavyMs: { + type: 'number', + integer: true, + min: 100, + default: 6000 + }, + fastMs: { type: 'number', integer: true, min: 100, default: 4000 } } } } }, - cluster: { + logging: { type: 'object', props: { - enabled: { type: 'boolean', default: true }, - workers: { type: 'number', integer: true, min: 0 }, - minWorkers: { type: 'number', integer: true, min: 0, default: 1 }, - timeouts: { + level: { type: 'string', default: 'info' }, + file: { type: 'object', props: { - heavyMs: { type: 'number', integer: true, min: 100, default: 6000 }, - fastMs: { type: 'number', integer: true, min: 100, default: 4000 } + enabled: { type: 'boolean', default: false }, + path: { type: 'string', default: 'logs' } } } } }, + connection: { + type: 'object', + props: { + interval: { type: 'number', integer: true, min: 1000 }, + timeout: { type: 'number', integer: true, min: 1000 } + } + }, + rateLimit: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + global: { type: 'object' }, + perIp: { type: 'object' } + } + }, + dosProtection: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + thresholds: { type: 'object' }, + mitigation: { type: 'object' } + } + }, + trustProxy: { type: 'boolean', default: false }, network: { type: 'object', props: { @@ -68,7 +101,15 @@ export default class ConfigValidationManager { type: 'object', props: { enabled: { type: 'boolean', default: false }, - strategy: { type: 'string', enum: ['RoundRobin', 'LeastConnections', 'LowestLatency', 'WeightedHealth'] } + strategy: { + type: 'string', + enum: [ + 'RoundRobin', + 'LeastConnections', + 'LowestLatency', + 'WeightedHealth' + ] + } } }, connection: { @@ -83,30 +124,117 @@ export default class ConfigValidationManager { search: { type: 'object', props: { - maxResults: { type: 'number', integer: true, min: 1, max: 100, default: 10 }, - defaultSource: { type: 'array', items: 'string' } + maxResults: { + type: 'number', + integer: true, + min: 1, + max: 100, + default: 10 + }, + defaultSource: { + type: 'multi', + rules: [{ type: 'array', items: 'string' }, { type: 'string' }] + }, + unifiedSources: { type: 'array', items: 'string', optional: true }, + resolveExternalLinks: { type: 'boolean', default: false }, + fetchChannelInfo: { type: 'boolean', default: false } } }, playback: { type: 'object', props: { - maxPlaylistLength: { type: 'number', integer: true, min: 0, default: 100 }, - intervals: { + maxPlaylistLength: { + type: 'number', + integer: true, + min: 0, + default: 100 + }, + playerUpdateInterval: { + type: 'number', + integer: true, + min: 100, + default: 2000, + optional: true + }, + statsUpdateInterval: { + type: 'number', + integer: true, + min: 100, + default: 30000, + optional: true + }, + trackStuckThresholdMs: { + type: 'number', + integer: true, + min: 100, + default: 10000, + optional: true + }, + eventTimeoutMs: { + type: 'number', + integer: true, + min: 100, + default: 3000, + optional: true + }, + zombieThresholdMs: { + type: 'number', + integer: true, + min: 100, + default: 300000, + optional: true + }, + sponsorblock: { type: 'object', + optional: true, props: { - playerUpdateMs: { type: 'number', integer: true, min: 100, default: 2000 }, - statsUpdateMs: { type: 'number', integer: true, min: 100, default: 30000 } + enabled: { type: 'boolean', default: false }, + api: { type: 'string', optional: true }, + categories: { type: 'array', items: 'string', optional: true }, + actionTypes: { type: 'array', items: 'string', optional: true } } }, + filters: { type: 'object', optional: true }, audio: { type: 'object', + optional: true, props: { - quality: { type: 'string', enum: ['high', 'medium', 'low', 'lowest'] }, - encryption: { type: 'string' } + quality: { + type: 'string', + enum: ['high', 'medium', 'low', 'lowest'] + }, + encryption: { type: 'string', optional: true }, + resamplingQuality: { type: 'string', optional: true }, + loudnessNormalizer: { type: 'boolean', optional: true }, + fading: { type: 'object', optional: true }, + crossfade: { type: 'object', optional: true } } - } + }, + voiceReceive: { type: 'object', optional: true }, + mix: { type: 'object', optional: true } } - } + }, + api: { + type: 'object', + props: { + enableTrackStreamEndpoint: { type: 'boolean', default: false }, + enableLoadStreamEndpoint: { type: 'boolean', default: false }, + metrics: { type: 'object', optional: true } + } + }, + experimental: { + type: 'object', + props: { + enableHoloTracks: { type: 'boolean', default: false } + } + }, + sources: { type: 'object' }, + lyrics: { type: 'object' }, + meanings: { type: 'object' }, + metrics: { type: 'object' }, + mix: { type: 'object' }, + plugins: { type: 'array', items: 'object', optional: true }, + pluginConfig: { type: 'object', optional: true } }; const check = validator.compile(schema); const result = check(this.config); @@ -115,7 +243,7 @@ export default class ConfigValidationManager { for (const error of errors) { logger('error', 'Config', `Validation error at ${error.field}: ${error.message}`); } - throw new Error(`Configuration validation failed: ${errors.map(e => e.message).join(', ')}`); + throw new Error(`Configuration validation failed: ${errors.map((e) => e.message).join(', ')}`); } logger('info', 'Config', 'Configuration validated successfully.'); } diff --git a/dist/src/managers/connectionManager.js b/dist/src/managers/connectionManager.js index 808faedd..a501ee18 100644 --- a/dist/src/managers/connectionManager.js +++ b/dist/src/managers/connectionManager.js @@ -54,7 +54,8 @@ export default class ConnectionManager { _lastPingMs; constructor(nodelink) { this.nodelink = nodelink; - this.config = nodelink.options.network.connection || {}; + this.config = + nodelink.options.network?.connection || nodelink.options.connection || {}; this.interval = null; this.status = 'unknown'; this.metrics = { timestamp: Date.now() }; diff --git a/dist/src/managers/credentialManager.js b/dist/src/managers/credentialManager.js index 7a708292..d1602dcf 100644 --- a/dist/src/managers/credentialManager.js +++ b/dist/src/managers/credentialManager.js @@ -4,7 +4,7 @@ const CREDENTIALS_VERSION = 1; const DEFAULT_SAVE_DELAY_MS = 1000; const DEFAULT_CREDENTIALS_PATH = './.cache/credentials.bin'; const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000; -const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); +const _isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); /** * Encrypted credential store with TTL support and debounced persistence. * @remarks @@ -113,12 +113,7 @@ export default class CredentialManager extends BaseCacheManager { }; } static _resolvePassword(options) { - const optionsCandidate = options; - const serverCandidate = optionsCandidate.server; - const server = isRecord(serverCandidate) - ? serverCandidate - : null; - const password = server && typeof server.password === 'string' ? server.password : null; + const password = options.server?.password; if (!password) { throw new Error('CredentialManager requires options.server.password'); } diff --git a/dist/src/managers/dosProtectionManager.js b/dist/src/managers/dosProtectionManager.js index 85e8fd77..8a00cc66 100644 --- a/dist/src/managers/dosProtectionManager.js +++ b/dist/src/managers/dosProtectionManager.js @@ -40,7 +40,9 @@ export default class DosProtectionManager { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = this._resolveConfig(nodelink.options?.api.dosProtection); + this.config = this._resolveConfig(nodelink.options?.api?.dosProtection ?? + nodelink.options?.security?.dosProtection ?? + nodelink.options?.dosProtection); this.ipRequestCounts = new Map(); this.cleanupInterval = setInterval(() => this._cleanup(), this._resolveCleanupInterval()); this.cleanupInterval.unref?.(); diff --git a/dist/src/managers/playerManager.js b/dist/src/managers/playerManager.js index 164aa113..5d2a6357 100644 --- a/dist/src/managers/playerManager.js +++ b/dist/src/managers/playerManager.js @@ -499,7 +499,7 @@ export default class PlayerManager { /** * Returns current SponsorBlock state for a player. */ - getSponsorBlock(guildId) { + async getSponsorBlock(guildId) { if (this.isCluster) { return this.runClusterPlayerCommand(guildId, 'getSponsorBlock', []); } diff --git a/dist/src/managers/proxyManager.js b/dist/src/managers/proxyManager.js new file mode 100644 index 00000000..4324f410 --- /dev/null +++ b/dist/src/managers/proxyManager.js @@ -0,0 +1,378 @@ +import { logger } from "../utils.js"; +const DEFAULT_CONFIG = { + enabled: false, + strategy: 'WeightedHealth', + disabledSources: [], + maxConsecutiveFailures: 3, + cooldownDurationMs: 60 * 1000 * 5, // 5 minutes + latencyWeight: 0.2 // EMA alpha +}; +const isRecord = (val) => typeof val === 'object' && val !== null && !Array.isArray(val); +export default class ProxyManager { + config; + targets; + states; + roundRobinIndex = 0; + constructor(options) { + this.targets = new Map(); + this.states = new Map(); + const network = isRecord(options.network) ? options.network : {}; + const rawConfig = isRecord(network.proxy) ? network.proxy : {}; + this.config = this.parseConfig(rawConfig); + this.extractTargets(rawConfig, options); + } + /** + * Evaluates if a given source is allowed to use proxies. + */ + isEnabledForSource(sourceName) { + if (!this.config.enabled) + return false; + if (this.config.disabledSources.includes(sourceName.toLowerCase())) + return false; + return this.targets.size > 0; + } + /** + * Selects the most optimal proxy based on the configured Load Balancing strategy. + * Employs a Circuit Breaker pattern to avoid dead proxies. + * + * @param sourceName - The identifier of the requesting source (e.g. 'youtube', 'bilibili') + * @returns A safe snapshot of the selected proxy, or `undefined` if none available. + */ + getBestProxy(sourceName) { + if (!this.isEnabledForSource(sourceName)) + return undefined; + this.processCooldowns(); + const availableIds = Array.from(this.states.entries()) + .filter(([_, state]) => state.status === 'UP') + .map(([id]) => id); + if (availableIds.length === 0) { + logger('warn', 'ProxyManager', `No healthy proxies available for source '${sourceName}'. All proxies are DOWN or in COOLDOWN.`); + return undefined; + } + let selectedId; + switch (this.config.strategy) { + case 'RoundRobin': + selectedId = this.selectRoundRobin(availableIds); + break; + case 'LeastConnections': + selectedId = this.selectLeastConnections(availableIds); + break; + case 'LowestLatency': + selectedId = this.selectLowestLatency(availableIds); + break; + case 'WeightedHealth': + selectedId = this.selectWeightedHealth(availableIds); + break; + default: + selectedId = this.selectWeightedHealth(availableIds); + } + if (!selectedId) + return undefined; + const state = this.states.get(selectedId); + const target = this.targets.get(selectedId); + if (!state || !target) + return undefined; + state.activeConnections++; + return { + target: { ...target }, + state: { ...state } + }; + } + /** + * Reports the resolution of a request utilizing a proxy. + * Updates health metrics, calculates exponential moving average latency, + * and trips the circuit breaker if thresholds are met. + */ + report(proxyUrl, success, statusCode, latencyMs = 0) { + const id = this.findIdByUrl(proxyUrl); + if (!id) + return; + const state = this.states.get(id); + if (!state) + return; + if (state.activeConnections > 0) { + state.activeConnections--; + } + if (success) { + this.handleSuccess(state, latencyMs); + } + else { + this.handleFailure(state, statusCode); + } + } + /** + * Allows adding dynamic proxies in runtime. + */ + addProxy(url, protocol = 'http') { + try { + const parsed = this.parseProxyUrl(url, protocol); + if (!this.targets.has(parsed.id)) { + this.targets.set(parsed.id, parsed); + this.states.set(parsed.id, this.createInitialState()); + logger('info', 'ProxyManager', `Dynamically added proxy: ${parsed.host}:${parsed.port}`); + } + } + catch (err) { + logger('error', 'ProxyManager', `Failed to add proxy ${url}: ${err instanceof Error ? err.message : String(err)}`); + } + } + /** + * Cleans up proxy manager resources. + */ + destroy() { + this.targets.clear(); + this.states.clear(); + } + handleSuccess(state, latencyMs) { + state.totalSuccesses++; + state.consecutiveFailures = 0; + state.lastSuccessAt = Date.now(); + if (latencyMs > 0) { + if (state.movingAverageLatency === 0) { + state.movingAverageLatency = latencyMs; + } + else { + const alpha = this.config.latencyWeight; + state.movingAverageLatency = + alpha * latencyMs + (1 - alpha) * state.movingAverageLatency; + } + } + if (state.status === 'DOWN' || state.status === 'COOLDOWN') { + state.status = 'UP'; + state.cooldownEndsAt = null; + } + } + handleFailure(state, statusCode) { + state.totalFailures++; + state.consecutiveFailures++; + state.lastFailureAt = Date.now(); + // Status code severity analysis + let failureWeight = 1; + if (statusCode === 403 || statusCode === 429) { + failureWeight = 3; // Severe rate limit or ban + } + else if (statusCode >= 500) { + failureWeight = 2; // Server side error + } + state.consecutiveFailures += failureWeight - 1; + if (state.consecutiveFailures >= this.config.maxConsecutiveFailures) { + this.tripCircuitBreaker(state); + } + } + tripCircuitBreaker(state) { + state.status = 'COOLDOWN'; + state.cooldownEndsAt = Date.now() + this.config.cooldownDurationMs; + logger('warn', 'ProxyManager', `Circuit breaker tripped for proxy. Cooling down for ${this.config.cooldownDurationMs / 1000}s.`); + } + processCooldowns() { + const now = Date.now(); + for (const state of this.states.values()) { + if (state.status === 'COOLDOWN' && + state.cooldownEndsAt && + now >= state.cooldownEndsAt) { + // Half-open state: transition back to UP to test it. If it fails once, it will trip again quickly. + state.status = 'UP'; + state.cooldownEndsAt = null; + // We reduce consecutive failures so it gets one more chance before tripping again + state.consecutiveFailures = Math.max(0, this.config.maxConsecutiveFailures - 1); + logger('info', 'ProxyManager', 'Proxy exited cooldown and is entering half-open state.'); + } + } + } + selectRoundRobin(availableIds) { + if (this.roundRobinIndex >= availableIds.length) { + this.roundRobinIndex = 0; + } + const selected = availableIds[this.roundRobinIndex]; + this.roundRobinIndex = (this.roundRobinIndex + 1) % availableIds.length; + return selected || availableIds[0] || ''; + } + selectLeastConnections(availableIds) { + return availableIds.reduce((prevId, currId) => { + const prevConnections = this.states.get(prevId)?.activeConnections ?? 0; + const currConnections = this.states.get(currId)?.activeConnections ?? 0; + return currConnections < prevConnections ? currId : prevId; + }); + } + selectLowestLatency(availableIds) { + return availableIds.reduce((prevId, currId) => { + const prevLatency = this.states.get(prevId)?.movingAverageLatency || Infinity; + const currLatency = this.states.get(currId)?.movingAverageLatency || Infinity; + // If both are 0 (untested), fallback to random or first + if (prevLatency === Infinity && currLatency === Infinity) + return prevId; + return currLatency < prevLatency ? currId : prevId; + }); + } + selectWeightedHealth(availableIds) { + // Sort by a composite score: (failures * high_penalty) + (active_connections * medium_penalty) + latency + const sorted = [...availableIds].sort((a, b) => { + const stateA = this.states.get(a); + const stateB = this.states.get(b); + if (!stateA || !stateB) + return 0; + const scoreA = stateA.consecutiveFailures * 1000 + + stateA.activeConnections * 100 + + (stateA.movingAverageLatency || 500); + const scoreB = stateB.consecutiveFailures * 1000 + + stateB.activeConnections * 100 + + (stateB.movingAverageLatency || 500); + return scoreA - scoreB; + }); + // Pick from the top 3 randomly to distribute load while still preferring healthy proxies + const poolSize = Math.min(3, sorted.length); + const randomIndex = Math.floor(Math.random() * poolSize); + return sorted[randomIndex] || availableIds[0] || ''; + } + findIdByUrl(url) { + for (const [id, target] of this.targets.entries()) { + if (target.url === url) + return id; + } + return undefined; + } + parseConfig(raw) { + const disabledSourcesCandidate = Array.isArray(raw.disabledSources) + ? raw.disabledSources + .filter((s) => typeof s === 'string') + .map((s) => s.toLowerCase()) + : DEFAULT_CONFIG.disabledSources; + let strategy = DEFAULT_CONFIG.strategy; + if (typeof raw.strategy === 'string') { + const s = raw.strategy; + if ([ + 'RoundRobin', + 'LeastConnections', + 'LowestLatency', + 'WeightedHealth' + ].includes(s)) { + strategy = s; + } + } + return { + enabled: typeof raw.enabled === 'boolean' ? raw.enabled : DEFAULT_CONFIG.enabled, + strategy, + disabledSources: disabledSourcesCandidate, + maxConsecutiveFailures: typeof raw.maxConsecutiveFailures === 'number' + ? raw.maxConsecutiveFailures + : DEFAULT_CONFIG.maxConsecutiveFailures, + cooldownDurationMs: typeof raw.cooldownDurationMs === 'number' + ? raw.cooldownDurationMs + : DEFAULT_CONFIG.cooldownDurationMs, + latencyWeight: typeof raw.latencyWeight === 'number' + ? raw.latencyWeight + : DEFAULT_CONFIG.latencyWeight + }; + } + extractTargets(rawConfig, fullOptions) { + // 1. Check global proxy object (single) + if (typeof rawConfig.url === 'string' && rawConfig.url.length > 0) { + try { + const parsed = this.parseProxyUrl(rawConfig.url, rawConfig.protocol || 'http', rawConfig.username, rawConfig.password, rawConfig.type); + this.targets.set(parsed.id, parsed); + } + catch (err) { + logger('error', 'ProxyManager', `Invalid global proxy URL: ${getErrorMessage(err)}`); + } + } + // 2. Check global proxy list + if (Array.isArray(rawConfig.list)) { + for (const item of rawConfig.list) { + try { + if (typeof item === 'string') { + const parsed = this.parseProxyUrl(item); + this.targets.set(parsed.id, parsed); + } + else if (isRecord(item) && typeof item.url === 'string') { + const parsed = this.parseProxyUrl(item.url, item.protocol, item.username, item.password, item.type); + this.targets.set(parsed.id, parsed); + } + } + catch (err) { + logger('error', 'ProxyManager', `Invalid proxy in list: ${getErrorMessage(err)}`); + } + } + } + // 3. Fallback: Migrate old YouTube proxies into the global pool if global list is empty + if (this.targets.size === 0) { + const sources = isRecord(fullOptions.sources) ? fullOptions.sources : {}; + const yt = isRecord(sources.youtube) ? sources.youtube : {}; + if (Array.isArray(yt.proxies)) { + for (const item of yt.proxies) { + try { + if (typeof item === 'string') { + const parsed = this.parseProxyUrl(item); + this.targets.set(parsed.id, parsed); + } + else if (isRecord(item) && typeof item.url === 'string') { + const parsed = this.parseProxyUrl(item.url, undefined, undefined, undefined, item.type); + this.targets.set(parsed.id, parsed); + } + } + catch (err) { + logger('error', 'ProxyManager', `Invalid youtube fallback proxy: ${getErrorMessage(err)}`); + } + } + } + // If we inherited proxies from youtube, implicitly enable the global manager + if (this.targets.size > 0 && typeof rawConfig.enabled !== 'boolean') { + this.config.enabled = true; + logger('info', 'ProxyManager', `Automatically enabled global ProxyManager using inherited YouTube proxies. (${this.targets.size} found)`); + } + } + for (const id of this.targets.keys()) { + this.states.set(id, this.createInitialState()); + } + } + parseProxyUrl(urlString, defaultProtocol = 'http', explicitUsername, explicitPassword, explicitType) { + let toParse = urlString; + if (!toParse.includes('://')) { + toParse = `${defaultProtocol}://${toParse}`; + } + const url = new URL(toParse); + let protocol = url.protocol.replace(':', ''); + if (!['http', 'https', 'socks4', 'socks5'].includes(protocol)) { + protocol = 'http'; + } + const username = explicitUsername || + (url.username ? decodeURIComponent(url.username) : undefined); + const password = explicitPassword || + (url.password ? decodeURIComponent(url.password) : undefined); + const host = url.hostname; + const port = url.port + ? parseInt(url.port, 10) + : protocol === 'https' + ? 443 + : 80; + const typeStr = explicitType === 'reverse' ? 'reverse' : 'forward'; + const id = `${protocol}://${host}:${port}`; + return { + id, + url: toParse, + protocol, + type: typeStr, + host, + port, + username, + password + }; + } + createInitialState() { + return { + status: 'UP', + consecutiveFailures: 0, + totalFailures: 0, + totalSuccesses: 0, + activeConnections: 0, + movingAverageLatency: 0, + lastFailureAt: null, + lastSuccessAt: null, + cooldownEndsAt: null + }; + } +} +function getErrorMessage(error) { + if (error instanceof Error) + return error.message; + return String(error); +} diff --git a/dist/src/managers/rateLimitManager.js b/dist/src/managers/rateLimitManager.js index 878f9726..ceffd9e4 100644 --- a/dist/src/managers/rateLimitManager.js +++ b/dist/src/managers/rateLimitManager.js @@ -48,7 +48,9 @@ export default class RateLimitManager { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = this._resolveConfig(nodelink.options?.api.rateLimit); + this.config = this._resolveConfig(nodelink.options?.api?.rateLimit ?? + nodelink.options?.security?.rateLimit ?? + nodelink.options?.rateLimit); this.store = new Map(); this.cleanupInterval = setInterval(() => this._cleanup(), this._resolveCleanupInterval()); this.cleanupInterval.unref?.(); diff --git a/dist/src/managers/routePlannerManager.js b/dist/src/managers/routePlannerManager.js index 893890a1..3a7edb75 100644 --- a/dist/src/managers/routePlannerManager.js +++ b/dist/src/managers/routePlannerManager.js @@ -1,4 +1,27 @@ import { logger } from "../utils.js"; +const normalizeIpBlocks = (blocks) => { + if (!Array.isArray(blocks)) + return []; + return blocks + .map((block) => { + if (typeof block === 'string') + return { cidr: block }; + if (block && + typeof block === 'object' && + typeof block.cidr === 'string') { + return block; + } + return null; + }) + .filter((block) => block !== null); +}; +const resolveConfig = (nodelink) => { + const cfg = nodelink.options.network.routePlanner; + return { + ...cfg, + ipBlocks: normalizeIpBlocks(cfg.ipBlocks) + }; +}; const BIGINT_MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); /** * Provides outbound IP selection and ban tracking for route-planned requests. @@ -24,7 +47,7 @@ export default class RoutePlannerManager { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = nodelink.options.network.routePlanner ?? {}; + this.config = resolveConfig(nodelink); this.blocks = []; this.bannedIps = new Map(); this.bannedBlocks = new Map(); @@ -40,6 +63,12 @@ export default class RoutePlannerManager { get ipBlocks() { return this.blocks; } + /** + * Compatibility alias used by shutdown paths. + */ + dispose() { + this.freeAll(); + } /** * Converts an IPv4 or IPv6 address string to bigint form. * @param ip - IP address string. diff --git a/dist/src/managers/sourceManager.js b/dist/src/managers/sourceManager.js index 154723b7..28175bc0 100644 --- a/dist/src/managers/sourceManager.js +++ b/dist/src/managers/sourceManager.js @@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { logger } from "../utils.js"; +import { getBestMatch, logger } from "../utils.js"; /** * Central manager for audio source providers. * Handles source discovery, dynamic loading, and request routing based on URL patterns or aliases. @@ -54,12 +54,8 @@ export default class SourcesManager { const processSource = async (name, mod) => { const isYouTube = name === 'youtube' || name.includes('YouTube.ts'); const sourceKey = isYouTube ? 'youtube' : name; - const youtubeKey = 'youtube'; const sourceConfig = this.nodelink.options.sources; - const enabled = isYouTube - ? sourceConfig?.[youtubeKey] - ?.enabled - : !!sourceConfig?.[sourceKey]?.enabled; + const enabled = sourceConfig[sourceKey]?.enabled; if (!enabled) return; const importedModule = mod; @@ -103,9 +99,13 @@ export default class SourcesManager { }; try { await fs.access(sourcesDir); - const enabledSourceKeys = Object.entries(this.nodelink.options.sources || {}) - .filter(([, cfg]) => !!cfg?.enabled) - .map(([key]) => key.toLowerCase()); + const sources = this.nodelink.options.sources; + const enabledSourceKeys = Object.keys(sources) + .filter((key) => { + const config = sources[key]; + return config?.enabled; + }) + .map((key) => key.toLowerCase()); const uniqueEnabled = Array.from(new Set(enabledSourceKeys)); const sourceEntries = uniqueEnabled.map((sourceKey) => { const fileCandidates = sourceKey === 'youtube' @@ -221,9 +221,10 @@ export default class SourcesManager { * @public */ async searchWithDefault(query) { - const defaultSources = Array.isArray(this.nodelink.options.search.defaultSource) - ? this.nodelink.options.search.defaultSource - : [this.nodelink.options.search.defaultSource]; + const configuredDefaultSource = this.nodelink.options.search.defaultSource; + const defaultSources = Array.isArray(configuredDefaultSource) + ? configuredDefaultSource + : [configuredDefaultSource]; for (const source of defaultSources) { try { const result = await this.search(source, query); @@ -246,9 +247,7 @@ export default class SourcesManager { * @public */ async unifiedSearch(query) { - const searchSources = (this.nodelink.options.search.unifiedSources || [ - 'youtube' - ]); + const searchSources = this.nodelink.options.search.unifiedSources; logger('debug', 'Sources', `Performing unified search for "${query}" on [${searchSources.join(', ')}]`); const searchPromises = searchSources.map((sourceName) => this._instrumentedSourceCall(sourceName, 'search', query).catch((e) => { logger('warn', 'Sources', `A source (${sourceName}) failed during unified search: ${e.message}`); @@ -348,7 +347,7 @@ export default class SourcesManager { const match = getBestMatch(searchResult.data, track); if (match) { logger('info', 'Sources', `ISRC Upscale: found match for ${track.isrc} on ${source}. Using high-quality source.`); - return (await this.getTrackUrl(match.info, undefined, false, true)); + return await this.getTrackUrl(match.info, undefined, false, true); } } } diff --git a/dist/src/managers/trackCacheManager.js b/dist/src/managers/trackCacheManager.js index 7760d92f..4ab9fd3f 100644 --- a/dist/src/managers/trackCacheManager.js +++ b/dist/src/managers/trackCacheManager.js @@ -5,7 +5,7 @@ const DEFAULT_SAVE_DELAY_MS = 5000; const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; const DEFAULT_MAX_ENTRIES = 5000; const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000; -const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); +const _isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); /** * Encrypted cache for resolved track metadata and URLs. * @remarks Uses AES-256-GCM and purges expired entries on load and access. @@ -68,16 +68,9 @@ export default class TrackCacheManager extends BaseCacheManager { this.save(); } static _resolveCacheOptions(options) { - const directCandidate = options.trackCache; - const rootCache = isRecord(directCandidate) ? directCandidate : null; - const nestedCandidate = options.cache; - const nestedCache = isRecord(nestedCandidate) - ? nestedCandidate.track - : null; - const nestedTrackCache = isRecord(nestedCache) ? nestedCache : null; - const selected = rootCache ?? nestedTrackCache; - const maxEntriesRaw = selected?.maxEntries; - const cleanupIntervalRaw = selected?.cleanupIntervalMs; + const playback = options.playback; + const maxEntriesRaw = playback.maxPlaylistLength; + const cleanupIntervalRaw = playback.statsUpdateInterval; const maxEntries = typeof maxEntriesRaw === 'number' && Number.isFinite(maxEntriesRaw) ? Math.max(100, Math.floor(maxEntriesRaw)) : DEFAULT_MAX_ENTRIES; @@ -88,12 +81,7 @@ export default class TrackCacheManager extends BaseCacheManager { return { maxEntries, cleanupIntervalMs }; } static _resolvePassword(options) { - const optionsCandidate = options; - const serverCandidate = optionsCandidate.server; - const server = isRecord(serverCandidate) - ? serverCandidate - : null; - const password = server && typeof server.password === 'string' ? server.password : null; + const password = options.server?.password; if (!password) { throw new Error('TrackCacheManager requires options.server.password'); } diff --git a/dist/src/modules/config/configMigration.js b/dist/src/modules/config/configMigration.js new file mode 100644 index 00000000..f27ca563 --- /dev/null +++ b/dist/src/modules/config/configMigration.js @@ -0,0 +1,185 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +/** + * Migrates old flat configuration structures to the new hierarchical NodeLink schema. + */ +export function migrateConfig(oldConfig) { + const newConfig = JSON.parse(JSON.stringify(oldConfig)); + const migrationMap = { + host: 'server.host', + port: 'server.port', + password: 'server.password', + useBunServer: 'server.useBunServer', + trustProxy: 'trustProxy', + connection: 'connection', + proxy: 'network.proxy', + routePlanner: 'network.routePlanner', + maxSearchResults: 'search.maxResults', + defaultSearchSource: 'search.defaultSource', + unifiedSearchSources: 'search.unifiedSources', + resolveExternalLinks: 'search.resolveExternalLinks', + fetchChannelInfo: 'search.fetchChannelInfo', + maxAlbumPlaylistLength: 'playback.maxPlaylistLength', + playerUpdateInterval: 'playback.playerUpdateInterval', + statsUpdateInterval: 'playback.statsUpdateInterval', + trackStuckThresholdMs: 'playback.trackStuckThresholdMs', + eventTimeoutMs: 'playback.eventTimeoutMs', + zombieThresholdMs: 'playback.zombieThresholdMs', + sponsorblock: 'playback.sponsorblock', + filters: 'playback.filters', + audio: 'playback.audio', + voiceReceive: 'playback.voiceReceive', + mix: 'playback.mix', + enableTrackStreamEndpoint: 'api.enableTrackStreamEndpoint', + enableLoadStreamEndpoint: 'api.enableLoadStreamEndpoint', + dosProtection: 'dosProtection', + rateLimit: 'rateLimit', + metrics: 'metrics', + enableHoloTracks: 'experimental.enableHoloTracks', + commandTimeout: 'cluster.timeouts.heavyMs', + fastCommandTimeout: 'cluster.timeouts.fastMs' + }; + for (const [oldKey, newPath] of Object.entries(migrationMap)) { + if (Object.hasOwn(oldConfig, oldKey)) { + const value = oldConfig[oldKey]; + if (value === undefined) + continue; + // Do not overwrite already-migrated or explicit hierarchical values. + if (getDeepValue(newConfig, newPath) !== undefined) { + continue; + } + setDeepValue(newConfig, newPath, value); + } + } + const cluster = newConfig.cluster; + const network = newConfig.network; + const clusterTimeouts = cluster?.timeouts; + const connection = getDeepValue(newConfig, 'connection'); + if (connection) { + setDeepValue(newConfig, 'network.connection', connection); + } + const metrics = getDeepValue(newConfig, 'metrics'); + if (metrics) { + setDeepValue(newConfig, 'api.metrics', metrics); + } + if (typeof cluster?.commandTimeout === 'number' && + typeof clusterTimeouts?.heavyMs !== 'number') { + setDeepValue(newConfig, 'cluster.timeouts.heavyMs', cluster.commandTimeout); + } + if (typeof cluster?.fastCommandTimeout === 'number' && + typeof clusterTimeouts?.fastMs !== 'number') { + setDeepValue(newConfig, 'cluster.timeouts.fastMs', cluster.fastCommandTimeout); + } + if (!network?.proxy || typeof network.proxy !== 'object') { + setDeepValue(newConfig, 'network.proxy', { + enabled: false, + strategy: 'RoundRobin', + retries: 3, + timeout: 10000, + shuffleOnStart: true, + list: [] + }); + } + return newConfig; +} +function setDeepValue(obj, path, value) { + const parts = path.split('.'); + if (parts.length === 0) + return; + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!part) + continue; + if (!current[part] || typeof current[part] !== 'object') { + current[part] = {}; + } + current = current[part]; + } + const leaf = parts[parts.length - 1]; + if (!leaf) + return; + current[leaf] = value; +} +function getDeepValue(obj, path) { + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (!current || + typeof current !== 'object' || + Array.isArray(current) || + !(part in current)) { + return undefined; + } + const next = current[part]; + if (next === undefined) + return undefined; + current = next; + } + return current; +} +/** + * Converts a JavaScript object to a TypeScript literal string. + * Uses single quotes and avoids quoting keys when possible. + * + * @param obj - The object to convert. + * @param indent - Current indentation level. + * @returns A formatted string representation of the object. + * @internal + */ +export function toTsLiteral(obj, indent = 0) { + const spaces = ' '.repeat(indent); + const nextSpaces = ' '.repeat(indent + 2); + if (obj === null) + return 'null'; + if (typeof obj === 'string') + return `'${obj.replace(/'/g, "\\'")}'`; + if (typeof obj !== 'object') + return String(obj); + if (Array.isArray(obj)) { + if (obj.length === 0) + return '[]'; + const items = obj + .map((v) => toTsLiteral(v, indent + 2)) + .join(`,\n${nextSpaces}`); + return `[\n${nextSpaces}${items}\n${spaces}]`; + } + const keys = Object.keys(obj); + if (keys.length === 0) + return '{}'; + const props = keys + .map((key) => { + const val = obj[key]; + if (val === undefined) + return null; + const value = toTsLiteral(val, indent + 2); + const validKey = /^[a-z_$][a-z0-9_$]*$/i.test(key) ? key : `'${key}'`; + return `${validKey}: ${value}`; + }) + .filter((v) => v !== null) + .join(`,\n${nextSpaces}`); + return `{\n${nextSpaces}${props}\n${spaces}}`; +} +/** + * Persists a configuration object to config.ts if it was loaded from a legacy + * source or the default template. + * + * @param config - The migrated configuration object. + * @param fileName - The name of the source file. + */ +export async function persistConfig(config, fileName) { + const isLegacy = fileName.endsWith('.js'); + const isDefault = fileName.startsWith('config.default'); + if (!isLegacy && !isDefault) + return; + try { + const configTsPath = path.resolve(process.cwd(), 'config.ts'); + const literal = toTsLiteral(config); + const content = `import type { NodelinkConfig } from './src/typings/config/config.types.ts'\n\nexport const config: NodelinkConfig = ${literal}\n\nexport default config\n`; + await fs.writeFile(configTsPath, content, 'utf-8'); + console.log(`[INFO] Config: Automatically updated local config.ts from ${fileName}`); + } + catch (err) { + console.warn(`[WARN] Config: Failed to persist updated configuration: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/dist/src/modules/config/configMigration.test.js b/dist/src/modules/config/configMigration.test.js new file mode 100644 index 00000000..adadb78a --- /dev/null +++ b/dist/src/modules/config/configMigration.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import defaultConfig from "../../../config.default.js"; +import { migrateConfig, toTsLiteral } from "./configMigration.js"; +test('migrateConfig maps legacy flat config to hierarchical structure', () => { + const legacy = { + server: { + host: '0.0.0.0', + port: 3000, + password: 'legacy-pass', + useBunServer: false + }, + connection: { + interval: 12345, + timeout: 6789, + thresholds: { bad: 1, average: 5 }, + logAllChecks: true + }, + routePlanner: { + strategy: 'RotateOnBan', + bannedIpCooldown: 1000, + ipBlocks: ['1.1.1.0/24'] + }, + maxSearchResults: 25, + defaultSearchSource: ['youtube'], + unifiedSearchSources: ['youtube', 'soundcloud'], + resolveExternalLinks: true, + fetchChannelInfo: true, + maxAlbumPlaylistLength: 777, + playerUpdateInterval: 3000, + statsUpdateInterval: 15000, + trackStuckThresholdMs: 45000, + eventTimeoutMs: 3333, + zombieThresholdMs: 99999, + sponsorblock: { + enabled: true, + api: 'x', + categories: [], + actionTypes: ['skip'], + skipMarginMs: 1 + }, + filters: { enabled: { tremolo: true } }, + audio: { quality: 'high' }, + voiceReceive: { enabled: true, format: 'opus' }, + mix: { + enabled: true, + defaultVolume: 0.8, + maxLayersMix: 5, + autoCleanup: true + }, + enableTrackStreamEndpoint: true, + enableLoadStreamEndpoint: true, + dosProtection: { enabled: true }, + rateLimit: { enabled: true }, + metrics: { enabled: true }, + enableHoloTracks: true, + cluster: { + commandTimeout: 6000, + fastCommandTimeout: 4000 + } + }; + const migrated = migrateConfig(legacy); + assert.equal(migrated.search.maxResults, 25); + assert.deepEqual(migrated.search.defaultSource, ['youtube']); + assert.deepEqual(migrated.search.unifiedSources, ['youtube', 'soundcloud']); + assert.equal(migrated.search.resolveExternalLinks, true); + assert.equal(migrated.search.fetchChannelInfo, true); + assert.equal(migrated.playback.maxPlaylistLength, 777); + assert.equal(migrated.playback.playerUpdateInterval, 3000); + assert.equal(migrated.playback.statsUpdateInterval, 15000); + assert.equal(migrated.playback.trackStuckThresholdMs, 45000); + assert.equal(migrated.playback.eventTimeoutMs, 3333); + assert.equal(migrated.playback.zombieThresholdMs, 99999); + assert.equal(migrated.playback.voiceReceive.enabled, true); + assert.equal(migrated.api.enableTrackStreamEndpoint, true); + assert.equal(migrated.api.enableLoadStreamEndpoint, true); + assert.equal(migrated.experimental.enableHoloTracks, true); + assert.deepEqual(migrated.network.connection.interval, 12345); + assert.deepEqual(migrated.network.routePlanner.ipBlocks, ['1.1.1.0/24']); + assert.equal(migrated.cluster.timeouts?.heavyMs, 6000); + assert.equal(migrated.cluster.timeouts?.fastMs, 4000); + assert.equal(migrated.server.password, 'legacy-pass'); +}); +test('migrateConfig preserves explicit hierarchical values over legacy duplicates', () => { + const hybrid = { + server: { password: 'server-password' }, + trustProxy: true, + maxSearchResults: 10, + search: { maxResults: 50 }, + cluster: { + commandTimeout: 6000, + fastCommandTimeout: 4000, + timeouts: { heavyMs: 9000, fastMs: 7000 } + }, + network: { + proxy: { + enabled: true, + strategy: 'RoundRobin', + retries: 1, + timeout: 1, + shuffleOnStart: false, + list: [] + } + } + }; + const migrated = migrateConfig(hybrid); + assert.equal(migrated.server.password, 'server-password'); + assert.equal(migrated.search.maxResults, 50); + assert.equal(migrated.cluster.timeouts?.heavyMs, 9000); + assert.equal(migrated.cluster.timeouts?.fastMs, 7000); + assert.equal(migrated.network.proxy.enabled, true); +}); +test('migrateConfig fills required compatibility defaults', () => { + const minimalLegacy = { + server: { password: 'abc' }, + cluster: { commandTimeout: 1000, fastCommandTimeout: 2000 } + }; + const migrated = migrateConfig(minimalLegacy); + assert.equal(migrated.server.password, 'abc'); + assert.equal(migrated.cluster.timeouts?.heavyMs, 1000); + assert.equal(migrated.cluster.timeouts?.fastMs, 2000); + assert.equal(migrated.network.proxy.enabled, false); + assert.deepEqual(migrated.network.proxy.list, []); +}); +test('migrateConfig keeps vanilla default config valid', () => { + const migrated = migrateConfig(defaultConfig); + assert.equal(migrated.server.host, defaultConfig.server.host); + assert.equal(migrated.search.maxResults, defaultConfig.search.maxResults); + assert.equal(migrated.playback.playerUpdateInterval, defaultConfig.playback.playerUpdateInterval); + assert.equal(migrated.api.enableTrackStreamEndpoint, defaultConfig.api.enableTrackStreamEndpoint); + assert.equal(migrated.server.password, defaultConfig.server.password); +}); +test('toTsLiteral generates idiomatic TypeScript formatting', () => { + const input = { + a: 1, + 'b-c': 'val', + d: [1, { e: true }], + f: null, + g: "'quotes'" + }; + const expected = `{ + a: 1, + 'b-c': 'val', + d: [ + 1, + { + e: true + } + ], + f: null, + g: '\\'quotes\\'' +}`; + assert.equal(toTsLiteral(input), expected); +}); diff --git a/dist/src/playback/hls/HLSHandler.js b/dist/src/playback/hls/HLSHandler.js index 1ff14767..7cddaf69 100644 --- a/dist/src/playback/hls/HLSHandler.js +++ b/dist/src/playback/hls/HLSHandler.js @@ -51,7 +51,7 @@ export default class HLSHandler extends PassThrough { this.currentUrl = url; this.headers = options.headers ?? {}; this.localAddress = options.localAddress ?? null; - this.proxy = options.network.proxy ?? null; + this.proxy = options.network?.proxy ?? options.proxy ?? null; this.onResolveUrl = options.onResolveUrl ?? null; this.strategy = options.strategy ?? diff --git a/dist/src/playback/hls/SegmentFetcher.js b/dist/src/playback/hls/SegmentFetcher.js index 03d3b9a3..f41aa13d 100644 --- a/dist/src/playback/hls/SegmentFetcher.js +++ b/dist/src/playback/hls/SegmentFetcher.js @@ -53,7 +53,7 @@ export default class SegmentFetcher { constructor(options = {}) { this.headers = options.headers || {}; this.localAddress = options.localAddress || null; - this.proxy = options.network.proxy || null; + this.proxy = options.network?.proxy || options.proxy || null; this.onResolveUrl = options.onResolveUrl || null; this.keyMap = new Map(); } diff --git a/dist/src/playback/player.js b/dist/src/playback/player.js index e16962dc..e5637417 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -115,7 +115,9 @@ export class Player { 'music_offtopic', 'filler' ], - actionTypes: this.nodelink.options.playback.sponsorblock?.actionTypes ?? ['skip'], + actionTypes: this.nodelink.options.playback.sponsorblock?.actionTypes ?? [ + 'skip' + ], segments: [], lastSkippedUuid: null, skipMarginMs: this.nodelink.options.playback.sponsorblock?.skipMarginMs ?? 150 @@ -230,7 +232,7 @@ export class Player { encryption: this.nodelink.options?.playback.audio?.encryption ?? null }); this.connection.stuckTimeout = - Math.max(this.nodelink.options.playback.trackStuckThresholdMs, 30000) + 5000; + Math.max(this.nodelink.options.playback.trackStuckThresholdMs ?? 10000, 30000) + 5000; this.connection.on('stateChange', (_, s) => { logger('debug', 'Player', `Voice connection state change for guild ${this.guildId} in session ${this.session.id}: ${s.status}`); this._onConn(s); @@ -324,7 +326,8 @@ export class Player { } if (state.status === 'idle' && state.reason === 'stuck') { logger('warn', 'Player', `Track became stuck for guild ${this.guildId}. Triggering immediate recovery.`); - this._stuckTime = this.nodelink.options.playback.trackStuckThresholdMs + 1; + this._stuckTime = + (this.nodelink.options.playback.trackStuckThresholdMs ?? 0) + 1; this._sendUpdate(); return; } @@ -767,7 +770,7 @@ export class Player { logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Current position: ${Math.round(position)}ms, Segments: ${this.sponsorBlock.segments.length}, LastSkipped: ${this.sponsorBlock.lastSkippedUuid}`); } } - const threshold = this.nodelink.options.playback.trackStuckThresholdMs; + const threshold = this.nodelink.options.playback.trackStuckThresholdMs ?? 0; if (threshold > 0 && !this.isUpdatingTrack && !this._isStopping && @@ -775,7 +778,8 @@ export class Player { !this._isResuming && !this.isPaused) { if (this._lastPosition === position) { - this._stuckTime += this.nodelink.options.playback.playerUpdateInterval; + this._stuckTime += + this.nodelink.options.playback.playerUpdateInterval ?? 0; if (this._stuckTime >= threshold && !this._isRecovering && this.connStatus === 'connected') { diff --git a/dist/src/sources/bluesky.js b/dist/src/sources/bluesky.js index 47c5921a..eea2d2db 100644 --- a/dist/src/sources/bluesky.js +++ b/dist/src/sources/bluesky.js @@ -31,9 +31,8 @@ export default class BlueskySource { const options = nodelink.options; this.nodelink = nodelink; this.config = { - maxSearchResults: typeof options.search.maxResults === 'number' - ? options.search.maxResults - : undefined + maxSearchResults: options.sources?.bluesky + ?.maxSearchResults ?? options.search?.maxResults }; this.searchTerms = ['bksearch']; this.patterns = [ @@ -272,7 +271,7 @@ export default class BlueskySource { async search(query) { logger('debug', 'Bluesky', `Searching for: ${query}`); const searchUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}` + - `&limit=${this.config.search.maxResults ?? 10}`; + `&limit=${this.config.maxSearchResults ?? 10}`; const response = await makeRequest(searchUrl, { method: 'GET' }); const searchResponse = this.getSearchResponse(response.body); if (response.error || !searchResponse?.posts) { diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index 7441a462..d85a6e1c 100644 --- a/dist/src/sources/deezer.js +++ b/dist/src/sources/deezer.js @@ -951,7 +951,7 @@ export default class DeezerSource { * @returns Maximum number of search results to return. */ getMaxSearchResults() { - const limit = this.config.search.maxResults; + const limit = this.config.search?.maxResults; return typeof limit === 'number' && limit > 0 ? limit : 10; } /** @@ -961,7 +961,7 @@ export default class DeezerSource { * @returns Maximum collection length for albums, playlists, or artists. */ getMaxCollectionLength(fallback) { - const limit = this.config.playback.maxPlaylistLength; + const limit = this.config.playback?.maxPlaylistLength; return typeof limit === 'number' && limit > 0 ? limit : fallback; } /** diff --git a/dist/src/sources/gaana.js b/dist/src/sources/gaana.js index bb8f5e1b..57f2af64 100644 --- a/dist/src/sources/gaana.js +++ b/dist/src/sources/gaana.js @@ -60,8 +60,8 @@ export default class GaanaSource { */ constructor(nodelink) { this.nodelink = nodelink; - const sourceConfig = this.asRecord(this.nodelink.options.sources?.gaana); - this.config = sourceConfig || {}; + const sourceConfig = this.nodelink.options.sources.gaana; + this.config = sourceConfig; this.searchTerms = ['gnsearch', 'gaanasearch']; this.patterns = [ /^@?(?:https?:\/\/)?(?:www\.)?gaana\.com\/(?song|album|playlist|artist)\/(?[\w-]+)(?:[?#].*)?$/ @@ -832,7 +832,7 @@ export default class GaanaSource { * @returns Proxy configuration object or null. */ getProxyConfig() { - const proxy = this.asRecord(this.config.network.proxy); + const proxy = this.asRecord(this.config.network?.proxy); const url = this.asString(proxy?.url); if (!url) return undefined; diff --git a/dist/src/sources/iheartradio.js b/dist/src/sources/iheartradio.js index 971aae48..e156118e 100644 --- a/dist/src/sources/iheartradio.js +++ b/dist/src/sources/iheartradio.js @@ -46,9 +46,9 @@ export default class IheartradioSource { this.nodelink = nodelink; const options = nodelink.options; this.maxSearchResults = - typeof options.search.maxResults === 'number' && - Number.isInteger(options.search.maxResults) && - options.search.maxResults > 0 + typeof options.search?.maxResults === 'number' && + Number.isInteger(options.search?.maxResults) && + options.search?.maxResults > 0 ? options.search.maxResults : 10; } diff --git a/dist/src/sources/jiosaavn.js b/dist/src/sources/jiosaavn.js index f737d00a..77b04fdf 100644 --- a/dist/src/sources/jiosaavn.js +++ b/dist/src/sources/jiosaavn.js @@ -289,7 +289,7 @@ export default class JioSaavnSource { const { stream, error, statusCode } = await http1makeRequest(url, { method: 'GET', streamOnly: true, - proxy: this.config.network.proxy + proxy: this.config.network?.proxy }); if (error || statusCode !== 200 || !stream) { return this.exceptionLoadResult(`Failed to load stream: ${error || statusCode || 'unknown'}`); @@ -333,7 +333,7 @@ export default class JioSaavnSource { const { body, error, statusCode } = await http1makeRequest(url.toString(), { method: 'GET', headers: HEADERS, - proxy: this.config.network.proxy + proxy: this.config.network?.proxy }); if (error || statusCode !== 200) { throw new Error(`JioSaavn API request failed: ${statusCode || 'unknown'}`); diff --git a/dist/src/sources/letrasmus.js b/dist/src/sources/letrasmus.js index 815655c1..e74ecc26 100644 --- a/dist/src/sources/letrasmus.js +++ b/dist/src/sources/letrasmus.js @@ -141,9 +141,9 @@ export default class LetrasMusSource { this.patterns = [LETRAS_PATTERN]; const options = nodelink.options; this.maxSearchResults = - typeof options.search.maxResults === 'number' && - Number.isInteger(options.search.maxResults) && - options.search.maxResults > 0 + typeof options.search?.maxResults === 'number' && + Number.isInteger(options.search?.maxResults) && + options.search?.maxResults > 0 ? options.search.maxResults : 10; } diff --git a/dist/src/sources/monochrome.js b/dist/src/sources/monochrome.js index 98ac676d..bf6c79c5 100644 --- a/dist/src/sources/monochrome.js +++ b/dist/src/sources/monochrome.js @@ -340,7 +340,7 @@ class MonochromeSource { } }; } - // 3. Collection resolution (Album/Playlist) with exaustive pagination + // 3. Collection resolution (Album/Playlist) with exhaustive pagination const collectionMatch = url.match(/(album|playlist)\/([a-f0-9-]+|\d+)/); if (collectionMatch) { const type = collectionMatch[1] || ''; diff --git a/dist/src/sources/pandora.js b/dist/src/sources/pandora.js index be06f24c..3a65e6e4 100644 --- a/dist/src/sources/pandora.js +++ b/dist/src/sources/pandora.js @@ -424,7 +424,7 @@ export default class PandoraSource { * @internal */ getMaxPlaylistLength() { - return this.options.playback.maxPlaylistLength ?? 100; + return (this.options.playback.maxPlaylistLength ?? 100); } /** * Resolves an artist, album, or track by ID. diff --git a/dist/src/sources/shazam.js b/dist/src/sources/shazam.js index 998b7074..11855391 100644 --- a/dist/src/sources/shazam.js +++ b/dist/src/sources/shazam.js @@ -58,7 +58,7 @@ export default class ShazamSource { */ getMaxSearchResults() { const options = this.nodelink.options; - const limit = options.search.maxResults; + const limit = options.search?.maxResults; return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10; @@ -127,7 +127,7 @@ export default class ShazamSource { } const { body, statusCode, error } = await http1makeRequest(url, { headers: { - "sec-ch-ua": "\"Chromium\";v=\"148\", \"Google Chrome\";v=\"148\", \"Not/A)Brand\";v=\"99\"" + 'sec-ch-ua': '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"' } }); if (error || statusCode !== 200) { diff --git a/dist/src/sources/songlink.js b/dist/src/sources/songlink.js index 09b3438d..1bc65cfc 100644 --- a/dist/src/sources/songlink.js +++ b/dist/src/sources/songlink.js @@ -104,11 +104,7 @@ export default class SongLinkSource { */ constructor(nodelink) { this.nodelink = nodelink; - const sourceConfig = this.nodelink.options.sources?.songlink; - this.config = - sourceConfig && typeof sourceConfig === 'object' - ? sourceConfig - : {}; + this.config = this.nodelink.options.sources.songlink; this.searchTerms = ['slsearch']; this.patterns = [SONG_LINK_PATTERN]; this.priority = 95; @@ -208,7 +204,7 @@ export default class SongLinkSource { */ async search(query, _sourceTerm, _searchType = 'track') { try { - const maxSearchRaw = this.nodelink.options.search.maxResults; + const maxSearchRaw = this.nodelink.options.search?.maxResults; const limit = typeof maxSearchRaw === 'number' && Number.isFinite(maxSearchRaw) ? maxSearchRaw : 10; @@ -565,7 +561,7 @@ export default class SongLinkSource { * @returns True when source can be used. */ _isSourceAvailable(sourceName) { - const sourceConfig = this.nodelink.options.sources?.[sourceName]; + const sourceConfig = this.nodelink.options.sources[sourceName]; if (!sourceConfig?.enabled) return false; return !!this.nodelink.sources.getSource(sourceName); diff --git a/dist/src/sources/twitter.js b/dist/src/sources/twitter.js index ab52d79f..6f81c9f7 100644 --- a/dist/src/sources/twitter.js +++ b/dist/src/sources/twitter.js @@ -356,7 +356,7 @@ export default class TwitterSource { */ getMaxSearchResults() { const options = this.nodelink.options; - const limit = options.search.maxResults; + const limit = options.search?.maxResults; return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 10; diff --git a/dist/src/sources/youtube/CipherManager.js b/dist/src/sources/youtube/CipherManager.js index 9ff654d5..d34e249a 100644 --- a/dist/src/sources/youtube/CipherManager.js +++ b/dist/src/sources/youtube/CipherManager.js @@ -73,8 +73,7 @@ export default class CipherManager { constructor(nodelink) { this.nodelink = nodelink; this.config = { - ...(nodelink.options.sources - ?.youtube?.cipher ?? {}) + ...(nodelink.options.sources?.youtube?.cipher ?? {}) }; if (this.config.url) { this.config.url = this.config.url.replace(/\/+$/, ''); diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index 52c99173..a68d6c4a 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream'; import HLSHandler from "../../playback/hls/HLSHandler.js"; -import { getBestMatch, http1makeRequest, logger } from "../../utils.js"; +import { getBestMatch, http1makeRequest, logger, makeRequest } from "../../utils.js"; import CipherManager from "./CipherManager.js"; import Android from "./clients/Android.js"; import AndroidVR from "./clients/AndroidVR.js"; @@ -11,7 +11,7 @@ import TVCast from "./clients/TVCast.js"; import Web from "./clients/Web.js"; import WebRemix from "./clients/Web_Remix.js"; import WebEmbedded from "./clients/WebEmbedded.js"; -import { checkURLType } from "./common.js"; +import { checkURLType, YOUTUBE_CONSTANTS } from "./common.js"; import YouTubeLiveChat from "./LiveChat.js"; import OAuth from "./OAuth.js"; import { SabrStream } from "./sabr/sabr.js"; @@ -23,6 +23,105 @@ const MAX_RETRIES = 3; const MAX_URL_REFRESH = 10; /** Interval in milliseconds between visitor data refreshes. */ const VISITOR_DATA_INTERVAL = 3_600_000; +/** + * Manages a scored pool of proxies with automatic health tracking. + * + * Proxies are scored from 0 to 100; failed requests decrease the score and + * successful ones restore it. When selecting a proxy the top-3 healthiest + * candidates are picked at random to spread the load. + */ +class YouTubeProxyManager { + /** Internal pool of proxy entries with health metrics. */ + proxies; + /** + * Creates a new proxy manager from raw configuration entries. + * @param rawProxies - Array of proxy URLs or configuration objects. + */ + constructor(rawProxies) { + this.proxies = (rawProxies || []).map((p) => ({ + url: typeof p === 'string' ? p : p.url, + type: (typeof p === 'string' ? 'forward' : p.type || 'forward'), + failures: 0, + lastFailure: 0, + activeRequests: 0, + score: 100, + latency: 0 + })); + } + /** + * Selects the healthiest available proxy from the pool. + * + * Filters out proxies that have been recently penalized (score 0 with + * recent failures), sorts by score then active requests then latency, + * and randomly picks one of the top-3 candidates to spread load. + * + * @returns A shallow copy {@link ProxySnapshot} of the selected proxy, or `undefined` if the pool is empty. + */ + getBestProxy() { + if (!this.proxies.length) + return undefined; + const now = Date.now(); + const available = this.proxies.filter((p) => { + if (p.score <= 0) + return now - p.lastFailure > 600_000; + if (p.failures > 5) + return now - p.lastFailure > 60_000; + return true; + }); + const list = available.length ? available : this.proxies; + list.sort((a, b) => { + if (b.score !== a.score) + return b.score - a.score; + if (a.activeRequests !== b.activeRequests) + return a.activeRequests - b.activeRequests; + return a.latency - b.latency; + }); + const topCount = Math.min(3, list.length); + const selected = list[Math.floor(Math.random() * topCount)]; + if (!selected) + return undefined; + selected.activeRequests++; + return { ...selected }; + } + /** + * Reports the outcome of a proxy request for health tracking. + * + * Successful requests restore 5 score points; failures apply a penalty + * proportional to the HTTP status code (403 = 50, 429 = 30, 503 = 20, other = 10). + * The latency is exponentially smoothed into the proxy's running average. + * + * @param proxyUrl - Full URL of the proxy used. + * @param success - Whether the request succeeded. + * @param status - HTTP status code of the response. + * @param latency - Round-trip latency in milliseconds (defaults to 0). + */ + report(proxyUrl, success, status, latency = 0) { + const p = this.proxies.find((pr) => pr.url === proxyUrl); + if (!p) + return; + if (p.activeRequests > 0) + p.activeRequests--; + if (latency > 0) { + p.latency = p.latency === 0 ? latency : p.latency * 0.8 + latency * 0.2; + } + if (success) { + p.score = Math.min(100, p.score + 5); + p.failures = 0; + } + else { + p.failures++; + p.lastFailure = Date.now(); + let penalty = 10; + if (status === 403) + penalty = 50; + if (status === 429) + penalty = 30; + if (status === 503) + penalty = 20; + p.score = Math.max(0, p.score - penalty); + } + } +} /** * YouTube source implementation for NodeLink. * @@ -37,6 +136,8 @@ export default class YouTubeSource { nodelink; /** YouTube-specific configuration block from `nodelink.options.sources.youtube`. */ config; + /** Scored proxy pool manager with automatic health tracking and load balancing. */ + proxyManager; /** Instantiated YouTube innertube client objects keyed by class name (e.g. `Android`, `Web`). */ // biome-ignore lint/suspicious/noExplicitAny: JS client class instances have heterogeneous method shapes clients; @@ -71,18 +172,18 @@ export default class YouTubeSource { */ constructor(nodelink) { this.nodelink = nodelink; - this.config = nodelink.options.sources?.youtube; + this.config = nodelink.options.sources + .youtube; + this.proxyManager = new YouTubeProxyManager(this.config.proxies || []); this.additionalsSourceName = ['ytmusic']; this.searchTerms = ['ytsearch', 'ytmsearch']; this.recommendationTerm = ['ytrec']; this.patterns = [ /^https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)/, - /^https?:\/\/(?:music|www)\.youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+|channel\/[\w-]+|user\/[\w-]+)/, - /^https?:\/\/(?:music|www)\.youtube\.com\/playlist\?list=[\w-]+/, - /^https?:\/\/(?:music|www)\.youtube\.com\/watch\?v=[\w-]+/, + /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[\w-]+/, /^https?:\/\/music\.youtube\.com\/(?:watch\?v=[\w-]+(?:&list=[\w-]+)?|playlist\?list=[\w-]+)/ ]; - this.priority = 10; + this.priority = 100; this.clients = {}; this.oauth = null; this.visitorDataInterval = null; @@ -95,45 +196,23 @@ export default class YouTubeSource { this.mirrorFallbackInFlight = new Set(); this.ytContext = { client: { - hl: this.config.hl || 'en', - gl: this.config.gl || 'US', - visitorData: this.config.visitorData || '', - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - clientName: 'WEB', - clientVersion: '2.20231201.01.00', - osName: 'Windows', - osVersion: '10.0', - platform: 'DESKTOP', - clientFormFactor: 'UNKNOWN_FORM_FACTOR', - userInterfaceTheme: 'USER_INTERFACE_THEME_DARK', - browserName: 'Chrome', - browserVersion: '120.0.0.0', screenDensityFloat: 1, screenHeightPoints: 1080, screenPixelDensity: 1, screenWidthPoints: 1920, - utcOffsetMinutes: 0 + hl: 'en', + gl: 'US', + visitorData: null } }; } /** * Returns the healthiest available proxy from the managed pool. - * @param _rotate - Whether to force a rotation (ignored in current weighted selection). + * @param _rotate - Whether to rotate the proxy selection (currently unused, kept for interface compatibility). * @returns A {@link ProxySnapshot} of the selected proxy, or `undefined` if no proxies are configured. */ getProxy(_rotate = true) { - const p = this.nodelink.proxyManager?.getBestProxy('youtube'); - if (!p) - return undefined; - return { - url: p.target.url, - type: p.target.type, - failures: p.state.totalFailures, - lastFailure: p.state.lastFailureAt || 0, - activeRequests: p.state.activeConnections, - score: p.state.status === 'DOWN' ? 0 : 100, - latency: p.state.movingAverageLatency - }; + return this.proxyManager.getBestProxy(); } /** * Reports the outcome of a proxied request for health tracking. @@ -144,12 +223,14 @@ export default class YouTubeSource { */ reportProxyStatus(proxy, success, status, latency = 0) { if (proxy?.url) { - this.nodelink.proxyManager?.report(proxy.url, success, status, latency); + this.proxyManager.report(proxy.url, success, status, latency); } } /** * Initializes the YouTube source by instantiating innertube clients, - * setting up OAuth, and starting periodic background tasks. + * fetching visitor data, caching the player script, and starting + * periodic visitor data refresh. + * @returns Promise resolving to `true` when setup completes successfully. */ async setup() { logger('info', 'YouTube', 'Setting up YouTube source...'); @@ -159,331 +240,1427 @@ export default class YouTubeSource { AndroidVR, IOS, Music, + WebRemix, TV, TVCast, Web, - WebRemix, WebEmbedded }; - for (const [name, ClientClass] of Object.entries(clientClasses)) { - this.clients[name] = new ClientClass(this.nodelink, { - getProxy: this.getProxy.bind(this), - reportProxyStatus: this.reportProxyStatus.bind(this), - getOAuthToken: async () => { - if (!this.oauth) - return null; - return this.oauth.getAccessToken(); - }, - getContext: () => this.ytContext, - getCipher: () => this.cipherManager - }); + for (const clientName of Object.keys(clientClasses)) { + const ClientCtor = clientClasses[clientName]; + if (!ClientCtor) + continue; + this.clients[clientName] = new ClientCtor(this.nodelink, this.oauth); } - // Perform initial visitor data fetch + logger('debug', 'YouTube', `Initialized clients: ${Object.keys(this.clients).join(', ')}`); await this._fetchVisitorData(); - // Schedule periodic visitor data refresh + await this.cipherManager.getCachedPlayerScript(); + await this.cipherManager.checkCipherServerStatus(); + if (this.visitorDataInterval) + clearInterval(this.visitorDataInterval); this.visitorDataInterval = setInterval(() => this._fetchVisitorData(), VISITOR_DATA_INTERVAL); if (typeof this.visitorDataInterval.unref === 'function') { this.visitorDataInterval.unref(); } + logger('info', 'YouTube', 'YouTube source setup complete.'); return true; } /** - * Fetches new visitor data from YouTube to keep the session context fresh. - * Automatically selects the best available innertube client for the request. + * Tears down the YouTube source by aborting active streams, clearing + * the visitor data interval, and cleaning up OAuth and cipher resources. + */ + cleanup() { + logger('info', 'YouTube', 'Cleaning up YouTube source...'); + for (const [, cancelSignal] of this.activeStreams.entries()) { + cancelSignal.aborted = true; + } + this.activeStreams.clear(); + if (this.visitorDataInterval) { + clearInterval(this.visitorDataInterval); + this.visitorDataInterval = null; + } + if (this.oauth) + this.oauth.cleanup?.(); + this.cipherManager?.cleanup?.(); + } + /** + * Fetches visitor data and player script URL from YouTube embed pages. + * + * Tries the embed endpoint first, then falls back to the guide API. + * Both visitor data and player script URL are cached in the credential + * manager for use by innertube clients. + * + * @returns Promise that resolves when the fetch attempt completes. */ async _fetchVisitorData() { - const clientNames = this.config.clients?.resolve || ['Web']; - let visitorData = ''; - for (const name of clientNames) { - const client = this.clients[name]; - if (!client) - continue; - try { - const response = await client.getVisitorData(); - if (response) { - visitorData = response; - break; + const cachedPlayerScript = this.nodelink.credentialManager?.get('yt_player_script_url'); + if (cachedPlayerScript) { + this.cipherManager.setPlayerScriptUrl(cachedPlayerScript); + logger('debug', 'YouTube', 'Player script URL loaded from cache.'); + } + let visitorFound = false; + let playerScriptUrl = null; + try { + const { body: data, error, statusCode } = await makeRequest('https://www.youtube.com/embed', { + method: 'GET', + headers: { + Cookie: 'YSC=cz5kYp3ZuIE; VISITOR_INFO1_LIVE=U-0T5oUyzf8;' + } + }); + if (!error && statusCode === 200) { + const bodyStr = data; + const visitorMatch = bodyStr?.match(/"VISITOR_DATA":"([^"]+)"/); + if (visitorMatch?.[1]) { + this.ytContext.client.visitorData = visitorMatch[1]; + this.nodelink.credentialManager?.set('yt_visitor_data', visitorMatch[1], 60 * 60 * 1000); + visitorFound = true; + logger('debug', 'YouTube', 'visitorData refreshed and cached.'); + } + const playerScriptMatch = bodyStr?.match(/"jsUrl":"([^"]+)"/); + if (playerScriptMatch?.[1]) { + playerScriptUrl = playerScriptMatch[1].replace(/\/[a-z]{2}_[A-Z]{2}\//, '/en_US/'); + this.nodelink.credentialManager?.set('yt_player_script_url', playerScriptUrl, 12 * 60 * 60 * 1000); + logger('debug', 'YouTube', `Player script URL: ${playerScriptUrl}`); } } - catch (err) { - logger('debug', 'YouTube', `Failed to fetch visitor data using ${name}: ${err instanceof Error ? err.message : String(err)}`); + else { + logger('warn', 'YouTube', `Embed request failed: ${error?.message || `Status ${statusCode}`}`); + } + if (!visitorFound) { + const { body: guideData, error: guideError, statusCode: guideStatusCode } = await makeRequest('https://www.youtube.com/youtubei/v1/guide', { + method: 'POST', + body: { context: this.ytContext }, + disableBodyCompression: true + }); + const guideBody = guideData; + if (!guideError && + guideStatusCode === 200 && + guideBody?.responseContext?.visitorData) { + this.ytContext.client.visitorData = + guideBody.responseContext.visitorData; + this.nodelink.credentialManager?.set('yt_visitor_data', guideBody.responseContext.visitorData, 60 * 60 * 1000); + visitorFound = true; + logger('debug', 'YouTube', 'visitorData refreshed via guide and cached.'); + } + else { + logger('warn', 'YouTube', 'Failed to refresh visitorData via guide; using cached fallback if present.'); + } } } - if (visitorData) { - this.ytContext.client.visitorData = visitorData; - logger('debug', 'YouTube', `Updated visitor data: ${visitorData}`); + catch (_e) { + logger('error', 'YouTube', `Error fetching visitor data: \${(e as Error).message}`); + logger('warn', 'YouTube', 'Using cached visitorData fallback (if present).'); } + if (playerScriptUrl) + this.cipherManager.setPlayerScriptUrl(playerScriptUrl); } /** - * Searches for tracks on YouTube or YouTube Music using the configured search clients. + * Searches YouTube for tracks, playlists, or recommendations. + * + * Iterates through the configured search clients in priority order, + * returning the first successful result. For YouTube Music searches + * (`ytmsearch`), only WebRemix and Music clients are used. * * @param query - Search query string. - * @param sourceName - Optional source override ('youtube' or 'ytmusic'). - * @returns A search result containing an array of {@link TrackData}. + * @param type - Search term alias (`'ytsearch'`, `'ytmsearch'`, `'ytrec'`). + * @param searchType - Content type to search for (`'track'`, `'playlist'`, etc.). + * @returns Promise resolving to a source result with search results or an exception. */ - async search(query, sourceName) { - const isMusic = sourceName === 'ytmusic'; - const clientNames = this.config.clients?.search || ['Android']; - logger('debug', 'YouTube', `Searching for "${query}" (Source: ${sourceName || 'youtube'})`); - for (const name of clientNames) { - const client = this.clients[name]; + async search(query, type, searchType = 'track') { + if (type === 'ytrec') { + return this.getRecommendations(query); + } + let clientList = this.config.clients.search; + if (type === 'ytmsearch') { + clientList = ['WebRemix', 'Music']; + } + const clientErrors = []; + for (const clientName of clientList) { + const client = this.clients[clientName]; if (!client) continue; try { - const results = await client.search(query, isMusic); - if (results && results.length > 0) { - return { loadType: 'search', data: results }; + logger('debug', 'YouTube', `Attempting ${searchType} search with client: ${clientName}`); + const searchProxy = clientName === 'Android' ? this.getProxy(true) : undefined; + const result = await client.search(query, searchType, this.ytContext, searchProxy, this.reportProxyStatus.bind(this)); + if (result && result.loadType === 'search') { + logger('debug', 'YouTube', `Search successful with client: ${clientName}`); + return result; } + const errorMessage = result?.data?.message || + 'Client returned empty or failed.'; + clientErrors.push({ client: clientName, message: errorMessage }); + logger('debug', 'YouTube', `Client ${clientName} returned empty or failed search.`); } - catch (err) { - logger('warn', 'YouTube', `Search failed using ${name}: ${err instanceof Error ? err.message : String(err)}`); + catch (e) { + clientErrors.push({ + client: clientName, + message: e.message + }); + logger('warn', 'YouTube', `Client ${clientName} threw an exception during search: ${e.message}`); } } - return { loadType: 'empty', data: {} }; + logger('error', 'YouTube', 'No search results found from any configured client.'); + return { + loadType: 'error', + exception: { + message: 'No search results found from any configured client.', + severity: 'fault', + cause: 'All clients failed.', + errors: clientErrors + } + }; } /** - * Resolves a YouTube URL to track or playlist data. + * Fetches YouTube auto-mix recommendations for a given video or query. + * + * Constructs an auto-mix playlist ID (`RD{videoId}`) and attempts to + * resolve it through music and TV clients in priority order. If the + * query is not a valid video ID, performs a search first to resolve one. * - * @param url - YouTube watch, playlist, or shorts URL. - * @returns A result containing a single track or a playlist of tracks. + * @param query - YouTube video ID or search query string. + * @returns Promise resolving to a playlist result with recommendations, or an empty result. */ - async resolve(url) { - const type = checkURLType(url); - const clientNames = this.config.clients?.resolve || ['Web']; - logger('debug', 'YouTube', `Resolving URL: ${url} (Type: ${type})`); - for (const name of clientNames) { - const client = this.clients[name]; - if (!client) - continue; - try { - let result = null; - if (type === 'track') { - const track = await client.resolveTrack(url); - if (track) - result = { loadType: 'track', data: track }; - } - else if (type === 'playlist') { - const playlist = await client.resolvePlaylist(url); - if (playlist) - result = { loadType: 'playlist', data: playlist }; - } - if (result) - return result; + async getRecommendations(query) { + let videoId = query; + if (!/^[a-zA-Z0-9_-]{11}$/.test(query)) { + const searchRes = await this.search(query, 'ytmsearch'); + if (searchRes.loadType !== 'search' || searchRes.data.length === 0) { + return { loadType: 'empty', data: {} }; } - catch (err) { - logger('warn', 'YouTube', `Resolve failed using ${name}: ${err instanceof Error ? err.message : String(err)}`); + videoId = searchRes.data[0].info.identifier; + } + try { + const automixId = `RD${videoId}`; + let automixRes = null; + if (this.clients.WebRemix || this.clients.Music) { + try { + const musicClient = this.clients.WebRemix ?? this.clients.Music; + if (!musicClient) + throw new Error('no music client'); + const clientName = this.clients.WebRemix ? 'WebRemix' : 'Music'; + logger('debug', 'YouTube', `Attempting recommendations with ${clientName} client`); + automixRes = await musicClient.resolve(`https://music.youtube.com/playlist?list=${automixId}`, 'ytmusic', this.ytContext, this.cipherManager); + } + catch (e) { + logger('debug', 'YouTube', `Music client failed for recommendations: ${e.message}`); + } } + if ((!automixRes || automixRes.loadType !== 'playlist') && + (this.clients.TV || this.clients.TVCast || this.clients.WebRemix)) { + try { + const tvClient = this.clients.TV ?? this.clients.TVCast; + if (!tvClient) + throw new Error('no tv client'); + const clientName = this.clients.TV ? 'TV' : 'TVCast'; + logger('debug', 'YouTube', `Attempting recommendations with ${clientName} client`); + automixRes = await tvClient.resolve(`https://www.youtube.com/playlist?list=${automixId}`, 'youtube', this.ytContext, this.cipherManager); + } + catch (e) { + logger('debug', 'YouTube', `TV client failed for recommendations: ${e.message}`); + } + } + if (automixRes && + automixRes.loadType === 'playlist' && + automixRes.data.tracks.length > 0) { + const tracks = automixRes.data.tracks.filter((t) => t.info.identifier !== videoId); + return { + loadType: 'playlist', + data: { + info: { name: 'YouTube Recommendations', selectedTrack: 0 }, + pluginInfo: { type: 'recommendations' }, + tracks + } + }; + } + return { loadType: 'empty', data: {} }; + } + catch (e) { + logger('error', 'YouTube', `Recommendations failed: ${e.message}`); + return { + loadType: 'error', + exception: { message: e.message, severity: 'fault' } + }; } - return { loadType: 'empty', data: {} }; } /** - * Resolves a playable URL and playback metadata for a YouTube track. + * Resolves a YouTube or YouTube Music URL into a track or playlist result. * - * Implements a retry-with-recovery logic: if all configured playback clients - * fail to resolve a URL, it attempts to "recover" by falling back to search - * for the track's ISRC or title on other sources. + * Normalizes live URLs, detects music URLs for specialized client routing, + * and iterates through configured clients with automatic fallback between + * music and standard YouTube clients when playability errors occur. * - * @param trackInfo - Decoded track information. - * @param itag - Optional requested format itag. - * @param isRecovering - Internal flag to prevent recursion during recovery. - * @returns A {@link TrackUrlResult} containing the playback URL and format. + * @param url - YouTube or YouTube Music URL to resolve. + * @param type - Optional source type override (e.g. `'youtube-fallback'` for recursive fallback). + * @returns Promise resolving to a source result with track/playlist data or an exception. */ - async getTrackUrl(trackInfo, itag, isRecovering = false) { - const clientNames = this.config.clients?.playback || ['AndroidVR', 'TV']; - const requestedItag = itag || this.config.targetItag || undefined; - logger('debug', 'YouTube', `Getting URL for: ${trackInfo.title} (${trackInfo.identifier})`); - for (const name of clientNames) { - const client = this.clients[name]; + 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'; + const processUrl = url; + const clientList = this.config.clients.resolve || this.config.clients.playback; + logger('debug', 'YouTube', `Using resolve clients: ${clientList.join(', ')}`); + const clientErrors = []; + const urlType = checkURLType(processUrl, sourceType); + if (isMusicUrl) { + const musicClients = ['WebRemix', 'Music']; + for (const clientName of musicClients) { + const musicClient = this.clients[clientName]; + if (!musicClient) + continue; + try { + logger('debug', 'YouTube', `Attempting to resolve YouTube Music URL with ${clientName} 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 ${clientName} client.`); + return result; + } + if (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', `${clientName} 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; + } + } + } + const errorMessage = result?.data?.message || + `${clientName} client returned empty or failed.`; + clientErrors.push({ client: clientName, message: errorMessage }); + logger('debug', 'YouTube', `${clientName} client returned empty or failed for Music URL.`); + } + catch (e) { + clientErrors.push({ + client: clientName, + message: e.message + }); + logger('warn', 'YouTube', `${clientName} client threw an exception during Music URL resolve: ${e.message}`); + } + } + const msg = 'All music clients failed for direct Music URL.'; + logger('error', 'YouTube', msg); + return { + loadType: 'error', + exception: { + message: msg, + severity: 'fault', + cause: 'MusicClientsFailure', + errors: clientErrors + } + }; + } + if (urlType === YOUTUBE_CONSTANTS.PLAYLIST) { + const androidClient = this.clients.Android; + if (androidClient) { + try { + logger('debug', 'YouTube', 'Attempting to resolve playlist with Android client.'); + const result = await androidClient.resolve(processUrl, sourceType, this.ytContext, this.cipherManager); + if (result && + (result.loadType === 'track' || + result.loadType === 'playlist' || + result.loadType === 'empty')) { + logger('debug', 'YouTube', 'Successfully resolved playlist with Android client.'); + return result; + } + const errorMessage = result?.data?.message || + 'Android client failed for playlist.'; + clientErrors.push({ client: 'Android', message: errorMessage }); + logger('debug', 'YouTube', 'Android client returned empty or failed to resolve playlist.'); + } + catch (e) { + clientErrors.push({ + client: 'Android', + message: e.message + }); + logger('warn', 'YouTube', `Android client threw an exception during playlist resolve: ${e.message}`); + } + } + else { + clientErrors.push({ + client: 'Android', + message: 'Android client not available.' + }); + logger('warn', 'YouTube', 'Android client not available for playlist priority.'); + } + } + for (const clientName of clientList) { + const client = this.clients[clientName]; if (!client) continue; - try { - const result = await client.getTrackUrl(trackInfo.identifier, requestedItag); - if (result && result.url) - return result; + if (!isMusicUrl && clientName === 'Music') + continue; + if (isMusicUrl && clientName !== 'Music' && type !== 'youtube-fallback') { + continue; } - catch (err) { - logger('debug', 'YouTube', `URL resolution failed using ${name}: ${err instanceof Error ? err.message : String(err)}`); + if (type === 'youtube-fallback' && + !['Android', 'Web'].includes(clientName)) { + continue; } - } - // Recovery logic: if we can't get a YouTube URL, try fallback sources - if (!isRecovering && !this.mirrorFallbackInFlight.has(trackInfo.identifier)) { - this.mirrorFallbackInFlight.add(trackInfo.identifier); try { - return await this._recoverTrack(trackInfo); + logger('debug', 'YouTube', `Attempting to resolve URL with client: ${clientName}`); + const result = await client.resolve(processUrl, sourceType, this.ytContext, this.cipherManager, this.reportProxyStatus.bind(this)); + if (result && + (result.loadType === 'track' || + result.loadType === 'playlist' || + result.loadType === 'empty')) { + logger('debug', 'YouTube', `Successfully resolved URL with client: ${clientName}`); + return result; + } + const errorMessage = result?.data?.message || + 'Client returned empty or failed.'; + clientErrors.push({ client: clientName, message: errorMessage }); + logger('debug', 'YouTube', `Client ${clientName} returned empty or failed to resolve URL.`); } - finally { - this.mirrorFallbackInFlight.delete(trackInfo.identifier); + catch (e) { + clientErrors.push({ + client: clientName, + message: e.message + }); + logger('warn', 'YouTube', `Client ${clientName} threw an exception during resolve: ${e.message}`); } } + logger('error', 'YouTube', 'All clients failed to resolve the URL.'); return { + loadType: 'error', exception: { - message: 'Could not resolve a playable URL for this track.', - severity: 'common' + message: 'All clients failed to resolve the URL.', + severity: 'fault', + cause: 'All clients failed.', + errors: clientErrors } }; } /** - * Attempts to find a mirror for a failing YouTube track on other enabled sources. + * Enriches a basic track with "Holo" metadata by querying the Web client + * player API. Falls back to the original track if enrichment fails. + * @param vanillaTrack - Basic track object with `info` and optional `userData`. + * @param options - Optional overrides for channel info and external link resolution. + * @returns Promise resolving to the enriched track or the original if enrichment fails. + */ + async resolveHoloTrack(vanillaTrack, options = {}) { + try { + const { info, userData } = vanillaTrack; + const webClient = this.clients.Web; + if (!webClient) { + logger('warn', 'YouTube', 'Web client not available for Holo resolution'); + return vanillaTrack; + } + const videoId = info.identifier; + const playerResult = await webClient._makePlayerRequest?.(videoId, this.ytContext, {}, this.cipherManager); + const playerBody = playerResult?.body; + if (!playerBody || playerBody.error) + return vanillaTrack; + const { buildHoloTrack } = await import("./common.js"); + const holoTrack = await buildHoloTrack(info, null, info.sourceName === 'ytmusic' ? 'ytmusic' : 'youtube', + // biome-ignore lint/suspicious/noExplicitAny: buildHoloTrack is dynamically imported from JS with inferred null-typed param + playerBody, { + fetchChannelInfo: options.fetchChannelInfo ?? false, + resolveExternalLinks: options.resolveExternalLinks ?? false, + search: {} + }); + if (holoTrack) + holoTrack.userData = userData; + return holoTrack; + } + catch (err) { + logger('error', 'YouTube', `Failed to resolve Holo track: ${err.message}`); + return vanillaTrack; + } + } + /** + * Resolves the playable stream URL for a decoded track. * - * @param trackInfo - The failing YouTube track. - * @returns A {@link TrackUrlResult} from a fallback source. + * Checks the track cache first (unless `forceRefresh` is set), then + * iterates through configured playback clients. Validates direct URLs + * with a pre-flight range request and falls back to HLS when direct + * URLs return 403. If all clients fail, attempts mirror-source fallback. + * + * @param decodedTrack - Track metadata to resolve. + * @param itag - Optional specific format itag to request. + * @param forceRefresh - When `true`, bypasses the track cache and forces a fresh URL fetch. + * @returns Promise resolving to track URL data with stream info or an exception. */ - async _recoverTrack(trackInfo) { - const fallbacks = this.config.fallbackSources || ['soundcloud']; - const query = trackInfo.isrc || `${trackInfo.author} - ${trackInfo.title}`; - logger('info', 'YouTube', `Attempting recovery for "${trackInfo.title}" using fallbacks...`); - for (const sourceName of fallbacks) { - if (sourceName === 'youtube' || sourceName === 'ytmusic') + async getTrackUrl(decodedTrack, itag, forceRefresh = false) { + if (!forceRefresh) { + const cached = this.nodelink.trackCacheManager?.get('youtube', decodedTrack.identifier); + if (cached) { + const cachedProxyUrl = cached.additionalData?.proxy?.url; + const currentProxies = this.config.proxies || []; + const isProxyStillValid = !cachedProxyUrl || + currentProxies.some((p) => (typeof p === 'string' ? p : p.url) === cachedProxyUrl); + if (isProxyStillValid) { + logger('debug', 'YouTube', `Using cached URL for ${decodedTrack.identifier}`); + return cached; + } + logger('debug', 'YouTube', `Cached proxy for ${decodedTrack.identifier} is no longer in config. Forcing refresh...`); + } + } + let clientList = [...this.config.clients.playback]; + if (!clientList.length) + clientList = ['Web']; + const clientErrors = []; + for (const clientName of clientList) { + const client = this.clients[clientName]; + if (!client) continue; try { - // Delegate search to the global source manager - const searchResult = await this.nodelink.sources?.search(sourceName, query); - if (searchResult?.loadType === 'search' && searchResult.data.length > 0) { - const bestMatch = getBestMatch(searchResult.data, trackInfo); - if (bestMatch) { - logger('info', 'YouTube', `Recovered "${trackInfo.title}" using ${sourceName} (${bestMatch.info.identifier})`); - // Get URL from the fallback source - return await this.nodelink.sources.getTrackUrl(bestMatch.info); + logger('debug', 'YouTube', `Attempting to get track URL for ${decodedTrack.title} with client: ${clientName}`); + const proxyToUse = this.getProxy(true); + const proxyStartTime = Date.now(); + const urlData = await client.getTrackUrl(decodedTrack, this.ytContext, this.cipherManager, itag, proxyToUse); + const proxyLatency = Date.now() - proxyStartTime; + if (urlData.exception) { + this.reportProxyStatus(proxyToUse, false, urlData.exception.status || 500, proxyLatency); + clientErrors.push({ + client: clientName, + message: urlData.exception.message + }); + logger('debug', 'YouTube', `Client ${clientName} failed: ${urlData.exception.message}`); + continue; + } + if (urlData.protocol === 'sabr') { + this.reportProxyStatus(proxyToUse, true, 200, proxyLatency); + const bestAudio = urlData.formats + ?.filter((f) => f.mimeType?.includes('audio')) + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]; + if (bestAudio) { + urlData.format = bestAudio.mimeType?.includes('webm') + ? 'webm/opus' + : 'm4a'; + } + return urlData; + } + if (urlData.url) { + const check = await http1makeRequest(urlData.url, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + streamOnly: true, + proxy: proxyToUse + }); + if (check.stream) + check.stream.destroy(); + this.reportProxyStatus(proxyToUse, !check.error && + (check.statusCode === 200 || check.statusCode === 206), check.statusCode || 0, Date.now() - proxyStartTime); + if (!check.error && + (check.statusCode === 200 || check.statusCode === 206)) { + let contentLength = null; + const headers = check.headers; + if (headers?.['content-range']) { + const match = headers['content-range']?.match(/\/(\d+)/); + if (match) + contentLength = Number.parseInt(match[1] ?? '0', 10); + } + if (!contentLength && headers?.['content-length']) { + contentLength = Number.parseInt(headers['content-length'], 10); + } + logger('debug', 'YouTube', `URL pre-flight check successful for client ${clientName}.`); + const result = { + ...urlData, + additionalData: { contentLength, proxy: proxyToUse } + }; + this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); + return result; + } + const errorMessage = `URL pre-flight failed. Status: ${check.statusCode}, Error: ${check.error}`; + clientErrors.push({ + client: clientName, + message: `Direct URL: ${errorMessage}` + }); + logger('warn', 'YouTube', `Client ${clientName}: ${errorMessage}`); + if (check.statusCode === 403 && urlData.hlsUrl) { + logger('warn', 'YouTube', `Direct URL 403, attempting HLS fallback for client ${clientName}.`); + const hlsCheck = await http1makeRequest(urlData.hlsUrl, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + streamOnly: true, + proxy: proxyToUse + }); + if (hlsCheck.stream) + hlsCheck.stream.destroy(); + this.reportProxyStatus(proxyToUse, !hlsCheck.error && + (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206), hlsCheck.statusCode || 0, Date.now() - proxyStartTime); + if (!hlsCheck.error && + (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206)) { + logger('debug', 'YouTube', `HLS fallback check successful for client ${clientName}.`); + const result = { + url: urlData.hlsUrl, + protocol: 'hls', + format: 'mpegts' + }; + this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); + return result; + } + const hlsError = `HLS fallback failed. Status: ${hlsCheck.statusCode}, Error: ${hlsCheck.error}`; + clientErrors.push({ client: clientName, message: hlsError }); + logger('warn', 'YouTube', `Client ${clientName}: ${hlsError}`); } } + else if (urlData.hlsUrl) { + const hlsCheck = await http1makeRequest(urlData.hlsUrl, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + streamOnly: true, + proxy: proxyToUse + }); + if (hlsCheck.stream) + hlsCheck.stream.destroy(); + this.reportProxyStatus(proxyToUse, !hlsCheck.error && + (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206), hlsCheck.statusCode || 0, Date.now() - proxyStartTime); + if (!hlsCheck.error && + (hlsCheck.statusCode === 200 || hlsCheck.statusCode === 206)) { + logger('debug', 'YouTube', `HLS-only check successful for client ${clientName}.`); + const result = { + url: urlData.hlsUrl, + protocol: 'hls', + format: 'mpegts' + }; + this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); + return result; + } + const hlsError = `HLS-only check failed. Status: ${hlsCheck.statusCode}, Error: ${hlsCheck.error}`; + clientErrors.push({ client: clientName, message: hlsError }); + logger('warn', 'YouTube', `Client ${clientName}: ${hlsError}`); + } } - catch (err) { - logger('debug', 'YouTube', `Recovery attempt using ${sourceName} failed: ${err instanceof Error ? err.message : String(err)}`); + catch (e) { + clientErrors.push({ + client: clientName, + message: e.message + }); + logger('warn', 'YouTube', `Client ${clientName} threw an exception in getTrackUrl: ${e.message}`); } } + 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); + } + const mirrored = await this._tryMirrorSourceTrackUrl(decodedTrack, itag, forceRefresh); + if (mirrored) + return mirrored; + logger('error', 'YouTube', 'Failed to get a working track URL from any configured client.'); return { + loadType: 'error', exception: { - message: 'Recovery failed. No alternative sources found.', - severity: 'common' + message: 'Failed to get a working track URL from any client.', + severity: 'fault', + cause: 'All clients failed.' } }; } /** - * Streams audio data for a track using SABR, HLS, or direct range-requests. - * - * @param track - Decoded track information. - * @param url - Resolved playback URL. - * @param protocol - Resolved transport protocol ('sabr', 'hls', or undefined). - * @param additionalData - Metadata required for SABR or seeking. - * @returns A {@link TrackStreamResult} containing the audio readable stream. + * Attempts to resolve a track URL via alternative enabled sources when all + * YouTube clients fail. Uses a debounce set to prevent infinite recursion. + * @param decodedTrack - Track metadata to mirror. + * @param itag - Optional specific format itag to request from the mirror source. + * @param forceRefresh - Whether to bypass caches in the mirror source. + * @returns Promise resolving to a track URL data with a `newTrack` redirect, or `null` on failure. */ - async loadStream(track, url, protocol, additionalData) { - logger('debug', 'YouTube', `Loading stream for: ${track.title}`); - if (protocol === 'sabr') { - return this._loadSabrStream(track, url, additionalData); + async _tryMirrorSourceTrackUrl(decodedTrack, itag, forceRefresh = false) { + const key = `${decodedTrack?.identifier || ''}:${decodedTrack?.title || ''}:${decodedTrack?.author || ''}`; + if (this.mirrorFallbackInFlight.has(key)) + return null; + const blockedFallbackSources = new Set([ + 'amazonmusic', + 'anghami', + 'applemusic', + 'eternalbox', + 'flowery', + 'genius', + 'google-tts', + 'http', + 'instagram', + 'kwai', + 'lastfm', + 'lazypytts', + 'letrasmus', + 'local', + 'pandora', + 'pinterest', + 'pipertts', + 'reddit', + 'rss', + 'shazam', + 'songlink', + 'spotify', + 'telegram', + 'tidal', + 'twitch', + 'tumblr', + 'twitter', + 'vimeo' + ]); + const configuredFallbackSources = Array.isArray(this.config?.fallbackSources) + ? this.config.fallbackSources + : []; + const opts = this.nodelink.options; + const searchConfig = opts.search; + const defaultSources = Array.isArray(searchConfig?.defaultSource) + ? searchConfig.defaultSource + : [searchConfig?.defaultSource]; + const fallbackOrder = [ + ...configuredFallbackSources, + ...defaultSources, + 'soundcloud', + 'deezer', + 'jiosaavn', + 'qobuz', + 'gaana', + 'vkmusic', + 'yandexmusic', + 'audiomack', + 'bandcamp', + 'audius', + 'mixcloud', + 'bilibili', + 'bluesky', + 'nicovideo' + ].filter((name, index, arr) => { + const source = this.nodelink.sources?.getSource(name); + const sourcesConfig = (opts.sources ?? {}); + return (typeof name === 'string' && + name.length > 0 && + arr.indexOf(name) === index && + !['youtube', 'ytmusic'].includes(name) && + !blockedFallbackSources.has(name) && + sourcesConfig[name]?.enabled && + source && + typeof source.search === 'function' && + typeof source.getTrackUrl === 'function'); + }); + if (fallbackOrder.length === 0) + return null; + const query = `${decodedTrack?.title || ''} ${decodedTrack?.author || ''}`.trim(); + if (!query) + return null; + this.mirrorFallbackInFlight.add(key); + try { + for (const fallbackSource of fallbackOrder) { + try { + const search = await this.nodelink.sources?.search(fallbackSource, query); + if (!search || + search.loadType !== 'search' || + !Array.isArray(search.data) || + search.data.length === 0) { + continue; + } + const bestMatch = getBestMatch(search.data, decodedTrack); + const bestInfo = bestMatch?.info; + if (!bestInfo || + ['youtube', 'ytmusic'].includes(bestInfo.sourceName)) { + continue; + } + const stream = await this.nodelink.sources?.getTrackUrl(bestInfo, itag ?? undefined, forceRefresh); + if (!stream?.exception) { + logger('warn', 'YouTube', `Fallback source succeeded via ${bestInfo.sourceName} for "${bestInfo.title}".`); + return { + ...stream, + newTrack: bestMatch + }; + } + } + catch (e) { + logger('debug', 'YouTube', `Fallback source ${fallbackSource} failed: ${e.message}`); + } + } } - if (protocol === 'hls') { - return this._loadHlsStream(url); + finally { + this.mirrorFallbackInFlight.delete(key); } - return this._loadHttpStream(url, additionalData?.startTime || 0); + return null; } /** - * Initializes a SABR (Server Abstraction Layer) stream. + * Opens a readable audio stream for the given track. + * + * Dispatches to the appropriate protocol handler: + * - `sabr` – SABR streaming with automatic stall recovery. + * - `hls` – HLS manifest with n-token decryption. + * - Direct HTTP – range-request streaming when content length is known, + * otherwise a simple single-stream download. + * + * @param decodedTrack - Track metadata. + * @param url - Resolved playback URL (direct or manifest). + * @param protocol - Streaming protocol identifier (`'sabr'`, `'hls'`, `'http'`, `'https'`). + * @param additionalData - Protocol-specific metadata from {@link TrackUrlAdditionalData}. + * @returns Promise resolving to a {@link StreamResult} with the readable stream or an exception. */ - async _loadSabrStream(track, url, data) { - const config = { - url, - videoId: track.identifier, - initialRange: data?.initialRange, - bitrate: data?.bitrate, - client: data?.clientName || 'ANDROID', - playbackContext: data?.playbackContext - }; + async loadStream(decodedTrack, url, protocol, additionalData) { + logger('debug', 'YouTube', `Loading stream for "${decodedTrack.title}" with protocol ${protocol}`); + const cancelSignal = { aborted: false }; + const streamKey = additionalData?.streamKey || Symbol('streamKey'); + this.activeStreams.set(streamKey, cancelSignal); try { - const sabr = new SabrStream(this.nodelink, config); - const stream = await sabr.init(); - return { stream, type: 'opus' }; + if (protocol === 'sabr') { + return await this._loadSabrStream(decodedTrack, additionalData ?? {}, cancelSignal, streamKey); + } + if (protocol === 'hls') { + return this._loadHlsStream(url, cancelSignal, streamKey); + } + if (!url) + throw new Error('No direct URL'); + let contentLength = additionalData?.contentLength ?? null; + if (!contentLength) { + const testResponse = await http1makeRequest(url, { + method: 'HEAD', + timeout: 5000 + }); + const headers = testResponse.headers; + if (headers?.['content-length']) { + contentLength = Number.parseInt(headers['content-length'] ?? '0', 10); + } + if (testResponse.statusCode === 403) { + throw new Error('URL returned 403 Forbidden'); + } + if (!contentLength) { + const rangeResponse = await http1makeRequest(url, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + streamOnly: true, + proxy: this.getProxy() + }); + if (rangeResponse.stream) + rangeResponse.stream.destroy(); + const rangeHeaders = rangeResponse.headers; + if (rangeHeaders?.['content-range']) { + const match = rangeHeaders['content-range']?.match(/\/(\d+)/); + if (match) + contentLength = Number.parseInt(match[1] ?? '0', 10); + } + } + } + if (contentLength && contentLength > 0) { + logger('debug', 'YouTube', `Using range buffering for ${decodedTrack.title} (${Math.round(contentLength / 1024 / 1024)}MB)`); + return this._streamWithRangeRequests(url, contentLength, decodedTrack, cancelSignal, streamKey, additionalData); + } + return await this._loadDirectStream(url, decodedTrack, cancelSignal, streamKey, additionalData); } - catch (err) { + catch (e) { + this.activeStreams.delete(streamKey); + logger('error', 'YouTube', `Error loading stream for ${decodedTrack.identifier}: ${e.message}`); return { exception: { - message: `SABR stream init failed: ${err instanceof Error ? err.message : String(err)}`, - severity: 'common' + message: e.message, + severity: 'fault', + cause: 'Upstream' } }; } } /** - * Initializes an HLS (HTTP Live Streaming) stream. + * Creates a SABR streaming session with automatic stall recovery. + * + * Initializes a {@link SabrStream} instance with the track's session data, + * pipes audio chunks through a PassThrough stream, and sets up a stall + * recovery handler that refreshes the session on failure. + * + * @param decodedTrack - Track metadata for stall recovery URL refresh. + * @param additionalData - SABR session data (tokens, config, formats). + * @param _cancelSignal - Shared cancel token (unused; stream owns cancellation via destroy). + * @param streamKey - Unique key for tracking this stream in the active streams map. + * @returns Promise resolving to a {@link StreamResult} with the PassThrough stream and media type. */ - async _loadHlsStream(url) { - try { - const hls = new HLSHandler(url); - const stream = await hls.init(); - return { stream, type: 'aac' }; - } - catch (err) { - return { - exception: { - message: `HLS stream init failed: ${err instanceof Error ? err.message : String(err)}`, - severity: 'common' + async _loadSabrStream(decodedTrack, additionalData, _cancelSignal, streamKey) { + const sabrConfig = { + videoId: decodedTrack.identifier, + accessToken: additionalData.accessToken, + visitorData: additionalData.visitorData, + serverAbrStreamingUrl: additionalData.serverAbrStreamingUrl, + videoPlaybackUstreamerConfig: additionalData.videoPlaybackUstreamerConfig, + poToken: additionalData.poToken, + clientInfo: additionalData.clientInfo, + formats: additionalData.formats, + startTime: additionalData.startTime ?? 0, + positionCallback: additionalData.positionCallback, + previousSession: additionalData.previousSession + }; + const sabr = new SabrStream(sabrConfig); + const stream = new PassThrough(); + let readyResolved = false; + let readyResolve; + let readyReject; + const ready = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + }); + let isRecovering = false; + let lastRecoverAt = 0; + sabr.on('data', (chunk) => { + if (!readyResolved) { + readyResolved = true; + readyResolve(); + } + if (!stream.write(chunk)) { + sabr.pause(); + } + }); + stream.on('drain', () => sabr.resume()); + sabr.on('end', () => { + if (!readyResolved) { + readyResolved = true; + readyReject(new Error('SABR stream ended before data')); + } + stream.end(); + }); + sabr.on('finishBuffering', () => stream.emit('finishBuffering')); + sabr.on('stall', async () => { + if (isRecovering || stream.destroyed) + return; + const now = Date.now(); + if (now - lastRecoverAt < 2000) + return; + lastRecoverAt = now; + isRecovering = true; + try { + logger('warn', 'YouTube', `SABR stall detected for ${decodedTrack.title}. Refreshing session...`); + const newUrlData = await this.getTrackUrl(decodedTrack, null, true); + if (!newUrlData || newUrlData.protocol !== 'sabr') { + throw new Error('No SABR session available for recovery'); } - }; + const ad = (newUrlData.additionalData || {}); + sabr.clearBuffers(); + sabr.updateSession({ + serverAbrStreamingUrl: (ad.serverAbrStreamingUrl || newUrlData.url), + videoPlaybackUstreamerConfig: ad.videoPlaybackUstreamerConfig, + poToken: ad.poToken, + visitorData: ad.visitorData, + clientInfo: ad.clientInfo, + formats: ad.formats, + userAgent: ad.userAgent, + playbackCookie: ad.playbackCookie + }); + } + catch (err) { + logger('warn', 'YouTube', `SABR recovery failed: ${err.message}`); + if (!stream.destroyed) + stream.destroy(err); + } + finally { + isRecovering = false; + } + }); + sabr.on('error', async (err) => { + logger('error', 'YouTube', `SABR stream error: ${err.message}`); + if (!readyResolved) { + readyResolved = true; + readyReject(err); + } + if ((err.message.includes('sabr.malformed_config') || + err.message.includes('sabr.media_serving_enforcement_id_error')) && + !isRecovering) { + logger('info', 'YouTube', `Known recoverable error detected (${err.message}), triggering stall recovery...`); + sabr.emit('stall'); + return; + } + if (!stream.destroyed) + stream.destroy(err); + }); + const originalDestroy = stream.destroy.bind(stream); + let isDestroying = false; + stream.destroy = ((err) => { + if (isDestroying) + return stream; + isDestroying = true; + sabr.destroy(err); + this.activeStreams.delete(streamKey); + originalDestroy(err); + return stream; + }); + stream.once('close', () => { + if (isDestroying) + return; + isDestroying = true; + sabr.destroy(); + this.activeStreams.delete(streamKey); + }); + stream._sabrStream = sabr; + stream.getSessionState = () => { + if (isDestroying || stream.destroyed) + return null; + return sabr.getSessionState(); + }; + const bestAudio = (additionalData.formats ?? []) + .filter((f) => f.mimeType?.includes('audio')) + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]; + if (!bestAudio) { + stream.destroy(new Error('No audio format available in SABR stream')); + throw new Error('No audio format available in SABR stream'); } + sabr.start(bestAudio.itag); + const type = bestAudio.mimeType?.includes('webm') ? 'webm/opus' : 'm4a'; + await ready; + return { stream, type }; } /** - * Initializes a direct HTTP range-request stream. + * Creates an HLS stream handler for the given manifest URL. + * + * Configures n-token decryption for segment URLs and wires up + * cancellation via the shared cancel signal. + * + * @param url - HLS manifest URL. + * @param cancelSignal - Shared cancel token for stream abort. + * @param streamKey - Unique key for tracking this stream in the active streams map. + * @returns A {@link StreamResult} containing the HLS handler stream. */ - async _loadHttpStream(url, startTimeMs) { + _loadHlsStream(url, cancelSignal, streamKey) { + const playerScriptPromise = this.cipherManager.getCachedPlayerScript(); + const stream = new HLSHandler(url, { + type: 'mpegts', + localAddress: this.nodelink.routePlanner?.getIP?.(), + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + Referer: 'https://www.youtube.com/', + Origin: 'https://www.youtube.com' + }, + onResolveUrl: async (segmentUrl) => { + if (segmentUrl.includes('/n/')) { + const nToken = segmentUrl.match(/\/n\/([^/]+)/)?.[1]; + const playerScript = await playerScriptPromise; + if (nToken && playerScript) { + try { + return await this.cipherManager.resolveUrl(segmentUrl, null, nToken, null, playerScript); + } + catch (err) { + logger('warn', 'YouTube', `Failed to resolve n-token: ${err.message}`); + } + } + } + return null; + } + }); + const originalDestroy = stream.destroy.bind(stream); + stream.destroy = ((err) => { + if (cancelSignal.aborted) + return stream; + cancelSignal.aborted = true; + this.activeStreams.delete(streamKey); + originalDestroy(err); + return stream; + }); + return { stream }; + } + /** + * Streams audio from a direct HTTP URL using a single-pass download. + * + * Pipes the raw HTTP response through a PassThrough stream with + * back-pressure support, error handling, and proper cleanup on + * stream close or destruction. + * + * @param url - Direct playback URL. + * @param _decodedTrack - Track metadata (unused; reserved for future use). + * @param cancelSignal - Shared cancel token for stream abort. + * @param streamKey - Unique key for tracking this stream in the active streams map. + * @param additionalData - Optional additional data containing proxy info. + * @returns Promise resolving to a {@link StreamResult} with the PassThrough stream. + */ + async _loadDirectStream(url, _decodedTrack, cancelSignal, streamKey, additionalData) { + const fetchStartTime = Date.now(); + const response = await http1makeRequest(url, { + method: 'GET', + streamOnly: true, + proxy: (additionalData?.proxy || + this.getProxy()), + timeout: 20000 + }); + this.reportProxyStatus((additionalData?.proxy || this.getProxy(false)), !response.error && + (response.statusCode === 200 || response.statusCode === 206), response.statusCode || 0, Date.now() - fetchStartTime); + if (response.statusCode !== 200 && response.statusCode !== 206) { + throw new Error(`HTTP status ${response.statusCode}`); + } + const responseStream = response.stream; const stream = new PassThrough(); - let currentPos = 0; - let isDestroyed = false; - const streamId = Symbol('YouTubeHTTPStream'); - const cancel = () => { - isDestroyed = true; - stream.destroy(); + stream.responseStream = + responseStream; + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) + return; + cleanedUp = true; + cancelSignal.aborted = true; + responseStream.removeAllListeners(); + if (!responseStream.destroyed) + responseStream.destroy(); + this.activeStreams.delete(streamKey); + stream.removeListener('close', cleanup); }; - this.activeStreams.set(streamId, cancel); - const fetchNextChunk = async () => { - if (isDestroyed) + responseStream.on('data', (chunk) => { + if (!stream.write(chunk)) { + responseStream.pause(); + } + }); + stream.on('drain', () => { + if (!responseStream.destroyed) + responseStream.resume(); + }); + responseStream.on('end', () => { + cleanup(); + if (!stream.writableEnded) { + stream.emit('finishBuffering'); + stream.end(); + } + }); + responseStream.on('error', (error) => { + cleanup(); + if (error.message === 'aborted' || error.code === 'ECONNRESET') { + logger('debug', 'YouTube', 'Client disconnected from stream'); + if (!stream.destroyed) + stream.destroy(); + return; + } + logger('error', 'YouTube', `Stream error: ${error.message}`); + if (!stream.destroyed) { + stream.emit('error', new Error(`Stream failed: ${error.message}`)); + stream.destroy(); + } + }); + const originalDestroy = stream.destroy.bind(stream); + stream.destroy = ((err) => { + cleanup(); + originalDestroy(err); + return stream; + }); + stream.once('close', cleanup); + return { stream }; + } + /** + * Streams audio using HTTP range requests with automatic URL recovery. + * + * Fetches the media in {@link CHUNK_SIZE} chunks, tracks the byte position, + * and performs URL recovery when the upstream returns 403/404/5xx errors + * or connection resets. Recovery attempts a fresh URL from `getTrackUrl` + * and resumes from the last known position. + * + * @param url - Initial playback URL. + * @param contentLength - Total content length in bytes. + * @param decodedTrack - Track metadata used for URL recovery. + * @param cancelSignal - Shared cancel token for stream abort. + * @param streamKey - Unique key for tracking this stream in the active streams map. + * @param additionalData - Optional additional data containing proxy info. + * @returns A {@link StreamResult} containing the range-request PassThrough stream. + */ + _streamWithRangeRequests(url, contentLength, decodedTrack, cancelSignal, streamKey, additionalData) { + const stream = new PassThrough({ highWaterMark: CHUNK_SIZE * 2 }); + let position = 0; + let errors = 0; + let refreshes = 0; + let currentUrl = url; + let destroyed = false; + let fetching = false; + let activeRequest = null; + let recoverTimeout = null; + let currentAdditionalData = additionalData; + const cleanup = () => { + if (destroyed) + return; + destroyed = true; + cancelSignal.aborted = true; + stream.removeListener('drain', onDrain); + stream.removeListener('close', cleanup); + stream.removeListener('end', cleanup); + stream.removeListener('error', cleanup); + if (activeRequest) { + activeRequest.removeAllListeners(); + if (!activeRequest.destroyed) + activeRequest.destroy(); + activeRequest = null; + } + if (recoverTimeout) { + clearTimeout(recoverTimeout); + recoverTimeout = null; + } + this.activeStreams.delete(streamKey); + }; + const onDrain = () => { + if (destroyed || cancelSignal.aborted) + return; + if (activeRequest && !activeRequest.destroyed) { + activeRequest.resume(); + } + if (!fetching && position < contentLength) { + fetchNext(); + } + }; + stream.on('drain', onDrain); + stream.once('close', cleanup); + stream.once('end', cleanup); + stream.once('error', cleanup); + const fetchNext = async () => { + if (destroyed || cancelSignal.aborted || stream.destroyed) { + cleanup(); return; + } + if (position >= contentLength) { + if (!stream.writableEnded) { + stream.emit('finishBuffering'); + stream.end(); + } + cleanup(); + return; + } + if (fetching) + return; + fetching = true; + const start = position; + const end = Math.min(start + CHUNK_SIZE - 1, contentLength - 1); try { - const startByte = currentPos === 0 && startTimeMs === 0 ? 0 : undefined; // Simple demo, real impl needs byte mapping - const response = await http1makeRequest(url, { - headers: startByte !== undefined ? { Range: `bytes=${startByte}-` } : {} + const fetchStartTime = Date.now(); + const result = await http1makeRequest(currentUrl, { + method: 'GET', + headers: { Range: `bytes=${start}-${end}` }, + streamOnly: true, + proxy: (currentAdditionalData?.proxy || + this.getProxy()), + timeout: 20000 }); - if (response.error || !response.body) { - stream.emit('error', new Error(response.error || 'Fetch failed')); + const responseStream = result.stream; + const { error, statusCode } = result; + this.reportProxyStatus((currentAdditionalData?.proxy || this.getProxy(false)), !error && (statusCode === 200 || statusCode === 206), statusCode || 0, Date.now() - fetchStartTime); + if (destroyed || cancelSignal.aborted) { + if (responseStream && !responseStream.destroyed) { + responseStream.destroy(); + } + fetching = false; return; } - // In a real implementation, we would pipe chunks here - // (Simplified for restoration purposes) - if (response.body instanceof PassThrough || response.body.pipe) { - response.body.pipe(stream); + const rs = responseStream; + activeRequest = rs; + if (error || (statusCode !== 200 && statusCode !== 206)) { + if (statusCode === 403 || + statusCode === 404 || + (statusCode ?? 0) >= 500) { + logger('warn', 'YouTube', `Got ${statusCode} at pos ${position} → forcing recovery`); + fetching = false; + recover(); + return; + } + throw new Error(`Range request failed: ${statusCode}`); } + const onData = (chunk) => { + if (destroyed || cancelSignal.aborted) { + rs.destroy(); + return; + } + if (refreshes > 0) + refreshes = 0; + position += chunk.length; + if (!stream.write(chunk)) { + rs.pause(); + } + }; + const onEnd = () => { + cleanupRequestListeners(); + activeRequest = null; + fetching = false; + if (!destroyed && !cancelSignal.aborted && position < contentLength) { + setImmediate(fetchNext); + } + else if (!stream.writableEnded && position >= contentLength) { + stream.emit('finishBuffering'); + stream.end(); + cleanup(); + } + }; + const onError = (err) => { + cleanupRequestListeners(); + activeRequest = null; + fetching = false; + if (!destroyed && !cancelSignal.aborted) { + logger('warn', 'YouTube', `Range request error at pos ${position}: ${err.message}`); + const isAborted = err.message === 'aborted' || err.code === 'ECONNRESET'; + if (++errors >= MAX_RETRIES || isAborted) { + if (isAborted) + logger('warn', 'YouTube', 'Connection aborted, forcing immediate recovery with new URL.'); + recover(err); + } + else { + const timeout = setTimeout(fetchNext, Math.min(1000 * 2 ** (errors - 1), 5000)); + if (typeof timeout.unref === 'function') + timeout.unref(); + } + } + }; + const cleanupRequestListeners = () => { + rs.removeListener('data', onData); + rs.removeListener('end', onEnd); + rs.removeListener('error', onError); + }; + rs.on('data', onData); + rs.on('end', onEnd); + rs.on('error', onError); } catch (err) { - stream.emit('error', err); + activeRequest = null; + fetching = false; + if (!destroyed && !cancelSignal.aborted) { + logger('warn', 'YouTube', `Range request exception at pos ${position}: ${err.message}`); + const isAborted = err.message === 'aborted' || + err.code === 'ECONNRESET'; + if (++errors >= MAX_RETRIES || isAborted) { + if (isAborted) + logger('warn', 'YouTube', 'Connection aborted, forcing immediate recovery with new URL.'); + recover(err); + } + else { + const timeout = setTimeout(fetchNext, Math.min(1000 * 2 ** (errors - 1), 5000)); + if (typeof timeout.unref === 'function') + timeout.unref(); + } + } } - finally { - this.activeStreams.delete(streamId); + }; + const recover = async (causeError) => { + if (destroyed || cancelSignal.aborted) + return; + const isForbidden = causeError?.message?.includes('403') || causeError?.statusCode === 403; + const isAborted = causeError?.message === 'aborted' || + causeError?.code === 'ECONNRESET'; + if (!isForbidden && !isAborted && 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) { + stream.destroy(new Error('Failed to recover stream')); + } + return; + } + if (stream.destroyed || stream.writableEnded) { + cleanup(); + return; + } + if (isAborted && stream.writableNeedDrain) { + logger('debug', 'YouTube', `Stream is paused/backed up, skipping recovery (cause: ${causeError?.message}). Player will recover on resume.`); + return; + } + if (isAborted && stream.writableNeedDrain) { + logger('debug', 'YouTube', `Stream is paused/backed up, waiting for drain before recovery (cause: ${causeError?.message})`); + await new Promise((resolve) => { + const onDrain = () => { + stream.off('drain', onDrain); + resolve(); + }; + stream.once('drain', onDrain); + const timeout = setTimeout(() => { + stream.off('drain', onDrain); + resolve(); + }, 60000); + if (typeof timeout.unref === 'function') + timeout.unref(); + }); + if (destroyed || cancelSignal.aborted || stream.destroyed) + return; + if (stream.writableNeedDrain) { + logger('debug', 'YouTube', 'Stream still backed up after drain wait, deferring recovery until resume'); + return; + } + } + try { + const newUrlData = await this.getTrackUrl(decodedTrack, null, true); + if (destroyed || cancelSignal.aborted) + return; + if (newUrlData.exception || !newUrlData.url) { + throw new Error('No valid URL from getTrackUrl'); + } + currentUrl = newUrlData.url; + currentAdditionalData = + newUrlData.additionalData; + errors = 0; + logger('debug', 'YouTube', `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, cause: ${causeError?.message})`); + fetching = false; + fetchNext(); + } + catch (error) { + logger('warn', 'YouTube', `Recovery failed (attempt ${refreshes}): ${error.message}`); + if (!destroyed && !cancelSignal.aborted) { + recoverTimeout = setTimeout(() => recover(causeError), 4000 + refreshes * 1000); + if (typeof recoverTimeout.unref === 'function') { + recoverTimeout.unref(); + } + } } }; - void fetchNextChunk(); - return { stream, type: 'webm/opus' }; + fetchNext(); + const originalDestroy = stream.destroy.bind(stream); + stream.destroy = ((err) => { + cleanup(); + originalDestroy(err); + return stream; + }); + return { stream }; } /** - * Fetches chapters for a YouTube video. + * Fetches chapter markers for a track using the Web client. + * @param trackInfo - Track metadata containing the video identifier. + * @returns Promise resolving to an array of chapter objects, or an empty array on failure. */ async getChapters(trackInfo) { - const clientNames = this.config.clients?.resolve || ['Web']; - for (const name of clientNames) { - const client = this.clients[name]; - if (!client) - continue; - try { - const chapters = await client.getChapters(trackInfo.identifier); - if (chapters) - return chapters; - } - catch (_err) { } + const webClient = this.clients.Web; + if (!webClient) { + logger('warn', 'YouTube', 'Web client not available for fetching chapters.'); + return []; + } + try { + return (await webClient.getChapters?.(trackInfo, this.ytContext)) ?? []; + } + catch (e) { + logger('error', 'YouTube', `Failed to fetch chapters: ${e.message}`); + return []; } - return []; } /** - * Passes the live chat WebSocket connection to the LiveChat handler. + * Handles a new live chat WebSocket connection for the given track. + * @param socket - WebSocket connection object from the framework. + * @param id - YouTube video identifier for the live stream. + * @returns Promise resolving when the live chat handler completes. */ - async handleLiveChat(socket, videoId) { - await this.liveChat.handle(socket, videoId); + async handleLiveChat(socket, id) { + return this.liveChat.handleConnection(socket, id); } } diff --git a/dist/src/sources/youtube/common.js b/dist/src/sources/youtube/common.js index 0f31b4f6..16392b6e 100644 --- a/dist/src/sources/youtube/common.js +++ b/dist/src/sources/youtube/common.js @@ -940,7 +940,7 @@ function getRendererFromItemData(itemData, itemType) { * @returns Built track data or null * @public */ -export async function buildTrack(itemData, itemType, sourceNameOverride = null, fullApiResponse = null, enableHolo = false, config = {}, makeRequestFn = null) { +export async function buildTrack(itemData, itemType, sourceNameOverride = null, fullApiResponse = null, enableHolo = false, config = { search: {} }, makeRequestFn = null) { if (!itemData) { logger('warn', 'buildTrack', 'itemData is null or undefined'); return null; @@ -1202,7 +1202,7 @@ export async function buildTrack(itemData, itemType, sourceNameOverride = null, * @returns Built holo track data * @public */ -export async function buildHoloTrack(trackInfo, itemData, itemType, fullApiResponse = null, config = {}, makeRequestFn = null) { +export async function buildHoloTrack(trackInfo, itemData, itemType, fullApiResponse = null, config = { search: {} }, makeRequestFn = null) { const duration = formatDuration(trackInfo.length); const sourceName = trackInfo.sourceName; const sourceUrl = sourceName === 'ytmusic' @@ -1696,7 +1696,8 @@ export class BaseClient { } const track = await buildTrack(videoDetails, sourceName, null, playerResponse, !!this.config.experimental.enableHoloTracks, { resolveExternalLinks: !!this.config.search.resolveExternalLinks, - fetchChannelInfo: !!this.config.search.fetchChannelInfo + fetchChannelInfo: !!this.config.search.fetchChannelInfo, + search: {} }); if (!track) { logger('error', `youtube-${this.name}`, `Failed to build track for ${videoId}`); @@ -1761,13 +1762,14 @@ export class BaseClient { } const tracks = []; let selectedTrack = 0; - const maxLength = this.config.playback.maxPlaylistLength || 100; + const maxLength = this.config.playback?.maxPlaylistLength || 100; for (let i = 0; i < Math.min(playlistContent.length, maxLength); i++) { const item = playlistContent[i]; try { const track = await buildTrack(item, sourceName || 'youtube', null, null, !!this.config.experimental.enableHoloTracks, { fetchChannelInfo: false, - resolveExternalLinks: false + resolveExternalLinks: false, + search: {} }); if (track) { tracks.push(track); @@ -1843,14 +1845,15 @@ export class BaseClient { return { loadType: 'empty', data: {} }; } const tracks = []; - const maxLength = this.config.playback.maxPlaylistLength || 100; + const maxLength = this.config.playback?.maxPlaylistLength || 100; const shelfContents = shelf.contents; for (let i = 0; i < Math.min(shelfContents.length, maxLength); i++) { const item = shelfContents[i]; try { const track = await buildTrack(item, sourceName || 'ytmusic', sourceName, browseResponse, !!this.config.experimental.enableHoloTracks, { fetchChannelInfo: false, - resolveExternalLinks: false + resolveExternalLinks: false, + search: {} }); if (track) { tracks.push(track); @@ -1919,7 +1922,7 @@ export class BaseClient { } else { const qualityPriority = this._getQualityPriority(); - const audioConfig = this.config.playback.audio; + const audioConfig = this.config.playback?.audio; const audioQuality = audioConfig?.quality || 'high'; targetItags = qualityPriority[audioQuality] || []; @@ -2245,4 +2248,43 @@ export class BaseClient { } return await this._extractStreamData(playerResult.body, decodedTrack, context, cipherManager, itag); } + /** + * Fetches the initial visitor data by making a GET request to YouTube. + * This is used to initialize the session context. + * + * @returns The extracted visitor data string, or null if it could not be found. + */ + async getVisitorData() { + try { + const response = await makeRequest('https://www.youtube.com', { + method: 'GET', + headers: { + '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' + }, + proxy: this.getProxy() + }); + if (response.statusCode !== 200 || !response.body) { + return null; + } + const body = response.body; + const match = body.match(/ytcfg\.set\((\{.*?\})\);/); + if (match?.[1]) { + try { + const ytcfg = JSON.parse(match[1]); + if (ytcfg.VISITOR_DATA) { + return ytcfg.VISITOR_DATA; + } + } + catch { + // Fallback to regex if JSON parse fails + } + } + const visitorDataMatch = body.match(/"visitorData":"([^"]+)"/); + return visitorDataMatch?.[1] || null; + } + catch (err) { + logger('debug', `youtube-${this.name}`, `Failed to fetch visitor data: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + } } diff --git a/dist/src/utils.js b/dist/src/utils.js index 5763ba51..e49e4669 100644 --- a/dist/src/utils.js +++ b/dist/src/utils.js @@ -1370,7 +1370,7 @@ async function makeRequest(urlString, options, nodelink) { 0) { return http1makeRequest(urlString, options); } - if (options.network.proxy) { + if (options.network?.proxy) { return http1makeRequest(urlString, options); } const localAddress = finalNodeLink?.routePlanner?.getIP?.() ?? undefined; @@ -1746,7 +1746,7 @@ function applyEnvOverrides(config, prefix = 'NODELINK') { * @returns Best matching candidate or null. * @public */ -function getBestMatch(list, original, options = {}) { +export function getBestMatch(list, original, options = {}) { const { durationTolerance = 0.15, allowExplicit = true } = options; const normalize = (str) => { if (!str) @@ -1906,13 +1906,13 @@ async function fetchSponsorBlockSegments(videoId, categories, actionTypes, apiBa } logger('debug', 'SponsorBlock', `Successfully loaded ${videoMatch.segments.length} segments for video ${videoId} in ${duration}ms`); return videoMatch.segments.map((s) => ({ - uuid: s.UUID, - start: Math.round(s.segment[0] * 1000), - end: Math.round(s.segment[1] * 1000), + uuid: s.uuid, + start: Math.round(s.start * 1000), + end: Math.round(s.end * 1000), category: s.category, actionType: s.actionType, votes: s.votes, - locked: s.locked === 1, + locked: s.locked, videoDuration: Math.round(s.videoDuration * 1000), description: s.description || '' })); @@ -1922,4 +1922,4 @@ async function fetchSponsorBlockSegments(videoId, categories, actionTypes, apiBa return []; } } -export { applyEnvOverrides, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, getBestMatch, getGitInfo, getStats, getVersion, http1makeRequest, initLogger, logger, makeRequest, parseClient, parseSemver, sendErrorResponse, sendResponse, validateProperty, verifyDiscordID, verifyMethod }; +export { applyEnvOverrides, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, getGitInfo, getStats, getVersion, http1makeRequest, initLogger, logger, makeRequest, parseClient, parseSemver, sendErrorResponse, sendResponse, validateProperty, verifyDiscordID, verifyMethod }; diff --git a/dist/src/workers/main.js b/dist/src/workers/main.js index 166eca6f..42c910ea 100644 --- a/dist/src/workers/main.js +++ b/dist/src/workers/main.js @@ -23,6 +23,7 @@ import RoutePlannerManager from "../managers/routePlannerManager.js"; import SourceManager from "../managers/sourceManager.js"; import StatsManager from "../managers/statsManager.js"; import TrackCacheManager from "../managers/trackCacheManager.js"; +import { migrateConfig } from "../modules/config/configMigration.js"; import { getWebmOpusProfilerStats } from "../playback/demuxers/WebmOpus.js"; import { bufferPool } from "../playback/structs/BufferPool.js"; import { applyEnvOverrides, cleanupHttpAgents, initLogger, logger } from "../utils.js"; @@ -57,14 +58,42 @@ try { catch (_e) { } let config; const resolveRootConfigUrl = (fileName) => pathToFileURL(resolvePath(process.cwd(), fileName)).href; -try { - config = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.js')))) - .default; -} -catch { - config = (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.js')))) - .default; -} +const resolveConfigExport = (importedModule, fileName) => { + const candidate = importedModule.default ?? + importedModule.config; + if (candidate && + typeof candidate === 'object' && + Object.keys(candidate).length > 0) { + return candidate; + } + throw new Error(`[ERROR] Config: ${fileName} must export a non-empty configuration object (default export or named "config").`); +}; +const loadConfig = async () => { + const candidates = [ + 'config.ts', + 'config.js', + 'config.default.ts', + 'config.default.js' + ]; + for (const fileName of candidates) { + try { + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl(fileName))); + const raw = resolveConfigExport(module, fileName); + return migrateConfig(raw); + } + catch (error) { + const err = error; + const isNotFound = err.code === 'ERR_MODULE_NOT_FOUND' || + err.code === 'ENOENT' || + err.message?.includes('Cannot find module'); + if (isNotFound) + continue; + throw error; + } + } + throw new Error('[ERROR] Config: Failed to load configuration (config.ts/config.js/config.default.ts/config.default.js).'); +}; +config = await loadConfig(); applyEnvOverrides(config); const HIBERNATION_ENABLED = config.cluster?.hibernation?.enabled !== false; const HIBERNATION_TIMEOUT = config.cluster?.hibernation?.timeoutMs || 20 * 60 * 1000; diff --git a/dist/src/workers/source.js b/dist/src/workers/source.js index eb695cbe..7bf58626 100644 --- a/dist/src/workers/source.js +++ b/dist/src/workers/source.js @@ -15,6 +15,7 @@ import { resolve as resolvePath } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import v8 from 'node:v8'; import { isMainThread, parentPort, workerData as rawWorkerData, Worker } from 'node:worker_threads'; +import { migrateConfig } from "../modules/config/configMigration.js"; import * as utils from "../utils.js"; import { createHeadQueue, dequeueHeadQueue, enqueueHeadQueue, getHeadQueueLength } from "./headQueue.js"; const __filename = fileURLToPath(import.meta.url); @@ -59,12 +60,39 @@ if (isMainThread) { * @internal */ async function loadConfig() { - try { - return (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.js')))).default; - } - catch { - return (await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.js')))).default; + const resolveConfigExport = (importedModule, fileName) => { + const candidate = importedModule.default ?? + importedModule.config; + if (candidate && + typeof candidate === 'object' && + Object.keys(candidate).length > 0) { + return candidate; + } + throw new Error(`[ERROR] Config: ${fileName} must export a non-empty configuration object (default export or named "config").`); + }; + const candidates = [ + 'config.ts', + 'config.js', + 'config.default.ts', + 'config.default.js' + ]; + for (const fileName of candidates) { + try { + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl(fileName))); + const raw = resolveConfigExport(module, fileName); + return migrateConfig(raw); + } + catch (error) { + const err = error; + const isNotFound = err.code === 'ERR_MODULE_NOT_FOUND' || + err.code === 'ENOENT' || + err.message?.includes('Cannot find module'); + if (isNotFound) + continue; + throw error; + } } + throw new Error('[ERROR] Config: Failed to load configuration (config.ts/config.js/config.default.ts/config.default.js).'); } const config = await loadConfig(); utils.applyEnvOverrides(config); From 936579aa900dcdb10d42446035454c748801fd35 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 17 May 2026 23:27:35 -0400 Subject: [PATCH 27/58] update: bump version to 3.8.1-dev.20260517.1 --- dist/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/package.json b/dist/package.json index 2a296d65..9dc2eeb6 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260515.1", + "version": "3.8.1-dev.20260517.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/package.json b/package.json index db22b094..0a7c836d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260515.1", + "version": "3.8.1-dev.20260517.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", From 2c79e45b61254ff29df280cc16f57f5eb1a7c4c1 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Mon, 18 May 2026 19:21:08 -0300 Subject: [PATCH 28/58] update: bump symphonia to 1.1.0 Symphonia v0.6.0 Rubato v2.0.0 And fixed FLAC playback, ig thats good. New repo: https://github.com/ToddyTheNoobDud/symphonia-decoder --- dist/package.json | 2 +- .../playback/processing/streamProcessor.js | 267 ++++++++-------- package.json | 2 +- src/playback/processing/streamProcessor.ts | 290 +++++++++--------- src/typings/playback/streamProcessor.types.ts | 6 +- 5 files changed, 282 insertions(+), 285 deletions(-) diff --git a/dist/package.json b/dist/package.json index 9dc2eeb6..1ec8da01 100644 --- a/dist/package.json +++ b/dist/package.json @@ -21,7 +21,7 @@ "@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.6", + "@toddynnn/symphonia-decoder": "1.1.0", "@toddynnn/voice-opus": "^1.0.1", "fastest-validator": "^1.19.1", "jsdom": "^29.1.1", diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index fe0f7c85..f12e6177 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -160,6 +160,18 @@ const _isMp4Format = (type) => type.indexOf('mp4') !== -1 || type.indexOf('quicktime') !== -1; const _isWebmFormat = (type) => type.includes('webm') || type.includes('weba'); const _isFlvFormat = (type) => type.indexOf('flv') !== -1; +const _getSymphoniaCodecHint = (type) => { + const lowerType = type.toLowerCase(); + if (lowerType.includes('flac')) + return 'flac'; + if (lowerType.includes('mp3') || lowerType.includes('mpeg')) + return 'mp3'; + if (lowerType.includes('ogg') || lowerType.includes('vorbis')) + return 'ogg'; + if (lowerType.includes('wav') || lowerType.includes('wave')) + return 'wav'; + return null; +}; const _tightBuffer = (buf) => buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength ? buf : Buffer.from(buf); @@ -374,12 +386,14 @@ class PCMFrameCounter extends Transform { class BaseAudioResource { pipes; stream; + canStop; _destroyed; guildId; constructor(guildId) { this.guildId = guildId || 'api-stream'; this.pipes = []; this.stream = null; + this.canStop = false; this._destroyed = false; } _assignStream(stream) { @@ -541,34 +555,30 @@ class BaseAudioResource { } class SymphoniaDecoderStream extends Transform { decoder; - resumeInput; + codecRegistryHint; + flushCallback; + inputClosed; isFinished; _aborted; - _loopScheduled; _isDecoding; _timeoutId; _immediateId; - _onResume; constructor(options = {}) { + const { codecRegistryHint, ...streamOptions } = options; super({ - ...options, - highWaterMark: AUDIO_CONFIG.highWaterMark, + ...streamOptions, + highWaterMark: options.highWaterMark ?? AUDIO_CONFIG.highWaterMark, objectMode: false }); this.decoder = new SymphoniaDecoder(); - this.resumeInput = null; + this.codecRegistryHint = codecRegistryHint ?? null; + this.flushCallback = null; + this.inputClosed = false; this.isFinished = false; this._aborted = false; - this._loopScheduled = false; this._isDecoding = false; this._timeoutId = null; this._immediateId = null; - this._onResume = () => { - if (!this.isFinished && !this._aborted && this.decoder) { - this._scheduleDecode(); - } - }; - this.on('resume', this._onResume); } abort() { this._aborted = true; @@ -583,7 +593,6 @@ class SymphoniaDecoderStream extends Transform { clearImmediate(this._immediateId); this._immediateId = null; } - this._loopScheduled = false; } _isDecoderValid() { return this.decoder !== null && !this._aborted && !this.isFinished; @@ -593,117 +602,109 @@ class SymphoniaDecoderStream extends Transform { callback(); return; } - this.decoder.push(chunk); - this._scheduleDecode(); - const bufferedBytes = this.decoder?.bufferedBytes ?? 0; - if (bufferedBytes > BUFFER_THRESHOLDS.maxCompressed) { - this.resumeInput = callback; - } - else { + try { + this.decoder.push(chunk); + if (!this.decoder.isProbed) { + this.decoder.initialize(this.codecRegistryHint); + } + this._scheduleDecode(); callback(); } + catch (err) { + callback(err); + } + } + _read(_size) { + super._read(_size); + if (this._isDecoderValid()) { + this._scheduleDecode(); + } } - _scheduleDecode() { - if (this._loopScheduled || - this._isDecoding || - !this._isDecoderValid() || - this.readableFlowing === false) + _scheduleDecode(delayMs = 0) { + if (this._immediateId || this._timeoutId || !this._isDecoderValid()) return; - if (this.readableLength >= this.readableHighWaterMark) { - this._loopScheduled = true; + if (delayMs > 0) { this._timeoutId = setTimeout(() => { this._timeoutId = null; - this._loopScheduled = false; - if (this._isDecoderValid()) - this._scheduleDecode(); - }, AUDIO_CONSTANTS.decodeIntervalMs); + this._decodeLoop(); + }, delayMs); return; } - this._loopScheduled = true; - this._timeoutId = setTimeout(() => { - this._timeoutId = null; - this._loopScheduled = false; - if (this._isDecoderValid()) - this._decodeLoop(); - }, AUDIO_CONSTANTS.decodeIntervalMs); + this._immediateId = setImmediate(() => { + this._immediateId = null; + this._decodeLoop(); + }); } - async _decodeLoop() { - if (!this._isDecoderValid() || this.readableFlowing === false) + _decodeLoop() { + if (this._isDecoding || !this._isDecoderValid()) return; + if (!this.decoder?.isProbed) { + try { + if (!this.decoder?.initialize(this.codecRegistryHint)) { + if (this.inputClosed) + this._finishDecode(); + return; + } + } + catch (err) { + this._failDecode(err); + return; + } + } + if (this.readableLength >= this.readableHighWaterMark) { + this._scheduleDecode(AUDIO_CONSTANTS.decodeIntervalMs); + return; + } this._isDecoding = true; try { - let hasMoreData = true; - while (hasMoreData && + let decodeCount = 0; + while (decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && this._isDecoderValid() && - this.readableFlowing !== false && this.readableLength < this.readableHighWaterMark) { - hasMoreData = this._processAudio(); - if (hasMoreData && this._isDecoderValid()) { - await new Promise((resolve) => { - this._immediateId = setImmediate(() => { - this._immediateId = null; - resolve(); - }); - }); + const result = this.decoder?.decode(); + if (!result) { + if (this.inputClosed) + this._finishDecode(); + return; + } + decodeCount++; + if (result.samples.length === 0) + continue; + if (!this.push(result.samples)) { + this._scheduleDecode(AUDIO_CONSTANTS.decodeIntervalMs); + return; } } + this._scheduleDecode(); } catch (err) { - if (!this._aborted) - this.emit('error', err); + this._failDecode(err); } finally { this._isDecoding = false; } - const bufferedBytes = this.decoder?.bufferedBytes ?? 0; - if (bufferedBytes > 0 && - this._isDecoderValid() && - this.readableFlowing !== false && - this.readableLength < this.readableHighWaterMark) { - this._scheduleDecode(); - } } - _processAudio() { - if (!this._isDecoderValid()) - return false; - if (this.readableLength >= this.readableHighWaterMark) - return true; - if (!this.decoder?.isProbed) { - try { - if (!this.decoder?.initialize()) - return false; - } - catch (err) { - throw new Error(`Symphonia init failed: ${err.message}`); - } + _finishDecode() { + const callback = this.flushCallback; + this.flushCallback = null; + this.isFinished = true; + this._cleanup(); + callback?.(); + } + _failDecode(err) { + const callback = this.flushCallback; + this.flushCallback = null; + this.isFinished = true; + this._cleanup(); + const error = err instanceof Error ? err : new Error(`Symphonia decode failed: ${err}`); + if (callback) { + callback(error); } - let decodeCount = 0; - let hasOutput = false; - while (decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && - this._isDecoderValid() && - this.readableLength < this.readableHighWaterMark) { - const result = this.decoder?.decode(); - if (!result) - break; - const canPush = this.push(result.samples); - hasOutput = true; - decodeCount++; - 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; + else { + this.emit('error', error); } - const remainingBytes = this.decoder?.bufferedBytes ?? 0; - return hasOutput || remainingBytes > 0; } _flush(callback) { - this.isFinished = true; this._cancelTimers(); if (this._aborted || !this.decoder) { this._cleanup(); @@ -712,47 +713,39 @@ class SymphoniaDecoderStream extends Transform { } try { this.decoder.closeInput(); - let count = 0; - while (count < 1000) { - const result = this.decoder?.decode(); - if (!result) - break; - this.push(result.samples); - count++; + this.inputClosed = true; + this.flushCallback = callback; + if (!this.decoder.initialize(this.codecRegistryHint)) { + if ((this.decoder.bufferedBytes ?? 0) === 0) { + this._cleanup(); + callback(); + return; + } + throw new Error('Symphonia init failed: not enough input data'); } + this._decodeLoop(); + } + catch (err) { + this.flushCallback = null; + this._cleanup(); + callback(err); } - catch { } - this._cleanup(); - callback(); } _destroy(err, callback) { this._aborted = true; this.isFinished = true; this._cancelTimers(); - if (this.resumeInput) { - const cb = this.resumeInput; - this.resumeInput = null; - cb(); + if (this.flushCallback) { + const cb = this.flushCallback; + this.flushCallback = null; + cb(err); } this._cleanup(); super._destroy(err, callback); } _cleanup() { this._cancelTimers(); - this.removeListener('resume', this._onResume); - if (this.resumeInput) { - const cb = this.resumeInput; - this.resumeInput = null; - try { - cb(); - } - catch { } - } if (this.decoder) { - try { - this.decoder.flush(); - } - catch { } try { this.decoder.free(); } @@ -1863,7 +1856,7 @@ class StreamAudioResource extends BaseAudioResource { case SupportedFormats.FLAC: case SupportedFormats.OGG_VORBIS: case SupportedFormats.WAV: - return this._createSymphoniaPipeline(stream); + return this._createSymphoniaPipeline(stream, type); case SupportedFormats.OPUS: return this._createOpusPipeline(stream, type); default: @@ -1896,7 +1889,9 @@ class StreamAudioResource extends BaseAudioResource { const demuxer = new MPEGTSDemuxer(); streams.push(demuxer); if (lowerType.includes('mp3') || lowerType.includes('mpeg')) { - const decoder = new SymphoniaDecoderStream(); + const decoder = new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(lowerType) + }); streams.push(decoder); this.pipes?.push(...streams.slice(1)); pipeline(streams, (err) => { @@ -1930,8 +1925,10 @@ class StreamAudioResource extends BaseAudioResource { }); return decoder; } - _createSymphoniaPipeline(stream) { - const decoder = new SymphoniaDecoderStream(); + _createSymphoniaPipeline(stream, type) { + const decoder = new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(type) + }); this.pipes?.push(decoder); pipeline(stream, decoder, (err) => { if (err && !this._destroyed) { @@ -2085,8 +2082,8 @@ class StreamAudioResource extends BaseAudioResource { type: 's16le', volume, enableAGC, - lookaheadMs: this.nodelink?.options?.playback.audio?.lookaheadMs, - gateThresholdLUFS: this.nodelink?.options?.playback.audio?.gateThresholdLUFS + lookaheadMs: this.nodelink?.options?.audio?.lookaheadMs, + gateThresholdLUFS: this.nodelink?.options?.audio?.gateThresholdLUFS }); pipeline(pcmStream, volumeTransformer, (err) => { if (err && !this._destroyed) { @@ -2199,7 +2196,9 @@ export const createPCMStream = (_guildId, stream, type, nodelink, volume = 1.0, else if (_isMpegtsFormat(lowerType)) { streams.push(new MPEGTSDemuxer()); if (lowerType.includes('mp3') || lowerType.includes('mpeg')) { - streams.push(new SymphoniaDecoderStream()); + streams.push(new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(lowerType) + })); break; } } @@ -2221,7 +2220,9 @@ export const createPCMStream = (_guildId, stream, type, nodelink, volume = 1.0, case SupportedFormats.FLAC: case SupportedFormats.OGG_VORBIS: case SupportedFormats.WAV: { - streams.push(new SymphoniaDecoderStream()); + streams.push(new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(type) + })); break; } case SupportedFormats.OPUS: { diff --git a/package.json b/package.json index 0a7c836d..73f0377c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@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.6", + "@toddynnn/symphonia-decoder": "1.1.0", "@toddynnn/voice-opus": "^1.0.1", "fastest-validator": "^1.19.1", "jsdom": "^29.1.1", diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index 68c6fa66..ddf66d9a 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -46,7 +46,8 @@ import type { ResamplingQuality, RingBufferLike, SeekableStreamMeta, - SymphoniaDecoderLike + SymphoniaDecoderLike, + SymphoniaDecoderStreamOptions } from '../../typings/playback/streamProcessor.types.ts' import { http1makeRequest, logger } from '../../utils.ts' import FlvDemuxer from '../demuxers/Flv.ts' @@ -263,6 +264,15 @@ const _isWebmFormat = (type: string): boolean => type.includes('webm') || type.includes('weba') const _isFlvFormat = (type: string): boolean => type.indexOf('flv') !== -1 + +const _getSymphoniaCodecHint = (type: string): string | null => { + const lowerType = type.toLowerCase() + if (lowerType.includes('flac')) return 'flac' + if (lowerType.includes('mp3') || lowerType.includes('mpeg')) return 'mp3' + if (lowerType.includes('ogg') || lowerType.includes('vorbis')) return 'ogg' + if (lowerType.includes('wav') || lowerType.includes('wave')) return 'wav' + return null +} const _tightBuffer = (buf: Buffer): Buffer => buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength ? buf @@ -581,6 +591,7 @@ class PCMFrameCounter extends Transform { class BaseAudioResource { pipes: (Readable | Transform)[] | null stream: (VoiceAudioStream & Transform) | null + canStop?: boolean protected _destroyed: boolean protected guildId: string @@ -588,12 +599,14 @@ class BaseAudioResource { this.guildId = guildId || 'api-stream' this.pipes = [] this.stream = null + this.canStop = false this._destroyed = false } protected _assignStream(stream: Transform): void { const voiceStream = stream as unknown as VoiceAudioStream & Transform & { + canStop?: boolean checkTapeRampCompleted?: () => boolean scratchTo?: ( durationMs: number, @@ -845,43 +858,33 @@ class BaseAudioResource { class SymphoniaDecoderStream extends Transform { private decoder: SymphoniaDecoderLike | null - private resumeInput: ((error?: Error | null) => void) | null + private readonly codecRegistryHint: string | null + private flushCallback: TransformCallback | null + private inputClosed: boolean private isFinished: boolean private _aborted: boolean - private _loopScheduled: boolean private _isDecoding: boolean private _timeoutId: ReturnType | null private _immediateId: ReturnType | null - private readonly _onResume: () => void - constructor( - options: { - highWaterMark?: number - objectMode?: boolean - } = {} - ) { + constructor(options: SymphoniaDecoderStreamOptions = {}) { + const { codecRegistryHint, ...streamOptions } = options + super({ - ...options, - highWaterMark: AUDIO_CONFIG.highWaterMark, + ...streamOptions, + highWaterMark: options.highWaterMark ?? AUDIO_CONFIG.highWaterMark, objectMode: false }) this.decoder = new SymphoniaDecoder() as SymphoniaDecoderLike - this.resumeInput = null + this.codecRegistryHint = codecRegistryHint ?? null + this.flushCallback = null + this.inputClosed = false this.isFinished = false this._aborted = false - this._loopScheduled = false this._isDecoding = false this._timeoutId = null this._immediateId = null - - this._onResume = () => { - if (!this.isFinished && !this._aborted && this.decoder) { - this._scheduleDecode() - } - } - - this.on('resume', this._onResume) } abort(): void { @@ -898,7 +901,6 @@ class SymphoniaDecoderStream extends Transform { clearImmediate(this._immediateId) this._immediateId = null } - this._loopScheduled = false } _isDecoderValid(): boolean { @@ -915,132 +917,119 @@ class SymphoniaDecoderStream extends Transform { return } - this.decoder.push(chunk) - this._scheduleDecode() - - const bufferedBytes = this.decoder?.bufferedBytes ?? 0 - if (bufferedBytes > BUFFER_THRESHOLDS.maxCompressed) { - this.resumeInput = callback - } else { + try { + this.decoder.push(chunk) + if (!this.decoder.isProbed) { + this.decoder.initialize(this.codecRegistryHint) + } + this._scheduleDecode() callback() + } catch (err) { + callback(err as Error) } } - _scheduleDecode(): void { - if ( - this._loopScheduled || - this._isDecoding || - !this._isDecoderValid() || - this.readableFlowing === false - ) - return + override _read(_size: number): void { + super._read(_size) - if (this.readableLength >= this.readableHighWaterMark) { - this._loopScheduled = true + if (this._isDecoderValid()) { + this._scheduleDecode() + } + } + + _scheduleDecode(delayMs = 0): void { + if (this._immediateId || this._timeoutId || !this._isDecoderValid()) return + + if (delayMs > 0) { this._timeoutId = setTimeout(() => { this._timeoutId = null - this._loopScheduled = false - if (this._isDecoderValid()) this._scheduleDecode() - }, AUDIO_CONSTANTS.decodeIntervalMs) + this._decodeLoop() + }, delayMs) return } - this._loopScheduled = true - - this._timeoutId = setTimeout(() => { - this._timeoutId = null - this._loopScheduled = false - if (this._isDecoderValid()) this._decodeLoop() - }, AUDIO_CONSTANTS.decodeIntervalMs) + this._immediateId = setImmediate(() => { + this._immediateId = null + this._decodeLoop() + }) } - async _decodeLoop(): Promise { - if (!this._isDecoderValid() || (this.readableFlowing as boolean) === false) + _decodeLoop(): void { + if (this._isDecoding || !this._isDecoderValid()) return + + if (!this.decoder?.isProbed) { + try { + if (!this.decoder?.initialize(this.codecRegistryHint)) { + if (this.inputClosed) this._finishDecode() + return + } + } catch (err) { + this._failDecode(err) + return + } + } + + if (this.readableLength >= this.readableHighWaterMark) { + this._scheduleDecode(AUDIO_CONSTANTS.decodeIntervalMs) return + } + this._isDecoding = true try { - let hasMoreData = true + let decodeCount = 0 while ( - hasMoreData && + decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && this._isDecoderValid() && - (this.readableFlowing as boolean) !== false && this.readableLength < this.readableHighWaterMark ) { - hasMoreData = this._processAudio() + const result = this.decoder?.decode() + if (!result) { + if (this.inputClosed) this._finishDecode() + return + } - if (hasMoreData && this._isDecoderValid()) { - await new Promise((resolve) => { - this._immediateId = setImmediate(() => { - this._immediateId = null - resolve() - }) - }) + decodeCount++ + if (result.samples.length === 0) continue + if (!this.push(result.samples)) { + this._scheduleDecode(AUDIO_CONSTANTS.decodeIntervalMs) + return } } + + this._scheduleDecode() } catch (err) { - if (!this._aborted) this.emit('error', err) + this._failDecode(err) } finally { this._isDecoding = false } - - const bufferedBytes = this.decoder?.bufferedBytes ?? 0 - if ( - bufferedBytes > 0 && - this._isDecoderValid() && - (this.readableFlowing as boolean) !== false && - this.readableLength < this.readableHighWaterMark - ) { - this._scheduleDecode() - } } - _processAudio(): boolean { - if (!this._isDecoderValid()) return false - if (this.readableLength >= this.readableHighWaterMark) return true - - if (!this.decoder?.isProbed) { - try { - if (!this.decoder?.initialize()) return false - } catch (err) { - throw new Error(`Symphonia init failed: ${(err as Error).message}`) - } - } - - let decodeCount = 0 - let hasOutput = false + _finishDecode(): void { + const callback = this.flushCallback + this.flushCallback = null + this.isFinished = true + this._cleanup() + callback?.() + } - while ( - decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && - this._isDecoderValid() && - this.readableLength < this.readableHighWaterMark - ) { - const result = this.decoder?.decode() - if (!result) break - - const canPush = this.push(result.samples) - hasOutput = true - decodeCount++ - - if (this.resumeInput) { - const afterBytes = this.decoder?.bufferedBytes ?? 0 - if (afterBytes < BUFFER_THRESHOLDS.minCompressed) { - const cb = this.resumeInput - this.resumeInput = null - cb() - } - } + _failDecode(err: unknown): void { + const callback = this.flushCallback + this.flushCallback = null + this.isFinished = true + this._cleanup() - if (!canPush) break + const error = + err instanceof Error ? err : new Error(`Symphonia decode failed: ${err}`) + if (callback) { + callback(error) + } else { + this.emit('error', error) } - - const remainingBytes = this.decoder?.bufferedBytes ?? 0 - return hasOutput || remainingBytes > 0 } override _flush(callback: TransformCallback): void { - this.isFinished = true this._cancelTimers() if (this._aborted || !this.decoder) { @@ -1051,18 +1040,24 @@ class SymphoniaDecoderStream extends Transform { try { this.decoder.closeInput() - - let count = 0 - while (count < 1000) { - const result = this.decoder?.decode() - if (!result) break - this.push(result.samples) - count++ + this.inputClosed = true + this.flushCallback = callback + + if (!this.decoder.initialize(this.codecRegistryHint)) { + if ((this.decoder.bufferedBytes ?? 0) === 0) { + this._cleanup() + callback() + return + } + throw new Error('Symphonia init failed: not enough input data') } - } catch {} - this._cleanup() - callback() + this._decodeLoop() + } catch (err) { + this.flushCallback = null + this._cleanup() + callback(err as Error) + } } override _destroy( @@ -1073,10 +1068,10 @@ class SymphoniaDecoderStream extends Transform { this.isFinished = true this._cancelTimers() - if (this.resumeInput) { - const cb = this.resumeInput - this.resumeInput = null - cb() + if (this.flushCallback) { + const cb = this.flushCallback + this.flushCallback = null + cb(err) } this._cleanup() @@ -1085,20 +1080,8 @@ class SymphoniaDecoderStream extends Transform { _cleanup(): void { this._cancelTimers() - this.removeListener('resume', this._onResume) - - if (this.resumeInput) { - const cb = this.resumeInput - this.resumeInput = null - try { - cb() - } catch {} - } if (this.decoder) { - try { - this.decoder.flush() - } catch {} try { this.decoder.free() } catch {} @@ -2452,7 +2435,7 @@ class StreamAudioResource extends BaseAudioResource { case SupportedFormats.FLAC: case SupportedFormats.OGG_VORBIS: case SupportedFormats.WAV: - return this._createSymphoniaPipeline(stream) + return this._createSymphoniaPipeline(stream, type) case SupportedFormats.OPUS: return this._createOpusPipeline(stream, type) @@ -2501,7 +2484,9 @@ class StreamAudioResource extends BaseAudioResource { streams.push(demuxer) if (lowerType.includes('mp3') || lowerType.includes('mpeg')) { - const decoder = new SymphoniaDecoderStream() + const decoder = new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(lowerType) + }) streams.push(decoder) this.pipes?.push(...streams.slice(1)) @@ -2549,8 +2534,10 @@ class StreamAudioResource extends BaseAudioResource { return decoder } - _createSymphoniaPipeline(stream: Readable): Transform { - const decoder = new SymphoniaDecoderStream() + _createSymphoniaPipeline(stream: Readable, type: string): Transform { + const decoder = new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(type) + }) this.pipes?.push(decoder) pipeline(stream, decoder, (err: Error | null): void => { @@ -2782,9 +2769,8 @@ class StreamAudioResource extends BaseAudioResource { type: 's16le', volume, enableAGC, - lookaheadMs: this.nodelink?.options?.playback.audio?.lookaheadMs, - gateThresholdLUFS: - this.nodelink?.options?.playback.audio?.gateThresholdLUFS + lookaheadMs: this.nodelink?.options?.audio?.lookaheadMs, + gateThresholdLUFS: this.nodelink?.options?.audio?.gateThresholdLUFS }) pipeline(pcmStream, volumeTransformer, (err: Error | null): void => { @@ -3013,7 +2999,11 @@ export const createPCMStream = ( streams.push(new MPEGTSDemuxer()) if (lowerType.includes('mp3') || lowerType.includes('mpeg')) { - streams.push(new SymphoniaDecoderStream()) + streams.push( + new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(lowerType) + }) + ) break } } else if (_isMp4Format(lowerType)) streams.push(new MP4ToAACStream()) @@ -3040,7 +3030,11 @@ export const createPCMStream = ( case SupportedFormats.FLAC: case SupportedFormats.OGG_VORBIS: case SupportedFormats.WAV: { - streams.push(new SymphoniaDecoderStream()) + streams.push( + new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(type) + }) + ) break } diff --git a/src/typings/playback/streamProcessor.types.ts b/src/typings/playback/streamProcessor.types.ts index 64e7e485..12b659b9 100644 --- a/src/typings/playback/streamProcessor.types.ts +++ b/src/typings/playback/streamProcessor.types.ts @@ -239,8 +239,8 @@ export interface SymphoniaDecoderLike { isProbed: boolean /** Pushes new data to the decoder buffer. */ push(chunk: Buffer): void - /** Initializes the decoder after probing. */ - initialize(): boolean + /** Initializes the decoder once enough compressed input is buffered. */ + initialize(codecRegistryHint?: string | null): boolean /** Decodes the next available frame. */ decode(): DecodeResult | null /** Closes the input handle. */ @@ -414,6 +414,8 @@ export interface FadeTransformerOptions { export interface SymphoniaDecoderStreamOptions extends TransformOptions { /** Desired resampling quality. */ resamplingQuality?: string + /** Optional file-extension hint for Symphonia probing. */ + codecRegistryHint?: string | null /** Stream high water mark in bytes. */ highWaterMark?: number } From b94777a2329aae7dc7dad363b69edf1588327ebc Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Wed, 20 May 2026 18:22:37 -0300 Subject: [PATCH 29/58] improve: stream lifecycle and stream management; fix: bandcamp 403 error Pass abort signal into DASH segment fetches so stop() cancels in-flight downloads. Refactor DASHHandler backpressure from data-event drain detection to _read()-driven read-waiters. Replace .pipe() with pipeline() for proper error propagation and cleanup. Remove removeAllListeners() from streamProcessor and AudioMixer in favor of owned listener cleanup. Delete unreachable duplicate writableNeedDrain early-return in YouTube recovery. Also fixed an 403 error happening on bandcamp (by adding an Referer, and fixed getTrackUrl capturing an trailing JSON.) --- dist/package.json | 2 +- dist/src/playback/dash/DASHHandler.js | 43 +++++++++++++---- dist/src/playback/processing/AudioMixer.js | 35 ++++++++++---- .../playback/processing/streamProcessor.js | 1 - dist/src/sources/bandcamp.js | 22 +++++++-- dist/src/sources/flowery.js | 19 ++++---- dist/src/sources/google-tts.js | 19 ++++---- dist/src/sources/pipertts.js | 19 ++++---- dist/src/sources/reddit.js | 12 ++++- dist/src/sources/youtube/YouTube.js | 4 -- package.json | 2 +- src/playback/dash/DASHHandler.ts | 46 +++++++++++++++---- src/playback/processing/AudioMixer.ts | 45 ++++++++++++++---- src/playback/processing/streamProcessor.ts | 2 - src/sources/bandcamp.ts | 29 ++++++++++-- src/sources/flowery.ts | 29 ++++++++---- src/sources/google-tts.ts | 29 ++++++++---- src/sources/pipertts.ts | 29 ++++++++---- src/sources/reddit.ts | 15 +++++- src/sources/youtube/YouTube.ts | 9 ---- 20 files changed, 289 insertions(+), 122 deletions(-) diff --git a/dist/package.json b/dist/package.json index 1ec8da01..9b07001b 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260517.1", + "version": "3.8.1-dev.20260520.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/dist/src/playback/dash/DASHHandler.js b/dist/src/playback/dash/DASHHandler.js index 8d85fc71..308e7e27 100644 --- a/dist/src/playback/dash/DASHHandler.js +++ b/dist/src/playback/dash/DASHHandler.js @@ -13,6 +13,7 @@ export class DASHHandler extends Transform { options; stopped = false; abortController = null; + readWaiters = []; constructor(mpdUrl, options = {}) { super(); this.mpdUrl = mpdUrl; @@ -21,6 +22,19 @@ export class DASHHandler extends Transform { _transform(_chunk, _encoding, callback) { callback(); } + _read(size) { + super._read(size); + this._resolveReadWaiters(); + } + _destroy(err, callback) { + this.stop(); + callback(err); + } + _resolveReadWaiters() { + const waiters = this.readWaiters.splice(0); + for (const resolve of waiters) + resolve(); + } /** * Fetch the MPD, parse representations, select AACLC, and stream segments. * Uses a bounded prefetch queue with backpressure to keep memory minimal. @@ -66,9 +80,7 @@ export class DASHHandler extends Transform { this.destroy(err); return; } - if (!this.push(initRes.body)) { - await this._waitForDrain(); - } + await this._pushChunk(initRes.body); const totalSegments = this._countSegments(selected); const segmentUrlGen = this._generateSegmentUrls(selected); const segmentDuration = this._calcSegmentDuration(selected); @@ -89,7 +101,8 @@ export class DASHHandler extends Transform { const fetchSegment = async (url) => { try { const res = await fetch(url, { - headers: this.options.headers + headers: this.options.headers, + signal }); if (!res.ok || !res.body) { logger('error', 'DASHHandler', `Segment fetch failed: HTTP ${res.status}`); @@ -125,7 +138,7 @@ export class DASHHandler extends Transform { break; const data = await pending; if (data && !this.stopped && !signal.aborted) { - this.push(data); + await this._pushChunk(data); } if (fetchIndex < totalSegments) { const url = segmentUrlGen.next().value; @@ -153,6 +166,7 @@ export class DASHHandler extends Transform { stop() { this.stopped = true; this.abortController?.abort(); + this._resolveReadWaiters(); } _sleepOrStop(ms) { return new Promise((resolve) => { @@ -171,17 +185,26 @@ export class DASHHandler extends Transform { } async _waitForBuffer() { while (this.readableLength > MAX_BUFFERED && !this.stopped) { - await this._waitForDrain(); + await this._waitForRead(); } } - _waitForDrain() { + _pushChunk(chunk) { + if (this.stopped || this.destroyed) + return Promise.resolve(); + if (this.push(chunk)) + return Promise.resolve(); + return this._waitForRead(); + } + _waitForRead() { return new Promise((resolve) => { if (this.readableLength <= MAX_BUFFERED) { resolve(); return; } const cleanup = () => { - this.off('data', onData); + const index = this.readWaiters.indexOf(onRead); + if (index !== -1) + this.readWaiters.splice(index, 1); this.off('end', onEnd); this.off('close', onEnd); }; @@ -189,13 +212,13 @@ export class DASHHandler extends Transform { cleanup(); resolve(); }; - const onData = () => { + const onRead = () => { if (this.readableLength <= MAX_BUFFERED) { cleanup(); resolve(); } }; - this.on('data', onData); + this.readWaiters.push(onRead); this.once('end', onEnd); this.once('close', onEnd); }); diff --git a/dist/src/playback/processing/AudioMixer.js b/dist/src/playback/processing/AudioMixer.js index a3aa8339..b68638d6 100644 --- a/dist/src/playback/processing/AudioMixer.js +++ b/dist/src/playback/processing/AudioMixer.js @@ -15,6 +15,7 @@ export class AudioMixer extends Readable { defaultVolume; autoCleanup; enabled; + layerListeners; /** * Creates a new AudioMixer. * @param config - Mixer configuration. @@ -26,6 +27,7 @@ export class AudioMixer extends Readable { this.defaultVolume = config.defaultVolume || 0.8; this.autoCleanup = config.autoCleanup !== false; this.enabled = config.enabled !== false; + this.layerListeners = new Map(); } _read(_size) { const targetSize = FRAME_SIZE; @@ -118,7 +120,7 @@ export class AudioMixer extends Readable { paused: false }; this.mixLayers.set(id, layer); - stream.on('data', (chunk) => { + const onData = (chunk) => { if (!layer.active) return; if (layer.ringBuffer.length > LAYER_BUFFER_SIZE * 0.8) { @@ -142,17 +144,27 @@ export class AudioMixer extends Readable { layer.receivedBytes += data.length; layer.ringBuffer.write(data); } - }); - stream.once('end', () => { + }; + const onEnd = () => { layer.finishedFeeding = true; - }); - stream.once('close', () => { + }; + const onClose = () => { layer.finishedFeeding = true; - }); - stream.once('error', (error) => { + }; + const onError = (error) => { this.emit('mixError', { id, error }); this.removeLayer(id, 'ERROR'); + }; + this.layerListeners.set(id, { + data: onData, + end: onEnd, + close: onClose, + error: onError }); + stream.on('data', onData); + stream.once('end', onEnd); + stream.once('close', onClose); + stream.once('error', onError); this.emit('mixStarted', { id, track, volume: layer.volume }); return id; } @@ -204,8 +216,15 @@ export class AudioMixer extends Readable { if (!layer) return false; layer.active = false; + const listeners = this.layerListeners.get(id); + if (listeners) { + layer.stream.off('data', listeners.data); + layer.stream.off('end', listeners.end); + layer.stream.off('close', listeners.close); + layer.stream.off('error', listeners.error); + this.layerListeners.delete(id); + } if (layer.stream && !layer.stream.destroyed) { - layer.stream.removeAllListeners('data'); layer.stream.destroy(); } layer.ringBuffer.dispose(); diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index f12e6177..2380c3d3 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -432,7 +432,6 @@ class BaseAudioResource { pipe.abort?.(); pipe.unpipe?.(); pipe.destroy?.(); - pipe.removeAllListeners?.(); } this.pipes.length = 0; this.stream = null; diff --git a/dist/src/sources/bandcamp.js b/dist/src/sources/bandcamp.js index 60ff91f6..52066e0f 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream'; +import { PassThrough, pipeline } from 'node:stream'; import { encodeTrack, logger, makeRequest } from "../utils.js"; const BANDCAMP_BASE_URL = 'https://bandcamp.com'; const BANDCAMP_TRACK_PATTERN = /^https?:\/\/([^/]+)\.bandcamp\.com\/(track|album)\/([^/?]+)/; @@ -209,7 +209,8 @@ export default class BandcampSource { } }; } - const streamUrlMatch = page.match(STREAM_URL_REGEX); + const decodedPage = this.decodeHtmlEntities(page); + const streamUrlMatch = decodedPage.match(STREAM_URL_REGEX); if (!streamUrlMatch) { return { loadType: 'error', @@ -221,7 +222,7 @@ export default class BandcampSource { }; } return { - url: this.decodeHtmlEntities(streamUrlMatch[0]), + url: streamUrlMatch[0], protocol: 'https', format: 'mp3' }; @@ -251,7 +252,10 @@ export default class BandcampSource { try { const response = await makeRequest(url, { method: 'GET', - streamOnly: true + streamOnly: true, + headers: { + Referer: decodedTrack.uri + } }); if (response.error || response.statusCode !== 200 || !response.stream) { return { @@ -265,7 +269,15 @@ export default class BandcampSource { }; } const stream = new PassThrough(); - response.stream.pipe(stream); + stream.once('close', () => { + ; + response.stream.destroy?.(); + }); + pipeline(response.stream, stream, (error) => { + if (error && error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger('error', 'Sources', `BandCamp stream error: ${error.message}`); + } + }); return { stream }; } catch (error) { diff --git a/dist/src/sources/flowery.js b/dist/src/sources/flowery.js index ced14ecf..26e394d0 100644 --- a/dist/src/sources/flowery.js +++ b/dist/src/sources/flowery.js @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream'; +import { PassThrough, pipeline } from 'node:stream'; import { URL } from 'node:url'; import { encodeTrack, logger, makeRequest } from "../utils.js"; /** @@ -525,15 +525,18 @@ export default class FlowerySource { throw new Error(`Flowery TTS returned status ${response.statusCode}`); } const stream = new PassThrough(); - response.stream.pipe(stream); - response.stream.on('end', () => { - stream.emit('finishBuffering'); + stream.once('close', () => { + ; + response.stream.destroy?.(); }); - response.stream.on('error', (error) => { - logger('error', 'Sources', `Flowery TTS stream error: ${error.message}`); - if (!stream.destroyed) { - stream.destroy(error); + pipeline(response.stream, stream, (error) => { + if (error) { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger('error', 'Sources', `Flowery TTS stream error: ${error.message}`); + } + return; } + stream.emit('finishBuffering'); }); return { stream }; } diff --git a/dist/src/sources/google-tts.js b/dist/src/sources/google-tts.js index dd1d4ff9..5a409e06 100644 --- a/dist/src/sources/google-tts.js +++ b/dist/src/sources/google-tts.js @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream'; +import { PassThrough, pipeline } from 'node:stream'; import { encodeTrack, logger, makeRequest } from "../utils.js"; /** * Google TTS source implementation. @@ -246,15 +246,18 @@ export default class GoogleTTSSource { throw new Error(`Google TTS returned status ${response.statusCode}`); } const stream = new PassThrough(); - response.stream.pipe(stream); - response.stream.on('end', () => { - stream.emit('finishBuffering'); + stream.once('close', () => { + ; + response.stream.destroy?.(); }); - response.stream.on('error', (error) => { - logger('error', 'Sources', `Google TTS stream error: ${error.message}`); - if (!stream.destroyed) { - stream.destroy(error); + pipeline(response.stream, stream, (error) => { + if (error) { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger('error', 'Sources', `Google TTS stream error: ${error.message}`); + } + return; } + stream.emit('finishBuffering'); }); return { stream }; } diff --git a/dist/src/sources/pipertts.js b/dist/src/sources/pipertts.js index 7a6b6448..0d868d08 100644 --- a/dist/src/sources/pipertts.js +++ b/dist/src/sources/pipertts.js @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream'; +import { PassThrough, pipeline } from 'node:stream'; import { encodeTrack, logger, makeRequest } from "../utils.js"; /** * Piper TTS source implementation. @@ -259,15 +259,18 @@ export default class PiperSource { throw new Error(`Piper TTS returned status ${response.statusCode}`); } const stream = new PassThrough(); - response.stream.pipe(stream); - response.stream.on('end', () => { - stream.emit('finishBuffering'); + stream.once('close', () => { + ; + response.stream.destroy?.(); }); - response.stream.on('error', (error) => { - logger('error', 'Sources', `Piper TTS stream error: ${error.message}`); - if (!stream.destroyed) { - stream.destroy(error); + pipeline(response.stream, stream, (error) => { + if (error) { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger('error', 'Sources', `Piper TTS stream error: ${error.message}`); + } + return; } + stream.emit('finishBuffering'); }); return { stream, type: 'wav' }; } diff --git a/dist/src/sources/reddit.js b/dist/src/sources/reddit.js index 5c3f9663..58e117f7 100644 --- a/dist/src/sources/reddit.js +++ b/dist/src/sources/reddit.js @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream'; +import { PassThrough, pipeline } from 'node:stream'; import { encodeTrack, logger, makeRequest } from "../utils.js"; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'; const REDDIT_BASE = 'https://www.reddit.com'; @@ -180,7 +180,15 @@ export default class RedditSource { throw new Error(`Reddit returned status ${response.statusCode}`); } const stream = new PassThrough(); - response.stream.pipe(stream); + stream.once('close', () => { + ; + response.stream.destroy?.(); + }); + pipeline(response.stream, stream, (error) => { + if (error && error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger('error', 'Sources', `Reddit stream error: ${error.message}`); + } + }); const type = url.endsWith('.mp3') ? 'mp3' : 'mp4'; return { stream, type }; } diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index a68d6c4a..ad7e0d57 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -1575,10 +1575,6 @@ export default class YouTubeSource { cleanup(); return; } - if (isAborted && stream.writableNeedDrain) { - logger('debug', 'YouTube', `Stream is paused/backed up, skipping recovery (cause: ${causeError?.message}). Player will recover on resume.`); - return; - } if (isAborted && stream.writableNeedDrain) { logger('debug', 'YouTube', `Stream is paused/backed up, waiting for drain before recovery (cause: ${causeError?.message})`); await new Promise((resolve) => { diff --git a/package.json b/package.json index 73f0377c..1ee05a01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260517.1", + "version": "3.8.1-dev.20260520.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/src/playback/dash/DASHHandler.ts b/src/playback/dash/DASHHandler.ts index 346da3e6..7542448a 100644 --- a/src/playback/dash/DASHHandler.ts +++ b/src/playback/dash/DASHHandler.ts @@ -19,6 +19,7 @@ export class DASHHandler extends Transform { private readonly options: DASHHandlerOptions private stopped = false private abortController: AbortController | null = null + private readonly readWaiters: Array<() => void> = [] constructor(mpdUrl: string, options: DASHHandlerOptions = {}) { super() @@ -34,6 +35,24 @@ export class DASHHandler extends Transform { callback() } + override _read(size: number): void { + super._read(size) + this._resolveReadWaiters() + } + + override _destroy( + err: Error | null, + callback: (error?: Error | null) => void + ): void { + this.stop() + callback(err) + } + + private _resolveReadWaiters(): void { + const waiters = this.readWaiters.splice(0) + for (const resolve of waiters) resolve() + } + /** * Fetch the MPD, parse representations, select AACLC, and stream segments. * Uses a bounded prefetch queue with backpressure to keep memory minimal. @@ -99,9 +118,7 @@ export class DASHHandler extends Transform { return } - if (!this.push(initRes.body)) { - await this._waitForDrain() - } + await this._pushChunk(initRes.body) const totalSegments = this._countSegments(selected) const segmentUrlGen = this._generateSegmentUrls(selected) @@ -133,7 +150,8 @@ export class DASHHandler extends Transform { const fetchSegment = async (url: string): Promise => { try { const res = await fetch(url, { - headers: this.options.headers as Record + headers: this.options.headers as Record, + signal }) if (!res.ok || !res.body) { logger( @@ -173,7 +191,7 @@ export class DASHHandler extends Transform { const data = await pending if (data && !this.stopped && !signal.aborted) { - this.push(data) + await this._pushChunk(data) } if (fetchIndex < totalSegments) { @@ -212,6 +230,7 @@ export class DASHHandler extends Transform { stop(): void { this.stopped = true this.abortController?.abort() + this._resolveReadWaiters() } private _sleepOrStop(ms: number): Promise { @@ -232,18 +251,25 @@ export class DASHHandler extends Transform { private async _waitForBuffer(): Promise { while (this.readableLength > MAX_BUFFERED && !this.stopped) { - await this._waitForDrain() + await this._waitForRead() } } - private _waitForDrain(): Promise { + private _pushChunk(chunk: Buffer): Promise { + if (this.stopped || this.destroyed) return Promise.resolve() + if (this.push(chunk)) return Promise.resolve() + return this._waitForRead() + } + + private _waitForRead(): Promise { return new Promise((resolve) => { if (this.readableLength <= MAX_BUFFERED) { resolve() return } const cleanup = () => { - this.off('data', onData) + const index = this.readWaiters.indexOf(onRead) + if (index !== -1) this.readWaiters.splice(index, 1) this.off('end', onEnd) this.off('close', onEnd) } @@ -251,13 +277,13 @@ export class DASHHandler extends Transform { cleanup() resolve() } - const onData = () => { + const onRead = () => { if (this.readableLength <= MAX_BUFFERED) { cleanup() resolve() } } - this.on('data', onData) + this.readWaiters.push(onRead) this.once('end', onEnd) this.once('close', onEnd) }) diff --git a/src/playback/processing/AudioMixer.ts b/src/playback/processing/AudioMixer.ts index d0fdfb3d..1234c0a8 100644 --- a/src/playback/processing/AudioMixer.ts +++ b/src/playback/processing/AudioMixer.ts @@ -14,6 +14,13 @@ interface ExtendedVoiceStream extends VoiceAudioStream { destroyed: boolean } +interface AudioMixerLayerListeners { + data: (chunk: Buffer) => void + end: () => void + close: () => void + error: (error: Error) => void +} + const LAYER_BUFFER_SIZE = 1024 * 1024 const EMPTY_BUFFER = Buffer.alloc(0) const FRAME_SIZE = 3840 @@ -29,6 +36,7 @@ export class AudioMixer extends Readable { private defaultVolume: number public autoCleanup: boolean public enabled: boolean + private readonly layerListeners: Map /** * Creates a new AudioMixer. @@ -42,6 +50,7 @@ export class AudioMixer extends Readable { this.defaultVolume = config.defaultVolume || 0.8 this.autoCleanup = config.autoCleanup !== false this.enabled = config.enabled !== false + this.layerListeners = new Map() } override _read(_size: number): void { @@ -166,7 +175,7 @@ export class AudioMixer extends Readable { this.mixLayers.set(id, layer) - stream.on('data', (chunk: Buffer) => { + const onData = (chunk: Buffer): void => { if (!layer.active) return if (layer.ringBuffer.length > LAYER_BUFFER_SIZE * 0.8) { @@ -193,21 +202,33 @@ export class AudioMixer extends Readable { layer.receivedBytes += data.length layer.ringBuffer.write(data) } - }) + } - stream.once('end', () => { + const onEnd = (): void => { layer.finishedFeeding = true - }) + } - stream.once('close', () => { + const onClose = (): void => { layer.finishedFeeding = true - }) + } - stream.once('error', (error: Error) => { + const onError = (error: Error): void => { this.emit('mixError', { id, error }) this.removeLayer(id, 'ERROR') + } + + this.layerListeners.set(id, { + data: onData, + end: onEnd, + close: onClose, + error: onError }) + stream.on('data', onData) + stream.once('end', onEnd) + stream.once('close', onClose) + stream.once('error', onError) + this.emit('mixStarted', { id, track, volume: layer.volume }) return id @@ -268,8 +289,16 @@ export class AudioMixer extends Readable { if (!layer) return false layer.active = false + const listeners = this.layerListeners.get(id) + if (listeners) { + layer.stream.off('data', listeners.data) + layer.stream.off('end', listeners.end) + layer.stream.off('close', listeners.close) + layer.stream.off('error', listeners.error) + this.layerListeners.delete(id) + } + if (layer.stream && !(layer.stream as ExtendedVoiceStream).destroyed) { - layer.stream.removeAllListeners('data') layer.stream.destroy() } layer.ringBuffer.dispose() diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index ddf66d9a..f46c9fa0 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -682,12 +682,10 @@ class BaseAudioResource { abort?: () => void unpipe?: () => void destroy?: () => void - removeAllListeners?: () => void } pipe.abort?.() pipe.unpipe?.() pipe.destroy?.() - pipe.removeAllListeners?.() } this.pipes.length = 0 diff --git a/src/sources/bandcamp.ts b/src/sources/bandcamp.ts index d8866b8c..3a957eeb 100644 --- a/src/sources/bandcamp.ts +++ b/src/sources/bandcamp.ts @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, pipeline } from 'node:stream' import type { SourceResult, TrackInfo, @@ -516,7 +516,8 @@ export default class BandcampSource { } } - const streamUrlMatch = page.match(STREAM_URL_REGEX) + const decodedPage = this.decodeHtmlEntities(page) + const streamUrlMatch = decodedPage.match(STREAM_URL_REGEX) if (!streamUrlMatch) { return { @@ -530,7 +531,7 @@ export default class BandcampSource { } return { - url: this.decodeHtmlEntities(streamUrlMatch[0]), + url: streamUrlMatch[0], protocol: 'https', format: 'mp3' } @@ -569,7 +570,10 @@ export default class BandcampSource { try { const response = await makeRequest(url, { method: 'GET', - streamOnly: true + streamOnly: true, + headers: { + Referer: decodedTrack.uri + } }) if (response.error || response.statusCode !== 200 || !response.stream) { @@ -586,7 +590,22 @@ export default class BandcampSource { } const stream = new PassThrough() - response.stream.pipe(stream) + stream.once('close', () => { + ;(response.stream as { destroy?: () => void }).destroy?.() + }) + pipeline( + response.stream, + stream, + (error: NodeJS.ErrnoException | null) => { + if (error && error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger( + 'error', + 'Sources', + `BandCamp stream error: ${error.message}` + ) + } + } + ) return { stream } } catch (error) { diff --git a/src/sources/flowery.ts b/src/sources/flowery.ts index 42147c83..38cd15e6 100644 --- a/src/sources/flowery.ts +++ b/src/sources/flowery.ts @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, pipeline } from 'node:stream' import { URL } from 'node:url' import type { SourceResult, @@ -899,18 +899,27 @@ export default class FlowerySource { } const stream = new PassThrough() - response.stream.pipe(stream) - - response.stream.on('end', () => { - stream.emit('finishBuffering') + stream.once('close', () => { + ;(response.stream as { destroy?: () => void }).destroy?.() }) - response.stream.on('error', (error: Error) => { - logger('error', 'Sources', `Flowery TTS stream error: ${error.message}`) - if (!stream.destroyed) { - stream.destroy(error) + pipeline( + response.stream, + stream, + (error: NodeJS.ErrnoException | null) => { + if (error) { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger( + 'error', + 'Sources', + `Flowery TTS stream error: ${error.message}` + ) + } + return + } + stream.emit('finishBuffering') } - }) + ) return { stream } } catch (error) { diff --git a/src/sources/google-tts.ts b/src/sources/google-tts.ts index 8400dd58..9dc93dbc 100644 --- a/src/sources/google-tts.ts +++ b/src/sources/google-tts.ts @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, pipeline } from 'node:stream' import type { SourceResult, TrackInfo, @@ -412,18 +412,27 @@ export default class GoogleTTSSource { } const stream = new PassThrough() - response.stream.pipe(stream) - - response.stream.on('end', () => { - stream.emit('finishBuffering') + stream.once('close', () => { + ;(response.stream as { destroy?: () => void }).destroy?.() }) - response.stream.on('error', (error: Error) => { - logger('error', 'Sources', `Google TTS stream error: ${error.message}`) - if (!stream.destroyed) { - stream.destroy(error) + pipeline( + response.stream, + stream, + (error: NodeJS.ErrnoException | null) => { + if (error) { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger( + 'error', + 'Sources', + `Google TTS stream error: ${error.message}` + ) + } + return + } + stream.emit('finishBuffering') } - }) + ) return { stream } } catch (error) { diff --git a/src/sources/pipertts.ts b/src/sources/pipertts.ts index 6b685617..f182b957 100644 --- a/src/sources/pipertts.ts +++ b/src/sources/pipertts.ts @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, pipeline } from 'node:stream' import type { SourceResult, TrackInfo, @@ -456,18 +456,27 @@ export default class PiperSource { } const stream = new PassThrough() - response.stream.pipe(stream) - - response.stream.on('end', () => { - stream.emit('finishBuffering') + stream.once('close', () => { + ;(response.stream as { destroy?: () => void }).destroy?.() }) - response.stream.on('error', (error: Error) => { - logger('error', 'Sources', `Piper TTS stream error: ${error.message}`) - if (!stream.destroyed) { - stream.destroy(error) + pipeline( + response.stream, + stream, + (error: NodeJS.ErrnoException | null) => { + if (error) { + if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger( + 'error', + 'Sources', + `Piper TTS stream error: ${error.message}` + ) + } + return + } + stream.emit('finishBuffering') } - }) + ) return { stream, type: 'wav' } } catch (error) { diff --git a/src/sources/reddit.ts b/src/sources/reddit.ts index 28a608cc..2517ca80 100644 --- a/src/sources/reddit.ts +++ b/src/sources/reddit.ts @@ -1,4 +1,4 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, pipeline } from 'node:stream' import type { SourceResult, TrackInfo, @@ -465,7 +465,18 @@ export default class RedditSource { } const stream = new PassThrough() - response.stream.pipe(stream) + stream.once('close', () => { + ;(response.stream as { destroy?: () => void }).destroy?.() + }) + pipeline( + response.stream, + stream, + (error: NodeJS.ErrnoException | null) => { + if (error && error.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + logger('error', 'Sources', `Reddit stream error: ${error.message}`) + } + } + ) const type = url.endsWith('.mp3') ? 'mp3' : 'mp4' return { stream, type } diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index 0d798b55..0764f23c 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -2363,15 +2363,6 @@ export default class YouTubeSource { return } - if (isAborted && stream.writableNeedDrain) { - logger( - 'debug', - 'YouTube', - `Stream is paused/backed up, skipping recovery (cause: ${causeError?.message}). Player will recover on resume.` - ) - return - } - if (isAborted && stream.writableNeedDrain) { logger( 'debug', From 6b286b8582a03d6d0ca6ab384172c79fbb8a7dda Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Thu, 21 May 2026 14:50:16 -0300 Subject: [PATCH 30/58] update: make useBunServer default to true This seems to be no longer experimental and can be used in prod, since bun got some major stability updates. Also fix a crash ocurring during startup. --- config.default.ts | 2 +- dist/config.default.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.default.ts b/config.default.ts index 204060a8..c4d9b060 100644 --- a/config.default.ts +++ b/config.default.ts @@ -5,7 +5,7 @@ export const config: NodelinkConfig = { host: '0.0.0.0', port: 3000, password: 'youshallnotpass', - useBunServer: false + useBunServer: true }, cluster: { diff --git a/dist/config.default.js b/dist/config.default.js index 3b64b89f..9b66eff7 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -3,7 +3,7 @@ export const config = { host: '0.0.0.0', port: 3000, password: 'youshallnotpass', - useBunServer: false + useBunServer: true }, cluster: { enabled: true, From 1162916539d0c07cb98136baab31d4e10fcf35c5 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sat, 23 May 2026 07:40:26 -0300 Subject: [PATCH 31/58] update: set mp4box.createFile argument to false https://github.com/gpac/mp4box.js/wiki/Memory-managment-in-MP4Box.js#the-createfile-constructor In short words, keeping this to true would just accumulate loads of ArrayBuffer in raw data, because the releaseUsedSamples only clears the samples and not the stored raw data. Should fix a memory leak hoppefully. --- dist/src/playback/processing/streamProcessor.js | 2 +- src/playback/processing/streamProcessor.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/src/playback/processing/streamProcessor.js b/dist/src/playback/processing/streamProcessor.js index 2380c3d3..3daffbd7 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -1187,7 +1187,7 @@ class MP4ToAACStream extends Transform { } this._initPromise = (async () => { const mp4Box = await getMP4Box(); - this.mp4boxFile = mp4Box.createFile(true); + this.mp4boxFile = mp4Box.createFile(false); this._setupMP4BoxHandlers(); })(); await this._initPromise; diff --git a/src/playback/processing/streamProcessor.ts b/src/playback/processing/streamProcessor.ts index f46c9fa0..c85d7b96 100644 --- a/src/playback/processing/streamProcessor.ts +++ b/src/playback/processing/streamProcessor.ts @@ -1603,7 +1603,7 @@ class MP4ToAACStream extends Transform { this._initPromise = (async () => { const mp4Box = await getMP4Box() - this.mp4boxFile = mp4Box.createFile(true) as unknown as MP4BoxFile + this.mp4boxFile = mp4Box.createFile(false) as unknown as MP4BoxFile this._setupMP4BoxHandlers() })() From 88b5291e0f30b9f69629e389cbf73d6c461daea3 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sat, 23 May 2026 09:19:04 -0300 Subject: [PATCH 32/58] improve: give preference to flac in monochrome; fix: crash on flac seek Quboz quality is now 6, which defaults to the auto-adaptative flac in monochrome Now, qobuzQuality is also configurable in the configs FLAC does not support seeking (internally it does, but its too recourse-heavy to keep it on), so i have disabled it. --- config.default.ts | 3 ++- dist/config.default.js | 3 ++- dist/src/playback/player.js | 7 +++++++ dist/src/sources/monochrome.js | 10 ++++++---- src/playback/player.ts | 13 +++++++++++++ src/sources/monochrome.ts | 13 +++++++++---- src/typings/sources/monochrome.types.ts | 2 ++ 7 files changed, 41 insertions(+), 10 deletions(-) diff --git a/config.default.ts b/config.default.ts index c4d9b060..817ed535 100644 --- a/config.default.ts +++ b/config.default.ts @@ -583,7 +583,8 @@ export const config: NodelinkConfig = { enabled: true, instances: [], streamingInstances: [], - quality: 'HI_RES_LOSSLESS' + quality: 'HI_RES_LOSSLESS', + qobuzQuality: 6 }, pandora: { diff --git a/dist/config.default.js b/dist/config.default.js index 9b66eff7..ba1b27ff 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -552,7 +552,8 @@ export const config = { enabled: true, instances: [], streamingInstances: [], - quality: 'HI_RES_LOSSLESS' + quality: 'HI_RES_LOSSLESS', + qobuzQuality: 6 }, pandora: { enabled: true, diff --git a/dist/src/playback/player.js b/dist/src/playback/player.js index e5637417..d3591842 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -1123,6 +1123,13 @@ export class Player { return false; if (!this.track.info.isSeekable && !this.track.info.isStream) return false; + const streamFormat = typeof this.streamInfo?.format === 'string' + ? this.streamInfo.format.toLowerCase() + : ''; + if (streamFormat.includes('flac')) { + logger('warn', 'Player', `Seeking not supported for FLAC stream on guild ${this.guildId}`); + return false; + } const seekPosition = position ?? this._realPosition(); if (seekPosition === 0 && !this._isRecovering && diff --git a/dist/src/sources/monochrome.js b/dist/src/sources/monochrome.js index bf6c79c5..f6316d43 100644 --- a/dist/src/sources/monochrome.js +++ b/dist/src/sources/monochrome.js @@ -399,11 +399,13 @@ class MonochromeSource { async getTrackUrl(track) { const isVideo = track.uri.includes('/video/'); if (!isVideo && track.isrc) { - const qobuzUrl = await this.fetchQobuzProxyUrl(track.isrc); + const qobuzQuality = this.config.qobuzQuality ?? 6; + const qobuzUrl = await this.fetchQobuzProxyUrl(track.isrc, qobuzQuality); if (qobuzUrl) { return { url: qobuzUrl, - protocol: 'https' + protocol: 'https', + format: qobuzQuality > 5 ? 'audio/flac' : 'audio/mpeg' }; } } @@ -618,7 +620,7 @@ class MonochromeSource { * @returns Direct stream URL or null. * @private */ - async fetchQobuzProxyUrl(isrc) { + async fetchQobuzProxyUrl(isrc, quality = 6) { const pool = this.qobuzInstances.filter((i) => i.score > 0); if (pool.length === 0) { logger('warn', 'Monochrome', 'No Qobuz proxy instances available.'); @@ -664,7 +666,7 @@ class MonochromeSource { instance.score = Math.max(instance.score - 5, 0); continue; } - const downloadUrl = `${instance.url}/api/download-music?track_id=${trackId}&quality=5`; + const downloadUrl = `${instance.url}/api/download-music?track_id=${trackId}&quality=${quality}`; const { body: downloadBody, statusCode: downloadStatus } = await http1makeRequest(downloadUrl, { method: 'GET', timeout: 5000, diff --git a/src/playback/player.ts b/src/playback/player.ts index 253da1f5..a47b3628 100644 --- a/src/playback/player.ts +++ b/src/playback/player.ts @@ -1679,6 +1679,19 @@ export class Player { if (this.destroying || !this.track) return false if (!this.track.info.isSeekable && !this.track.info.isStream) return false + const streamFormat = + typeof this.streamInfo?.format === 'string' + ? this.streamInfo.format.toLowerCase() + : '' + if (streamFormat.includes('flac')) { + logger( + 'warn', + 'Player', + `Seeking not supported for FLAC stream on guild ${this.guildId}` + ) + return false + } + const seekPosition = position ?? this._realPosition() if ( diff --git a/src/sources/monochrome.ts b/src/sources/monochrome.ts index 65c1648d..a3d34f62 100644 --- a/src/sources/monochrome.ts +++ b/src/sources/monochrome.ts @@ -551,11 +551,13 @@ class MonochromeSource implements SourceInstance { const isVideo = track.uri.includes('/video/') if (!isVideo && track.isrc) { - const qobuzUrl = await this.fetchQobuzProxyUrl(track.isrc) + const qobuzQuality = this.config.qobuzQuality ?? 6 + const qobuzUrl = await this.fetchQobuzProxyUrl(track.isrc, qobuzQuality) if (qobuzUrl) { return { url: qobuzUrl, - protocol: 'https' + protocol: 'https', + format: qobuzQuality > 5 ? 'audio/flac' : 'audio/mpeg' } } } @@ -842,7 +844,10 @@ class MonochromeSource implements SourceInstance { * @returns Direct stream URL or null. * @private */ - private async fetchQobuzProxyUrl(isrc: string): Promise { + private async fetchQobuzProxyUrl( + isrc: string, + quality: number = 6 + ): Promise { const pool = this.qobuzInstances.filter((i) => i.score > 0) if (pool.length === 0) { logger('warn', 'Monochrome', 'No Qobuz proxy instances available.') @@ -900,7 +905,7 @@ class MonochromeSource implements SourceInstance { continue } - const downloadUrl = `${instance.url}/api/download-music?track_id=${trackId}&quality=5` + const downloadUrl = `${instance.url}/api/download-music?track_id=${trackId}&quality=${quality}` const { body: downloadBody, statusCode: downloadStatus } = await http1makeRequest(downloadUrl, { method: 'GET', diff --git a/src/typings/sources/monochrome.types.ts b/src/typings/sources/monochrome.types.ts index b041f73c..51333a1e 100644 --- a/src/typings/sources/monochrome.types.ts +++ b/src/typings/sources/monochrome.types.ts @@ -13,6 +13,8 @@ export interface MonochromeSourceConfig { qobuzInstances?: string[] /** Preferred audio quality. */ quality?: 'HI_RES_LOSSLESS' | 'LOSSLESS' | 'HIGH' | 'LOW' + /** Quality level for Qobuz proxy downloads (5 = MP3 320kbps, 6 = Default FLAC, 7 = FLAC 24bit, 27 = HiRes FLAC 24bit). */ + qobuzQuality?: number } /** From 457897366ce8bb2e0e1d2e44d79040f81a8c4abc Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sat, 23 May 2026 09:21:28 -0300 Subject: [PATCH 33/58] update: bump version in package.json --- dist/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/package.json b/dist/package.json index 9b07001b..e393f2fe 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260520.1", + "version": "3.8.1-dev.20260523.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/package.json b/package.json index 1ee05a01..93e642fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260520.1", + "version": "3.8.1-dev.20260523.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", From acaf1e18b396d7c6e3ba70415ff72d938af627a6 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Sat, 23 May 2026 10:01:36 -0300 Subject: [PATCH 34/58] fix: loadchapters 500 in cluster mode !runtime.sources rejects null, but the sources are always null in cluster mode, and since loadchapters is handled in the worker manager, swapping to === undefined like the other routers fixes it. --- dist/src/api/loadChapters.js | 2 +- src/api/loadChapters.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/src/api/loadChapters.js b/dist/src/api/loadChapters.js index 886ae7d0..f3555f79 100644 --- a/dist/src/api/loadChapters.js +++ b/dist/src/api/loadChapters.js @@ -29,7 +29,7 @@ function getLoadChaptersRuntime(nodelink) { const runtime = nodelink; if (runtime.sourceWorkerManager === undefined || runtime.workerManager === undefined || - !runtime.sources) { + runtime.sources === undefined) { return null; } return runtime; diff --git a/src/api/loadChapters.ts b/src/api/loadChapters.ts index c781d861..9fb2d1c8 100644 --- a/src/api/loadChapters.ts +++ b/src/api/loadChapters.ts @@ -147,7 +147,7 @@ function getLoadChaptersRuntime( if ( runtime.sourceWorkerManager === undefined || runtime.workerManager === undefined || - !runtime.sources + runtime.sources === undefined ) { return null } From 5096f308568e879a3bfde79fa0e3cd33ef4553e7 Mon Sep 17 00:00:00 2001 From: Lucas Morais Rodrigues <76886832+1Lucas1apk@users.noreply.github.com> Date: Sun, 24 May 2026 14:15:55 -0400 Subject: [PATCH 35/58] Update Magmastream entry with version info Signed-off-by: Lucas Morais Rodrigues <76886832+1Lucas1apk@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f72d46ba..e562491c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ However, some clients may not work properly, since NodeLink changes certain beha | [lava-lyra](https://github.com/ParrotXray/lava-lyra) | Python | Yes | Yes | v3 | | | [Hikari-ongaku](https://github.com/MPlatypus/hikari-ongaku) | Python | unknown | No | v1 and v2 | | | [Moonlink.js](https://github.com/1Lucas1apk/moonlink.js) | TypeScript | Yes | Yes | v1, v2, v3 | | -| [Magmastream](https://github.com/Blackfort-Hosting/magmastream) | TypeScript | unknown | No | v1 | | +| [Magmastream](https://github.com/Blackfort-Hosting/magmastream) | TypeScript | Yes | Yes | v1 and v3 | | | [Lavacord](https://github.com/lavacord/Lavacord) | TypeScript | unknown | No | v1 and v2 | | | [Shoukaku](https://github.com/Deivu/Shoukaku) | TypeScript | Yes | No | v1, v2, v3 | | | [Hoshimi](https://github.com/Ganyu-Studios/Hoshimi) | TypeScript | Yes | No | v1, v2, v3 | ;P | From 0beb18a430643521bb8ba4db810dc71aea65ee67 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Mon, 25 May 2026 11:42:32 -0300 Subject: [PATCH 36/58] improve: refactor potoken with nativedom & remove jsdom Well, this native dom should be faster, more memory efficient and wayyy more lightweight. from what i used, it reduced the memory usage greatly, consumed less cpu and reduced the disk space on node_modules by ~20/15mb since jsdom is really heavy. Tested with multiple songs, tested with jsdom tests, all passed, so should not affect in a negative form at all. (DOM only gets loaded when using SABR.) --- dist/package.json | 2 - dist/src/sources/youtube/sabr/nativeDOM.js | 1164 ++++++++++++++++++ dist/src/sources/youtube/sabr/potoken.js | 33 +- package.json | 2 - src/sources/youtube/sabr/nativeDOM.ts | 1273 ++++++++++++++++++++ src/sources/youtube/sabr/potoken.ts | 54 +- 6 files changed, 2497 insertions(+), 31 deletions(-) create mode 100644 dist/src/sources/youtube/sabr/nativeDOM.js create mode 100644 src/sources/youtube/sabr/nativeDOM.ts diff --git a/dist/package.json b/dist/package.json index e393f2fe..6472311a 100644 --- a/dist/package.json +++ b/dist/package.json @@ -24,7 +24,6 @@ "@toddynnn/symphonia-decoder": "1.1.0", "@toddynnn/voice-opus": "^1.0.1", "fastest-validator": "^1.19.1", - "jsdom": "^29.1.1", "mp4box": "^2.3.0", "prom-client": "^15.1.3", "proxy-agent": "^8.0.1" @@ -34,7 +33,6 @@ "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@types/bun": "^1.3.13", - "@types/jsdom": "^28.0.1", "@types/node": "^25.6.2", "dotenv": "^17.4.2", "husky": "9.1.7", diff --git a/dist/src/sources/youtube/sabr/nativeDOM.js b/dist/src/sources/youtube/sabr/nativeDOM.js new file mode 100644 index 00000000..feebb2b2 --- /dev/null +++ b/dist/src/sources/youtube/sabr/nativeDOM.js @@ -0,0 +1,1164 @@ +import { Buffer } from 'node:buffer'; +let _ready = false; +// These are populated by _init() and referenced by NativeDOM constructor. +let EMPTY_ARRAY; +// biome-ignore lint/suspicious/noExplicitAny: constructor cache holds arbitrary constructor functions +let dynamicClassCache; +// biome-ignore lint/suspicious/noExplicitAny: event ctor protos hold arbitrary prototype shapes +let EVENT_CTOR_PROTOS; +let EVENT_TYPE_MAP; +let EVENT_TYPE_KEYS; +let ELEMENT_STRING_PROPS; +let ELEMENT_BOOL_PROPS; +let ELEMENT_ZERO_PROPS; +let SHADOW_ROOT_PROTO; +let ANIMATION_OBJ; +let CANVAS_2D_CTX; +let DEFERRED_REQUEST_OBJ; +// Class references — set by _init() +// biome-ignore lint/suspicious/noExplicitAny: DOM class stubs are untyped by design +let _classes = {}; +function _init() { + if (_ready) + return; + _ready = true; + EMPTY_ARRAY = Object.freeze([]); + dynamicClassCache = new Map(); + function getDummyConstructor(name) { + const cached = dynamicClassCache.get(name); + if (cached !== undefined) + return cached; + const dummy = () => { }; + Object.defineProperty(dummy, 'name', { value: name }); + Object.defineProperty(dummy, 'toString', { + value: () => `function ${name}() { [native code] }` + }); + const proto = Object.create(null); + Object.defineProperty(proto, 'constructor', { + value: dummy, + writable: true, + configurable: true + }); + Object.defineProperty(proto, Symbol.toStringTag, { + value: name, + configurable: true + }); + Object.defineProperty(dummy, 'prototype', { + value: proto, + writable: true, + configurable: true + }); + dynamicClassCache.set(name, dummy); + return dummy; + } + class EventTarget { + } + Object.assign(EventTarget.prototype, { + addEventListener() { }, + removeEventListener() { }, + dispatchEvent() { + return true; + } + }); + const NODE_CONSTANTS = Object.freeze({ + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12, + DOCUMENT_POSITION_DISCONNECTED: 1, + DOCUMENT_POSITION_PRECEDING: 2, + DOCUMENT_POSITION_FOLLOWING: 4, + DOCUMENT_POSITION_CONTAINS: 8, + DOCUMENT_POSITION_CONTAINED_BY: 16, + DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32 + }); + class Node extends EventTarget { + } + Object.assign(Node, NODE_CONSTANTS); + Object.assign(Node.prototype, NODE_CONSTANTS); + class Element extends Node { + } + class HTMLElement extends Element { + get [Symbol.toStringTag]() { + return 'HTMLElement'; + } + } + class HTMLIFrameElement extends HTMLElement { + get [Symbol.toStringTag]() { + return 'HTMLIFrameElement'; + } + } + class HTMLCanvasElement extends HTMLElement { + get [Symbol.toStringTag]() { + return 'HTMLCanvasElement'; + } + } + class HTMLImageElement extends HTMLElement { + get [Symbol.toStringTag]() { + return 'HTMLImageElement'; + } + } + class HTMLDivElement extends HTMLElement { + get [Symbol.toStringTag]() { + return 'HTMLDivElement'; + } + } + class HTMLBodyElement extends HTMLElement { + get [Symbol.toStringTag]() { + return 'HTMLBodyElement'; + } + } + class HTMLHtmlElement extends HTMLElement { + get [Symbol.toStringTag]() { + return 'HTMLHtmlElement'; + } + } + class Window extends EventTarget { + get [Symbol.toStringTag]() { + return 'Window'; + } + } + class Document extends EventTarget { + get [Symbol.toStringTag]() { + return 'HTMLDocument'; + } + } + class HTMLDocument extends Document { + } + class Navigator { + get [Symbol.toStringTag]() { + return 'Navigator'; + } + } + class Location { + get [Symbol.toStringTag]() { + return 'Location'; + } + } + class Performance { + get [Symbol.toStringTag]() { + return 'Performance'; + } + } + class Screen { + get [Symbol.toStringTag]() { + return 'Screen'; + } + } + class CSSStyleDeclaration { + getPropertyValue() { + return ''; + } + setProperty() { } + removeProperty() { + return ''; + } + item() { + return ''; + } + get [Symbol.toStringTag]() { + return 'CSSStyleDeclaration'; + } + } + class PluginArray { + get length() { + return 0; + } + item() { + return null; + } + namedItem() { + return null; + } + get [Symbol.toStringTag]() { + return 'PluginArray'; + } + } + class MimeTypeArray { + get length() { + return 0; + } + item() { + return null; + } + namedItem() { + return null; + } + get [Symbol.toStringTag]() { + return 'MimeTypeArray'; + } + } + class Scheduler { + postTask( + // biome-ignore lint/suspicious/noExplicitAny: BotGuard passes arbitrary callbacks + callback, + // biome-ignore lint/suspicious/noExplicitAny: BotGuard options are untyped + options) { + const delay = options?.delay || 0; + return new Promise((resolve, reject) => { + const t = setTimeout(async () => { + try { + resolve(await callback()); + } + catch (err) { + reject(err); + } + }, delay); + if (options?.signal) { + options.signal.addEventListener('abort', () => { + clearTimeout(t); + // biome-ignore lint/suspicious/noExplicitAny: globalThis DOM extension + const g = globalThis; + const AbortError = g.DOMException + ? new g.DOMException('The user aborted a request.', 'AbortError') + : new Error('The user aborted a request.'); + reject(AbortError); + }); + } + }); + } + get [Symbol.toStringTag]() { + return 'Scheduler'; + } + } + SHADOW_ROOT_PROTO = (() => { + const ShadowRoot = getDummyConstructor('ShadowRoot'); + const p = Object.create(ShadowRoot.prototype); + Object.assign(p, { + nodeType: 11, + nodeName: '#document-fragment', + innerHTML: '', + querySelector() { + return null; + }, + querySelectorAll() { + return EMPTY_ARRAY; + }, + get childNodes() { + return EMPTY_ARRAY; + }, + hasChildNodes() { + return false; + }, + appendChild(child) { + return child; + }, + removeChild(child) { + return child; + } + }); + return p; + })(); + ANIMATION_OBJ = Object.freeze({ + cancel() { }, + finish() { }, + play() { }, + pause() { } + }); + CANVAS_2D_CTX = Object.freeze({ + fillRect() { }, + clearRect() { }, + getImageData() { + return { data: new Uint8ClampedArray(4) }; + }, + putImageData() { }, + measureText() { + return { width: 0 }; + }, + createLinearGradient() { + return { addColorStop() { } }; + } + }); + const DeferredRequest = getDummyConstructor('DeferredRequest'); + DEFERRED_REQUEST_OBJ = Object.freeze(Object.assign(Object.create(DeferredRequest.prototype), { activated: true })); + ELEMENT_STRING_PROPS = new Set([ + 'name', + 'id', + 'className', + 'title', + 'lang', + 'dir', + 'width', + 'height', + 'autocapitalize', + 'elementTiming', + 'border', + 'align', + 'virtualKeyboardPolicy', + 'longDesc', + 'srcset', + 'enterKeyHint', + 'accessKey', + 'innerHTML' + ]); + ELEMENT_BOOL_PROPS = new Set([ + 'disabled', + 'hidden', + 'checked', + 'isMap', + 'sharedStorageWritable', + 'inert' + ]); + ELEMENT_ZERO_PROPS = new Set([ + 'scrollTop', + 'scrollLeft', + 'scrollHeight', + 'scrollWidth', + 'clientTop', + 'clientLeft', + 'offsetWidth', + 'offsetHeight', + 'clientWidth', + 'clientHeight', + 'offsetTop', + 'offsetLeft', + 'hspace' + ]); + EVENT_CTOR_PROTOS = new Map(); + EVENT_TYPE_MAP = { + Mouse: 'MouseEvent', + Keyboard: 'KeyboardEvent', + Touch: 'TouchEvent', + UI: 'UIEvent', + Custom: 'CustomEvent', + Mutation: 'MutationEvent', + Message: 'MessageEvent' + }; + EVENT_TYPE_KEYS = Object.keys(EVENT_TYPE_MAP); + function getEventCtorProto(constructorName) { + const cached = EVENT_CTOR_PROTOS.get(constructorName); + if (cached !== undefined) + return cached; + const dummyCtor = getDummyConstructor(constructorName); + const proto = Object.create(dummyCtor.prototype); + proto.bubbles = false; + proto.cancelable = false; + proto.eventPhase = 0; + proto.defaultPrevented = false; + proto.composed = false; + proto.isTrusted = true; + proto.target = null; + proto.currentTarget = null; + proto.srcElement = null; + proto.returnValue = true; + proto.cancelBubble = false; + proto.screenX = 0; + proto.screenY = 0; + proto.clientX = 0; + proto.clientY = 0; + proto.ctrlKey = false; + proto.shiftKey = false; + proto.altKey = false; + proto.metaKey = false; + proto.button = 0; + proto.buttons = 0; + proto.which = 0; + proto.pageX = 0; + proto.pageY = 0; + proto.detail = 0; + proto.initEvent = function (type, bubbles, cancelable) { + this.type = type; + this.bubbles = bubbles; + this.cancelable = cancelable; + }; + proto.initMouseEvent = function (type, bubbles, cancelable, _view, detail, screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey, button, _relatedTarget) { + this.type = type; + this.bubbles = bubbles; + this.cancelable = cancelable; + this.detail = detail; + this.screenX = screenX; + this.screenY = screenY; + this.clientX = clientX; + this.clientY = clientY; + this.ctrlKey = ctrlKey; + this.altKey = altKey; + this.shiftKey = shiftKey; + this.metaKey = metaKey; + this.button = button; + }; + proto.initUIEvent = function (type, bubbles, cancelable, _view, detail) { + this.type = type; + this.bubbles = bubbles; + this.cancelable = cancelable; + this.detail = detail; + }; + proto.stopPropagation = () => { }; + proto.preventDefault = () => { }; + proto.stopImmediatePropagation = () => { }; + EVENT_CTOR_PROTOS.set(constructorName, proto); + return proto; + } + Object.defineProperties(Node.prototype, { + ownerDocument: { + get() { + // biome-ignore lint/suspicious/noExplicitAny: globalThis DOM extension + return globalThis.document; + }, + configurable: true, + enumerable: true + }, + childNodes: { + get() { + return EMPTY_ARRAY; + }, + configurable: true, + enumerable: true + }, + nodeValue: { value: null, writable: true, configurable: true }, + textContent: { value: '', writable: true, configurable: true }, + parentNode: { value: null, writable: true, configurable: true }, + hasChildNodes: { + value() { + return false; + }, + writable: true, + configurable: true + }, + compareDocumentPosition: { + value() { + return 0; + }, + writable: true, + configurable: true + }, + appendChild: { + value(child) { + return child; + }, + writable: true, + configurable: true + }, + removeChild: { + value(child) { + return child; + }, + writable: true, + configurable: true + }, + insertBefore: { + value(newChild) { + return newChild; + }, + writable: true, + configurable: true + }, + replaceChild: { + value(_newChild, oldChild) { + return oldChild; + }, + writable: true, + configurable: true + }, + cloneNode: { + value() { + const tag = (this.tagName || '').toLowerCase() || 'div'; + // biome-ignore lint/suspicious/noExplicitAny: globalThis DOM extension + return globalThis.document.createElement(tag); + }, + writable: true, + configurable: true + }, + addEventListener: { value() { }, writable: true, configurable: true }, + removeEventListener: { value() { }, writable: true, configurable: true }, + dispatchEvent: { + value() { + return true; + }, + writable: true, + configurable: true + }, + moveBefore: { + value(node) { + return node; + }, + writable: true, + configurable: true + }, + lookupPrefix: { + value() { + return null; + }, + writable: true, + configurable: true + }, + lookupNamespaceURI: { + value() { + return null; + }, + writable: true, + configurable: true + }, + isDefaultNamespace: { + value() { + return false; + }, + writable: true, + configurable: true + } + }); + Object.defineProperties(Element.prototype, { + nodeType: { value: 1, writable: true, configurable: true }, + localName: { + get() { + return (this.tagName || '').toLowerCase(); + }, + configurable: true, + enumerable: true + }, + namespaceURI: { + get() { + return 'http://www.w3.org/1999/xhtml'; + }, + configurable: true, + enumerable: true + }, + prefix: { value: null, writable: true, configurable: true }, + hasAttribute: { + value() { + return false; + }, + writable: true, + configurable: true + }, + getAttribute: { + value() { + return null; + }, + writable: true, + configurable: true + }, + setAttribute: { value() { }, writable: true, configurable: true }, + removeAttribute: { value() { }, writable: true, configurable: true }, + hasAttributeNS: { + value() { + return false; + }, + writable: true, + configurable: true + }, + getAttributeNS: { + value() { + return null; + }, + writable: true, + configurable: true + }, + setAttributeNS: { value() { }, writable: true, configurable: true }, + removeAttributeNS: { value() { }, writable: true, configurable: true }, + releasePointerCapture: { value() { }, writable: true, configurable: true }, + setPointerCapture: { value() { }, writable: true, configurable: true }, + hasPointerCapture: { + value() { + return false; + }, + writable: true, + configurable: true + }, + getAttributeNode: { + value() { + return null; + }, + writable: true, + configurable: true + }, + getAttributeNodeNS: { + value() { + return null; + }, + writable: true, + configurable: true + }, + getElementsByTagName: { + value() { + return EMPTY_ARRAY; + }, + writable: true, + configurable: true + }, + getElementsByTagNameNS: { + value() { + return EMPTY_ARRAY; + }, + writable: true, + configurable: true + }, + querySelector: { + value() { + return null; + }, + writable: true, + configurable: true + }, + querySelectorAll: { + value() { + return EMPTY_ARRAY; + }, + writable: true, + configurable: true + }, + scrollTop: { value: 0, writable: true, configurable: true }, + scrollLeft: { value: 0, writable: true, configurable: true }, + scrollHeight: { value: 0, writable: true, configurable: true }, + scrollWidth: { value: 0, writable: true, configurable: true }, + clientTop: { value: 0, writable: true, configurable: true }, + clientLeft: { value: 0, writable: true, configurable: true }, + firstElementChild: { value: null, writable: true, configurable: true }, + lastElementChild: { value: null, writable: true, configurable: true }, + nextElementSibling: { value: null, writable: true, configurable: true }, + previousElementSibling: { value: null, writable: true, configurable: true }, + children: { + get() { + return EMPTY_ARRAY; + }, + configurable: true, + enumerable: true + }, + prepend: { value() { }, writable: true, configurable: true }, + append: { value() { }, writable: true, configurable: true }, + replaceWith: { value() { }, writable: true, configurable: true }, + hasAttributes: { + value() { + return false; + }, + writable: true, + configurable: true + }, + scrollIntoViewIfNeeded: { value() { }, writable: true, configurable: true }, + removeAttributeNode: { value() { }, writable: true, configurable: true }, + setAttributeNode: { value() { }, writable: true, configurable: true }, + normalize: { value() { }, writable: true, configurable: true }, + dispatchEvent: { + value() { + return true; + }, + writable: true, + configurable: true + }, + animate: { + value() { + return ANIMATION_OBJ; + }, + writable: true, + configurable: true + }, + browsingTopics: { + async value() { + return EMPTY_ARRAY; + }, + writable: true, + configurable: true + }, + ariaNotify: { value() { }, writable: true, configurable: true }, + checkVisibility: { + value() { + return true; + }, + writable: true, + configurable: true + }, + webkitMatchesSelector: { + value() { + return false; + }, + writable: true, + configurable: true + }, + attachShadow: { + value(init) { + const shadowRoot = Object.create(SHADOW_ROOT_PROTO); + shadowRoot.mode = init?.mode || 'open'; + shadowRoot.host = this; + return shadowRoot; + }, + writable: true, + configurable: true + } + }); + Object.defineProperties(HTMLElement.prototype, { + dir: { value: '', writable: true, configurable: true }, + id: { value: '', writable: true, configurable: true }, + className: { value: '', writable: true, configurable: true }, + title: { value: '', writable: true, configurable: true }, + lang: { value: '', writable: true, configurable: true }, + offsetHeight: { value: 0, writable: true, configurable: true }, + offsetWidth: { value: 0, writable: true, configurable: true }, + clientHeight: { value: 0, writable: true, configurable: true }, + clientWidth: { value: 0, writable: true, configurable: true }, + click: { value() { }, writable: true, configurable: true }, + blur: { value() { }, writable: true, configurable: true }, + focus: { value() { }, writable: true, configurable: true } + }); + // ts was so annonying to test and make holy. its not perfect, but it works :p + Object.defineProperties(Document.prototype, { + defaultCharset: { value: 'UTF-8', writable: true, configurable: true }, + readyState: { value: 'complete', writable: true, configurable: true }, + hidden: { value: false, writable: true, configurable: true }, + hasFocus: { + value() { + return true; + }, + writable: true, + configurable: true + }, + getElementsByTagNameNS: { + value() { + return EMPTY_ARRAY; + }, + writable: true, + configurable: true + }, + getElementById: { + value() { + return null; + }, + writable: true, + configurable: true + }, + querySelector: { + value() { + return null; + }, + writable: true, + configurable: true + }, + querySelectorAll: { + value() { + return EMPTY_ARRAY; + }, + writable: true, + configurable: true + }, + createTextNode: { + value(text) { + return { nodeValue: text, textContent: text }; + }, + writable: true, + configurable: true + }, + createDocumentFragment: { + value() { + const frag = Object.create(Node.prototype); + frag.nodeType = 11; + return frag; + }, + writable: true, + configurable: true + } + }); + Object.assign(HTMLIFrameElement.prototype, { + width: '', + height: '', + contentDocument: null, + contentWindow: null + }); + Object.assign(HTMLCanvasElement.prototype, { width: 300, height: 150 }); + Object.assign(HTMLImageElement.prototype, { + width: 0, + height: 0, + src: '', + alt: '', + useMap: '', + complete: true, + hspace: 0, + vspace: 0 + }); + _classes = { + EventTarget, + Node, + Element, + HTMLElement, + HTMLIFrameElement, + HTMLCanvasElement, + HTMLImageElement, + HTMLDivElement, + HTMLBodyElement, + HTMLHtmlElement, + Window, + Document, + HTMLDocument, + Navigator, + Location, + Performance, + Screen, + CSSStyleDeclaration, + PluginArray, + MimeTypeArray, + Scheduler, + getDummyConstructor, + getEventCtorProto + }; +} +function isConstructorName(prop) { + const c0 = prop.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) + return true; + if (c0 === 119 && + prop.length > 6 && + prop.charCodeAt(1) === 101 && + prop.charCodeAt(2) === 98 && + prop.charCodeAt(3) === 107 && + prop.charCodeAt(4) === 105 && + prop.charCodeAt(5) === 116) { + const c6 = prop.charCodeAt(6); + return c6 >= 65 && c6 <= 90; + } + if (c0 === 109 && + prop.length > 3 && + prop.charCodeAt(1) === 111 && + prop.charCodeAt(2) === 122) { + const c3 = prop.charCodeAt(3); + return c3 >= 65 && c3 <= 90; + } + if (c0 === 109 && prop.length > 2 && prop.charCodeAt(1) === 115) { + const c2 = prop.charCodeAt(2); + return c2 >= 65 && c2 <= 90; + } + return false; +} +export class NativeDOM { + // biome-ignore lint/suspicious/noExplicitAny: window shape is arbitrary BotGuard-facing object + window; + constructor(options) { + _init(); + const { EventTarget, Node, Element, HTMLElement, HTMLIFrameElement, HTMLCanvasElement, HTMLImageElement, HTMLDivElement, HTMLBodyElement, HTMLHtmlElement, Window, Document, HTMLDocument, Navigator, Location, Performance, Screen, CSSStyleDeclaration, PluginArray, MimeTypeArray, Scheduler, getDummyConstructor, getEventCtorProto } = _classes; + const urlObj = new URL(options.url); + const performanceMock = Object.create(Performance.prototype); + performanceMock.now = () => performance.now(); + performanceMock.timeOrigin = performance.timeOrigin; + const locationMock = Object.create(Location.prototype); + locationMock.href = options.url; + locationMock.origin = urlObj.origin; + locationMock.protocol = urlObj.protocol; + locationMock.host = urlObj.host; + locationMock.hostname = urlObj.hostname; + locationMock.pathname = urlObj.pathname; + locationMock.search = urlObj.search; + locationMock.hash = urlObj.hash; + locationMock.ancestorOrigins = EMPTY_ARRAY; + locationMock.toString = () => options.url; + locationMock.valueOf = () => locationMock; + const navigatorMock = Object.create(Navigator.prototype); + navigatorMock.userAgent = options.userAgent; + navigatorMock.languages = ['en-US', 'en']; + navigatorMock.platform = 'Win32'; + navigatorMock.appName = 'Netscape'; + navigatorMock.appCodeName = 'Mozilla'; + navigatorMock.plugins = Object.create(PluginArray.prototype); + navigatorMock.mimeTypes = Object.create(MimeTypeArray.prototype); + navigatorMock.cookieEnabled = true; + navigatorMock.onLine = true; + navigatorMock.hardwareConcurrency = 8; + navigatorMock.deviceMemory = 8; + navigatorMock.maxTouchPoints = 0; + navigatorMock.javaEnabled = () => false; + const TAG_PROTO_MAP = { + iframe: HTMLIFrameElement.prototype, + canvas: HTMLCanvasElement.prototype, + img: HTMLImageElement.prototype, + div: HTMLDivElement.prototype, + body: HTMLBodyElement.prototype, + html: HTMLHtmlElement.prototype + }; + const cssProxyHandler = { + get(target, prop, receiver) { + if (typeof prop === 'symbol') + return Reflect.get(target, prop, receiver); + if (prop === 'toString') + return () => '[object CSSStyleDeclaration]'; + if (Reflect.has(target, prop) || Reflect.has(Object.prototype, prop)) { + return Reflect.get(target, prop, receiver); + } + return ''; + }, + set() { + return true; + } + }; + const elementProxyHandler = { + get(target, prop, receiver) { + if (typeof prop !== 'string') + return Reflect.get(target, prop, receiver); + if (Reflect.has(target, prop)) + return Reflect.get(target, prop, receiver); + if (ELEMENT_STRING_PROPS.has(prop)) { + const tag = target.tagName?.toLowerCase(); + if (prop === 'width' || prop === 'height') { + if (tag === 'canvas') + return prop === 'width' ? 300 : 150; + if (tag === 'img') + return 0; + return ''; + } + return ''; + } + if (ELEMENT_BOOL_PROPS.has(prop)) + return false; + if (ELEMENT_ZERO_PROPS.has(prop)) + return 0; + if (prop === 'tabIndex') + return -1; + return Reflect.get(target, prop, receiver); + } + }; + const createElement = (tagName) => { + const tag = tagName.toLowerCase(); + const proto = TAG_PROTO_MAP[tag] ?? HTMLElement.prototype; + const styleMock = new Proxy(Object.create(CSSStyleDeclaration.prototype), cssProxyHandler); + const element = Object.create(proto); + element.tagName = tag.toUpperCase(); + element.nodeName = element.tagName; + element.style = styleMock; + element.attributes = EMPTY_ARRAY; + if (tag === 'iframe') { + let _contentWindow = null; + const buildIframe = () => { + if (_contentWindow) + return; + const subDocument = Object.create(HTMLDocument.prototype); + subDocument.URL = 'about:blank'; + subDocument.referrer = options.url; + subDocument.readyState = 'complete'; + subDocument.defaultCharset = 'UTF-8'; + subDocument.hidden = false; + subDocument.createElement = createElement; + subDocument.createEvent = (type) => documentMock.createEvent(type); + subDocument.body = createElement('body'); + subDocument.documentElement = createElement('html'); + subDocument.getElementsByTagName = () => EMPTY_ARRAY; + subDocument.addEventListener = () => { }; + subDocument.removeEventListener = () => { }; + subDocument.hasFocus = () => true; + const subWindow = Object.create(Window.prototype); + subWindow.document = subDocument; + subWindow.location = locationMock; + subWindow.origin = urlObj.origin; + subWindow.navigator = navigatorMock; + subWindow.performance = performanceMock; + subWindow.length = 0; + subWindow.TEMPORARY = 0; + subWindow.PERSISTENT = 1; + subWindow.scrollY = 0; + subWindow.scrollX = 0; + subWindow.btoa = windowMock.btoa; + subWindow.atob = windowMock.atob; + subWindow.close = () => { }; + Object.setPrototypeOf(subWindow, globalThis); + subWindow.window = subWindow; + subWindow.self = subWindow; + subWindow.top = windowProxy; + subWindow.parent = windowProxy; + subWindow.frames = subWindow; + subDocument.defaultView = subWindow; + _contentWindow = makeWindowProxy(subWindow); + }; + Object.defineProperty(element, 'contentWindow', { + get() { + buildIframe(); + return _contentWindow; + }, + configurable: true, + enumerable: true + }); + Object.defineProperty(element, 'contentDocument', { + get() { + buildIframe(); + return _contentWindow?.document ?? null; + }, + configurable: true, + enumerable: true + }); + } + else if (tag === 'canvas') { + element.getContext = (type) => type === '2d' ? CANVAS_2D_CTX : null; + } + return new Proxy(element, elementProxyHandler); + }; + const createEvent = (type) => { + let constructorName = 'Event'; + for (let i = 0; i < EVENT_TYPE_KEYS.length; i++) { + const key = EVENT_TYPE_KEYS[i]; + if (key !== undefined && type.includes(key)) { + constructorName = EVENT_TYPE_MAP[key] ?? 'Event'; + break; + } + } + const event = Object.create(getEventCtorProto(constructorName)); + event.type = type; + event.timeStamp = performance.now(); + return event; + }; + const documentMock = Object.create(HTMLDocument.prototype); + documentMock.URL = options.url; + documentMock.referrer = options.referrer; + documentMock.readyState = 'complete'; + documentMock.defaultCharset = 'UTF-8'; + documentMock.hidden = false; + documentMock.location = locationMock; + documentMock.body = createElement('body'); + documentMock.documentElement = createElement('html'); + documentMock.createElement = createElement; + documentMock.createEvent = createEvent; + documentMock.getElementsByTagName = (name) => { + const tag = name.toLowerCase(); + if (tag === 'head' || tag === 'body' || tag === 'html') + return [createElement(tag)]; + return EMPTY_ARRAY; + }; + documentMock.getElementsByTagNameNS = (_ns, name) => documentMock.getElementsByTagName(name); + documentMock.addEventListener = () => { }; + documentMock.removeEventListener = () => { }; + documentMock.hasFocus = () => true; + const windowMock = Object.create(Window.prototype); + windowMock.document = documentMock; + windowMock.location = locationMock; + windowMock.origin = urlObj.origin; + windowMock.navigator = navigatorMock; + windowMock.performance = performanceMock; + windowMock.name = ''; + windowMock.innerHeight = 1080; + windowMock.innerWidth = 1920; + windowMock.outerHeight = 1080; + windowMock.outerWidth = 1920; + windowMock.screenX = 0; + windowMock.screenY = 0; + windowMock.screenLeft = 0; + windowMock.screenTop = 0; + windowMock.devicePixelRatio = 1; + windowMock.screen = Object.assign(Object.create(Screen.prototype), { + width: 1920, + height: 1080, + availWidth: 1920, + availHeight: 1080, + colorDepth: 24, + pixelDepth: 24 + }); + windowMock.length = 0; + windowMock.TEMPORARY = 0; + windowMock.PERSISTENT = 1; + windowMock.scrollY = 0; + windowMock.scrollX = 0; + windowMock.btoa = (str) => Buffer.from(str, 'binary').toString('base64'); + windowMock.atob = (str) => Buffer.from(str, 'base64').toString('binary'); + windowMock.close = () => { }; + windowMock.getComputedStyle = (el) => el.style || {}; + windowMock.cancelIdleCallback = () => { }; + windowMock.requestIdleCallback = (cb) => setTimeout(cb, 1); + windowMock.credentialless = false; + windowMock.offscreenBuffering = true; + windowMock.isSecureContext = true; + windowMock.reportError = (err) => { + console.error(err); + }; + windowMock.history = { + length: 1, + scrollRestoration: 'auto', + state: null, + back() { }, + forward() { }, + go() { }, + pushState() { }, + replaceState() { } + }; + windowMock.sharedStorage = {}; + windowMock.scheduler = new Scheduler(); + windowMock.fetchLater = () => DEFERRED_REQUEST_OBJ; + Object.assign(windowMock, { + EventTarget, + Window, + Document, + HTMLDocument, + Navigator, + Location, + Performance, + PluginArray, + MimeTypeArray, + CSSStyleDeclaration, + Screen, + Node, + Element, + HTMLElement, + HTMLIFrameElement, + HTMLCanvasElement, + HTMLImageElement, + HTMLDivElement, + HTMLBodyElement, + HTMLHtmlElement, + Scheduler + }); + const makeWindowProxy = (winTarget) => new Proxy(winTarget, { + has(target, prop) { + if (typeof prop !== 'string') + return Reflect.has(target, prop); + if (Reflect.has(target, prop) || Reflect.has(globalThis, prop)) + return true; + if (isConstructorName(prop)) + return true; + return (prop.length > 2 && + prop.charCodeAt(0) === 111 && + prop.charCodeAt(1) === 110); + }, + getOwnPropertyDescriptor(target, prop) { + if (typeof prop !== 'string') + return Reflect.getOwnPropertyDescriptor(target, prop); + if (Reflect.has(target, prop)) + return Reflect.getOwnPropertyDescriptor(target, prop); + if (isConstructorName(prop)) { + return { + value: getDummyConstructor(prop), + writable: true, + enumerable: true, + configurable: true + }; + } + if (prop.length > 2 && + prop.charCodeAt(0) === 111 && + prop.charCodeAt(1) === 110) { + return { + value: null, + writable: true, + enumerable: true, + configurable: true + }; + } + if (Reflect.has(globalThis, prop)) + return Reflect.getOwnPropertyDescriptor(globalThis, prop); + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + get(target, prop, receiver) { + if (typeof prop !== 'string') + return Reflect.get(target, prop, receiver); + if (prop === 'window' || + prop === 'self' || + prop === 'top' || + prop === 'parent' || + prop === 'frames') { + return receiver; + } + if (Reflect.has(target, prop)) + return Reflect.get(target, prop, receiver); + if (isConstructorName(prop)) + return getDummyConstructor(prop); + if (prop.length > 2 && + prop.charCodeAt(0) === 111 && + prop.charCodeAt(1) === 110) + return null; + if (Reflect.has(globalThis, prop)) + return Reflect.get(globalThis, prop); + return Reflect.get(target, prop, receiver); + } + }); + // windowProxy must exist before iframe sub-windows reference it. + let windowProxy; + windowProxy = makeWindowProxy(windowMock); + documentMock.defaultView = windowProxy; + this.window = windowProxy; + } +} diff --git a/dist/src/sources/youtube/sabr/potoken.js b/dist/src/sources/youtube/sabr/potoken.js index 7268ccbc..2c82d805 100644 --- a/dist/src/sources/youtube/sabr/potoken.js +++ b/dist/src/sources/youtube/sabr/potoken.js @@ -169,22 +169,22 @@ export class PoTokenManager { _prevGlobals = null; _idleTimer = null; /** - * Refreshes the idle timeout for JSDOM resources. + * Refreshes the idle timeout for NativeDOM resources. * @internal */ _refreshIdleTimer() { if (this._idleTimer) clearTimeout(this._idleTimer); this._idleTimer = setTimeout(() => { - logger('debug', 'PoToken', 'Idle timeout reached. Cleaning up JSDOM resources.'); + logger('debug', 'PoToken', 'Idle timeout reached. Cleaning up NativeDOM resources.'); this.reset(); }, 10 * 60 * 1000); if (this._idleTimer.unref) this._idleTimer.unref(); } /** - * Applies JSDOM environment to globalThis. - * @param dom - JSDOM instance. + * Applies NativeDOM environment to globalThis. + * @param dom - NativeDOM instance. * @internal */ _applyDomGlobals(dom) { @@ -212,7 +212,7 @@ export class PoTokenManager { } } /** - * Cleans up JSDOM and restores previous globals. + * Cleans up NativeDOM and restores previous globals. * @internal */ _cleanupDom() { @@ -331,9 +331,17 @@ export class PoTokenManager { this.visitorData = await this.fetchVisitorData(); } logger('debug', 'PoToken', `VisitorData: ${this.visitorData?.slice(0, 20)}...`); + await this._initializeWithDom(); + logger('debug', 'PoToken', 'BotGuard initialization with NativeDOM complete'); + } + /** + * Internal helper to perform BotGuard initialization with NativeDOM. + * @internal + */ + async _initializeWithDom() { this._cleanupDom(); - const { JSDOM } = await import('jsdom'); - this._dom = new JSDOM('', { + const { NativeDOM } = await import("./nativeDOM.js"); + this._dom = new NativeDOM({ url: 'https://www.youtube.com/', referrer: 'https://www.youtube.com/', userAgent: PO_CONFIG.userAgent @@ -372,9 +380,16 @@ export class PoTokenManager { body: JSON.stringify([requestKey, botguardResponse]) }); const response = (await integrityTokenResponse.json()); - if (typeof response[0] !== 'string') + let token = ''; + if (response && typeof response[0] === 'string') { + token = response[0]; + } + else if (response && typeof response[3] === 'string') { + token = response[3]; + } + if (!token) throw new Error('Could not get integrity token'); - this.integrityToken = response[0]; + this.integrityToken = token; logger('debug', 'PoToken', `IntegrityToken retrieved. Length: ${this.integrityToken.length}`); this.minter = await WebPoMinter.create(this.integrityToken, webPoSignalOutput); logger('debug', 'PoToken', 'Initialization complete'); diff --git a/package.json b/package.json index 93e642fd..5740f798 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "@toddynnn/symphonia-decoder": "1.1.0", "@toddynnn/voice-opus": "^1.0.1", "fastest-validator": "^1.19.1", - "jsdom": "^29.1.1", "mp4box": "^2.3.0", "prom-client": "^15.1.3", "proxy-agent": "^8.0.1" @@ -34,7 +33,6 @@ "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", "@types/bun": "^1.3.13", - "@types/jsdom": "^28.0.1", "@types/node": "^25.6.2", "dotenv": "^17.4.2", "husky": "9.1.7", diff --git a/src/sources/youtube/sabr/nativeDOM.ts b/src/sources/youtube/sabr/nativeDOM.ts new file mode 100644 index 00000000..2621fee8 --- /dev/null +++ b/src/sources/youtube/sabr/nativeDOM.ts @@ -0,0 +1,1273 @@ +import { Buffer } from 'node:buffer' + +// biome-ignore lint/suspicious/noExplicitAny: internal DOM state uses unknown shapes +type AnyObj = Record + +let _ready = false + +// These are populated by _init() and referenced by NativeDOM constructor. +let EMPTY_ARRAY: readonly never[] +// biome-ignore lint/suspicious/noExplicitAny: constructor cache holds arbitrary constructor functions +let dynamicClassCache: Map +// biome-ignore lint/suspicious/noExplicitAny: event ctor protos hold arbitrary prototype shapes +let EVENT_CTOR_PROTOS: Map +let EVENT_TYPE_MAP: Record +let EVENT_TYPE_KEYS: string[] +let ELEMENT_STRING_PROPS: Set +let ELEMENT_BOOL_PROPS: Set +let ELEMENT_ZERO_PROPS: Set +let SHADOW_ROOT_PROTO: AnyObj +let ANIMATION_OBJ: AnyObj +let CANVAS_2D_CTX: AnyObj +let DEFERRED_REQUEST_OBJ: AnyObj + +// Class references — set by _init() +// biome-ignore lint/suspicious/noExplicitAny: DOM class stubs are untyped by design +let _classes: Record = {} + +function _init() { + if (_ready) return + _ready = true + + EMPTY_ARRAY = Object.freeze([]) as never[] + + dynamicClassCache = new Map() + + function getDummyConstructor(name: string) { + const cached = dynamicClassCache.get(name) + if (cached !== undefined) return cached + + const dummy = () => {} + Object.defineProperty(dummy, 'name', { value: name }) + Object.defineProperty(dummy, 'toString', { + value: () => `function ${name}() { [native code] }` + }) + const proto = Object.create(null) + Object.defineProperty(proto, 'constructor', { + value: dummy, + writable: true, + configurable: true + }) + Object.defineProperty(proto, Symbol.toStringTag, { + value: name, + configurable: true + }) + Object.defineProperty(dummy, 'prototype', { + value: proto, + writable: true, + configurable: true + }) + dynamicClassCache.set(name, dummy) + return dummy + } + + class EventTarget {} + Object.assign(EventTarget.prototype, { + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return true + } + }) + + const NODE_CONSTANTS = Object.freeze({ + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12, + DOCUMENT_POSITION_DISCONNECTED: 1, + DOCUMENT_POSITION_PRECEDING: 2, + DOCUMENT_POSITION_FOLLOWING: 4, + DOCUMENT_POSITION_CONTAINS: 8, + DOCUMENT_POSITION_CONTAINED_BY: 16, + DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32 + }) + + class Node extends EventTarget {} + Object.assign(Node, NODE_CONSTANTS) + Object.assign(Node.prototype, NODE_CONSTANTS) + + class Element extends Node {} + class HTMLElement extends Element { + get [Symbol.toStringTag]() { + return 'HTMLElement' + } + } + class HTMLIFrameElement extends HTMLElement { + override get [Symbol.toStringTag]() { + return 'HTMLIFrameElement' + } + } + class HTMLCanvasElement extends HTMLElement { + override get [Symbol.toStringTag]() { + return 'HTMLCanvasElement' + } + } + class HTMLImageElement extends HTMLElement { + override get [Symbol.toStringTag]() { + return 'HTMLImageElement' + } + } + class HTMLDivElement extends HTMLElement { + override get [Symbol.toStringTag]() { + return 'HTMLDivElement' + } + } + class HTMLBodyElement extends HTMLElement { + override get [Symbol.toStringTag]() { + return 'HTMLBodyElement' + } + } + class HTMLHtmlElement extends HTMLElement { + override get [Symbol.toStringTag]() { + return 'HTMLHtmlElement' + } + } + class Window extends EventTarget { + get [Symbol.toStringTag]() { + return 'Window' + } + } + class Document extends EventTarget { + get [Symbol.toStringTag]() { + return 'HTMLDocument' + } + } + class HTMLDocument extends Document {} + class Navigator { + get [Symbol.toStringTag]() { + return 'Navigator' + } + } + class Location { + get [Symbol.toStringTag]() { + return 'Location' + } + } + class Performance { + get [Symbol.toStringTag]() { + return 'Performance' + } + } + class Screen { + get [Symbol.toStringTag]() { + return 'Screen' + } + } + class CSSStyleDeclaration { + getPropertyValue() { + return '' + } + setProperty() {} + removeProperty() { + return '' + } + item() { + return '' + } + get [Symbol.toStringTag]() { + return 'CSSStyleDeclaration' + } + } + class PluginArray { + get length() { + return 0 + } + item() { + return null + } + namedItem() { + return null + } + get [Symbol.toStringTag]() { + return 'PluginArray' + } + } + class MimeTypeArray { + get length() { + return 0 + } + item() { + return null + } + namedItem() { + return null + } + get [Symbol.toStringTag]() { + return 'MimeTypeArray' + } + } + class Scheduler { + postTask( + // biome-ignore lint/suspicious/noExplicitAny: BotGuard passes arbitrary callbacks + callback: any, + // biome-ignore lint/suspicious/noExplicitAny: BotGuard options are untyped + options?: any + ) { + const delay = options?.delay || 0 + return new Promise((resolve, reject) => { + const t = setTimeout(async () => { + try { + resolve(await callback()) + } catch (err) { + reject(err) + } + }, delay) + if (options?.signal) { + options.signal.addEventListener('abort', () => { + clearTimeout(t) + // biome-ignore lint/suspicious/noExplicitAny: globalThis DOM extension + const g = globalThis as any + const AbortError = g.DOMException + ? new g.DOMException('The user aborted a request.', 'AbortError') + : new Error('The user aborted a request.') + reject(AbortError) + }) + } + }) + } + get [Symbol.toStringTag]() { + return 'Scheduler' + } + } + + SHADOW_ROOT_PROTO = (() => { + const ShadowRoot = getDummyConstructor('ShadowRoot') + const p = Object.create(ShadowRoot.prototype) + Object.assign(p, { + nodeType: 11, + nodeName: '#document-fragment', + innerHTML: '', + querySelector() { + return null + }, + querySelectorAll() { + return EMPTY_ARRAY + }, + get childNodes() { + return EMPTY_ARRAY + }, + hasChildNodes() { + return false + }, + appendChild(child: unknown) { + return child + }, + removeChild(child: unknown) { + return child + } + }) + return p + })() + + ANIMATION_OBJ = Object.freeze({ + cancel() {}, + finish() {}, + play() {}, + pause() {} + }) + + CANVAS_2D_CTX = Object.freeze({ + fillRect() {}, + clearRect() {}, + getImageData() { + return { data: new Uint8ClampedArray(4) } + }, + putImageData() {}, + measureText() { + return { width: 0 } + }, + createLinearGradient() { + return { addColorStop() {} } + } + }) + + const DeferredRequest = getDummyConstructor('DeferredRequest') + DEFERRED_REQUEST_OBJ = Object.freeze( + Object.assign(Object.create(DeferredRequest.prototype), { activated: true }) + ) + + ELEMENT_STRING_PROPS = new Set([ + 'name', + 'id', + 'className', + 'title', + 'lang', + 'dir', + 'width', + 'height', + 'autocapitalize', + 'elementTiming', + 'border', + 'align', + 'virtualKeyboardPolicy', + 'longDesc', + 'srcset', + 'enterKeyHint', + 'accessKey', + 'innerHTML' + ]) + + ELEMENT_BOOL_PROPS = new Set([ + 'disabled', + 'hidden', + 'checked', + 'isMap', + 'sharedStorageWritable', + 'inert' + ]) + + ELEMENT_ZERO_PROPS = new Set([ + 'scrollTop', + 'scrollLeft', + 'scrollHeight', + 'scrollWidth', + 'clientTop', + 'clientLeft', + 'offsetWidth', + 'offsetHeight', + 'clientWidth', + 'clientHeight', + 'offsetTop', + 'offsetLeft', + 'hspace' + ]) + + EVENT_CTOR_PROTOS = new Map() + + EVENT_TYPE_MAP = { + Mouse: 'MouseEvent', + Keyboard: 'KeyboardEvent', + Touch: 'TouchEvent', + UI: 'UIEvent', + Custom: 'CustomEvent', + Mutation: 'MutationEvent', + Message: 'MessageEvent' + } + EVENT_TYPE_KEYS = Object.keys(EVENT_TYPE_MAP) + + function getEventCtorProto(constructorName: string): AnyObj { + const cached = EVENT_CTOR_PROTOS.get(constructorName) + if (cached !== undefined) return cached + + const dummyCtor = getDummyConstructor(constructorName) + const proto: AnyObj = Object.create(dummyCtor.prototype) + proto.bubbles = false + proto.cancelable = false + proto.eventPhase = 0 + proto.defaultPrevented = false + proto.composed = false + proto.isTrusted = true + proto.target = null + proto.currentTarget = null + proto.srcElement = null + proto.returnValue = true + proto.cancelBubble = false + proto.screenX = 0 + proto.screenY = 0 + proto.clientX = 0 + proto.clientY = 0 + proto.ctrlKey = false + proto.shiftKey = false + proto.altKey = false + proto.metaKey = false + proto.button = 0 + proto.buttons = 0 + proto.which = 0 + proto.pageX = 0 + proto.pageY = 0 + proto.detail = 0 + proto.initEvent = function ( + type: string, + bubbles: boolean, + cancelable: boolean + ) { + this.type = type + this.bubbles = bubbles + this.cancelable = cancelable + } + proto.initMouseEvent = function ( + type: string, + bubbles: boolean, + cancelable: boolean, + _view: unknown, + detail: number, + screenX: number, + screenY: number, + clientX: number, + clientY: number, + ctrlKey: boolean, + altKey: boolean, + shiftKey: boolean, + metaKey: boolean, + button: number, + _relatedTarget: unknown + ) { + this.type = type + this.bubbles = bubbles + this.cancelable = cancelable + this.detail = detail + this.screenX = screenX + this.screenY = screenY + this.clientX = clientX + this.clientY = clientY + this.ctrlKey = ctrlKey + this.altKey = altKey + this.shiftKey = shiftKey + this.metaKey = metaKey + this.button = button + } + proto.initUIEvent = function ( + type: string, + bubbles: boolean, + cancelable: boolean, + _view: unknown, + detail: number + ) { + this.type = type + this.bubbles = bubbles + this.cancelable = cancelable + this.detail = detail + } + proto.stopPropagation = () => {} + proto.preventDefault = () => {} + proto.stopImmediatePropagation = () => {} + + EVENT_CTOR_PROTOS.set(constructorName, proto) + return proto + } + + Object.defineProperties(Node.prototype, { + ownerDocument: { + get() { + // biome-ignore lint/suspicious/noExplicitAny: globalThis DOM extension + return (globalThis as any).document + }, + configurable: true, + enumerable: true + }, + childNodes: { + get() { + return EMPTY_ARRAY + }, + configurable: true, + enumerable: true + }, + nodeValue: { value: null, writable: true, configurable: true }, + textContent: { value: '', writable: true, configurable: true }, + parentNode: { value: null, writable: true, configurable: true }, + hasChildNodes: { + value() { + return false + }, + writable: true, + configurable: true + }, + compareDocumentPosition: { + value() { + return 0 + }, + writable: true, + configurable: true + }, + appendChild: { + value(child: unknown) { + return child + }, + writable: true, + configurable: true + }, + removeChild: { + value(child: unknown) { + return child + }, + writable: true, + configurable: true + }, + insertBefore: { + value(newChild: unknown) { + return newChild + }, + writable: true, + configurable: true + }, + replaceChild: { + value(_newChild: unknown, oldChild: unknown) { + return oldChild + }, + writable: true, + configurable: true + }, + cloneNode: { + value() { + const tag = (this.tagName || '').toLowerCase() || 'div' + // biome-ignore lint/suspicious/noExplicitAny: globalThis DOM extension + return (globalThis as any).document.createElement(tag) + }, + writable: true, + configurable: true + }, + addEventListener: { value() {}, writable: true, configurable: true }, + removeEventListener: { value() {}, writable: true, configurable: true }, + dispatchEvent: { + value() { + return true + }, + writable: true, + configurable: true + }, + moveBefore: { + value(node: unknown) { + return node + }, + writable: true, + configurable: true + }, + lookupPrefix: { + value() { + return null + }, + writable: true, + configurable: true + }, + lookupNamespaceURI: { + value() { + return null + }, + writable: true, + configurable: true + }, + isDefaultNamespace: { + value() { + return false + }, + writable: true, + configurable: true + } + }) + + Object.defineProperties(Element.prototype, { + nodeType: { value: 1, writable: true, configurable: true }, + localName: { + get() { + return (this.tagName || '').toLowerCase() + }, + configurable: true, + enumerable: true + }, + namespaceURI: { + get() { + return 'http://www.w3.org/1999/xhtml' + }, + configurable: true, + enumerable: true + }, + prefix: { value: null, writable: true, configurable: true }, + hasAttribute: { + value() { + return false + }, + writable: true, + configurable: true + }, + getAttribute: { + value() { + return null + }, + writable: true, + configurable: true + }, + setAttribute: { value() {}, writable: true, configurable: true }, + removeAttribute: { value() {}, writable: true, configurable: true }, + hasAttributeNS: { + value() { + return false + }, + writable: true, + configurable: true + }, + getAttributeNS: { + value() { + return null + }, + writable: true, + configurable: true + }, + setAttributeNS: { value() {}, writable: true, configurable: true }, + removeAttributeNS: { value() {}, writable: true, configurable: true }, + releasePointerCapture: { value() {}, writable: true, configurable: true }, + setPointerCapture: { value() {}, writable: true, configurable: true }, + hasPointerCapture: { + value() { + return false + }, + writable: true, + configurable: true + }, + getAttributeNode: { + value() { + return null + }, + writable: true, + configurable: true + }, + getAttributeNodeNS: { + value() { + return null + }, + writable: true, + configurable: true + }, + getElementsByTagName: { + value() { + return EMPTY_ARRAY + }, + writable: true, + configurable: true + }, + getElementsByTagNameNS: { + value() { + return EMPTY_ARRAY + }, + writable: true, + configurable: true + }, + querySelector: { + value() { + return null + }, + writable: true, + configurable: true + }, + querySelectorAll: { + value() { + return EMPTY_ARRAY + }, + writable: true, + configurable: true + }, + scrollTop: { value: 0, writable: true, configurable: true }, + scrollLeft: { value: 0, writable: true, configurable: true }, + scrollHeight: { value: 0, writable: true, configurable: true }, + scrollWidth: { value: 0, writable: true, configurable: true }, + clientTop: { value: 0, writable: true, configurable: true }, + clientLeft: { value: 0, writable: true, configurable: true }, + firstElementChild: { value: null, writable: true, configurable: true }, + lastElementChild: { value: null, writable: true, configurable: true }, + nextElementSibling: { value: null, writable: true, configurable: true }, + previousElementSibling: { value: null, writable: true, configurable: true }, + children: { + get() { + return EMPTY_ARRAY + }, + configurable: true, + enumerable: true + }, + prepend: { value() {}, writable: true, configurable: true }, + append: { value() {}, writable: true, configurable: true }, + replaceWith: { value() {}, writable: true, configurable: true }, + hasAttributes: { + value() { + return false + }, + writable: true, + configurable: true + }, + scrollIntoViewIfNeeded: { value() {}, writable: true, configurable: true }, + removeAttributeNode: { value() {}, writable: true, configurable: true }, + setAttributeNode: { value() {}, writable: true, configurable: true }, + normalize: { value() {}, writable: true, configurable: true }, + dispatchEvent: { + value() { + return true + }, + writable: true, + configurable: true + }, + animate: { + value() { + return ANIMATION_OBJ + }, + writable: true, + configurable: true + }, + browsingTopics: { + async value() { + return EMPTY_ARRAY + }, + writable: true, + configurable: true + }, + ariaNotify: { value() {}, writable: true, configurable: true }, + checkVisibility: { + value() { + return true + }, + writable: true, + configurable: true + }, + webkitMatchesSelector: { + value() { + return false + }, + writable: true, + configurable: true + }, + attachShadow: { + value(init?: { mode?: string }) { + const shadowRoot: AnyObj = Object.create(SHADOW_ROOT_PROTO) + shadowRoot.mode = init?.mode || 'open' + shadowRoot.host = this + return shadowRoot + }, + writable: true, + configurable: true + } + }) + + Object.defineProperties(HTMLElement.prototype, { + dir: { value: '', writable: true, configurable: true }, + id: { value: '', writable: true, configurable: true }, + className: { value: '', writable: true, configurable: true }, + title: { value: '', writable: true, configurable: true }, + lang: { value: '', writable: true, configurable: true }, + offsetHeight: { value: 0, writable: true, configurable: true }, + offsetWidth: { value: 0, writable: true, configurable: true }, + clientHeight: { value: 0, writable: true, configurable: true }, + clientWidth: { value: 0, writable: true, configurable: true }, + click: { value() {}, writable: true, configurable: true }, + blur: { value() {}, writable: true, configurable: true }, + focus: { value() {}, writable: true, configurable: true } + }) + + // ts was so annonying to test and make holy. its not perfect, but it works :p + + Object.defineProperties(Document.prototype, { + defaultCharset: { value: 'UTF-8', writable: true, configurable: true }, + readyState: { value: 'complete', writable: true, configurable: true }, + hidden: { value: false, writable: true, configurable: true }, + hasFocus: { + value() { + return true + }, + writable: true, + configurable: true + }, + getElementsByTagNameNS: { + value() { + return EMPTY_ARRAY + }, + writable: true, + configurable: true + }, + getElementById: { + value() { + return null + }, + writable: true, + configurable: true + }, + querySelector: { + value() { + return null + }, + writable: true, + configurable: true + }, + querySelectorAll: { + value() { + return EMPTY_ARRAY + }, + writable: true, + configurable: true + }, + createTextNode: { + value(text: string) { + return { nodeValue: text, textContent: text } + }, + writable: true, + configurable: true + }, + createDocumentFragment: { + value() { + const frag: AnyObj = Object.create(Node.prototype) + frag.nodeType = 11 + return frag + }, + writable: true, + configurable: true + } + }) + + Object.assign(HTMLIFrameElement.prototype, { + width: '', + height: '', + contentDocument: null, + contentWindow: null + }) + Object.assign(HTMLCanvasElement.prototype, { width: 300, height: 150 }) + Object.assign(HTMLImageElement.prototype, { + width: 0, + height: 0, + src: '', + alt: '', + useMap: '', + complete: true, + hspace: 0, + vspace: 0 + }) + + _classes = { + EventTarget, + Node, + Element, + HTMLElement, + HTMLIFrameElement, + HTMLCanvasElement, + HTMLImageElement, + HTMLDivElement, + HTMLBodyElement, + HTMLHtmlElement, + Window, + Document, + HTMLDocument, + Navigator, + Location, + Performance, + Screen, + CSSStyleDeclaration, + PluginArray, + MimeTypeArray, + Scheduler, + getDummyConstructor, + getEventCtorProto + } +} + +function isConstructorName(prop: string): boolean { + const c0 = prop.charCodeAt(0) + if (c0 >= 65 && c0 <= 90) return true + if ( + c0 === 119 && + prop.length > 6 && + prop.charCodeAt(1) === 101 && + prop.charCodeAt(2) === 98 && + prop.charCodeAt(3) === 107 && + prop.charCodeAt(4) === 105 && + prop.charCodeAt(5) === 116 + ) { + const c6 = prop.charCodeAt(6) + return c6 >= 65 && c6 <= 90 + } + if ( + c0 === 109 && + prop.length > 3 && + prop.charCodeAt(1) === 111 && + prop.charCodeAt(2) === 122 + ) { + const c3 = prop.charCodeAt(3) + return c3 >= 65 && c3 <= 90 + } + if (c0 === 109 && prop.length > 2 && prop.charCodeAt(1) === 115) { + const c2 = prop.charCodeAt(2) + return c2 >= 65 && c2 <= 90 + } + return false +} +export class NativeDOM { + // biome-ignore lint/suspicious/noExplicitAny: window shape is arbitrary BotGuard-facing object + public window: any + + constructor(options: { url: string; referrer: string; userAgent: string }) { + _init() + + const { + EventTarget, + Node, + Element, + HTMLElement, + HTMLIFrameElement, + HTMLCanvasElement, + HTMLImageElement, + HTMLDivElement, + HTMLBodyElement, + HTMLHtmlElement, + Window, + Document, + HTMLDocument, + Navigator, + Location, + Performance, + Screen, + CSSStyleDeclaration, + PluginArray, + MimeTypeArray, + Scheduler, + getDummyConstructor, + getEventCtorProto + } = _classes + + const urlObj = new URL(options.url) + + const performanceMock: AnyObj = Object.create(Performance.prototype) + performanceMock.now = () => performance.now() + performanceMock.timeOrigin = performance.timeOrigin + + const locationMock: AnyObj = Object.create(Location.prototype) + locationMock.href = options.url + locationMock.origin = urlObj.origin + locationMock.protocol = urlObj.protocol + locationMock.host = urlObj.host + locationMock.hostname = urlObj.hostname + locationMock.pathname = urlObj.pathname + locationMock.search = urlObj.search + locationMock.hash = urlObj.hash + locationMock.ancestorOrigins = EMPTY_ARRAY + locationMock.toString = () => options.url + locationMock.valueOf = () => locationMock + + const navigatorMock: AnyObj = Object.create(Navigator.prototype) + navigatorMock.userAgent = options.userAgent + navigatorMock.languages = ['en-US', 'en'] + navigatorMock.platform = 'Win32' + navigatorMock.appName = 'Netscape' + navigatorMock.appCodeName = 'Mozilla' + navigatorMock.plugins = Object.create(PluginArray.prototype) + navigatorMock.mimeTypes = Object.create(MimeTypeArray.prototype) + navigatorMock.cookieEnabled = true + navigatorMock.onLine = true + navigatorMock.hardwareConcurrency = 8 + navigatorMock.deviceMemory = 8 + navigatorMock.maxTouchPoints = 0 + navigatorMock.javaEnabled = () => false + + const TAG_PROTO_MAP: Record = { + iframe: HTMLIFrameElement.prototype, + canvas: HTMLCanvasElement.prototype, + img: HTMLImageElement.prototype, + div: HTMLDivElement.prototype, + body: HTMLBodyElement.prototype, + html: HTMLHtmlElement.prototype + } + + const cssProxyHandler: ProxyHandler = { + get(target, prop, receiver) { + if (typeof prop === 'symbol') return Reflect.get(target, prop, receiver) + if (prop === 'toString') return () => '[object CSSStyleDeclaration]' + if (Reflect.has(target, prop) || Reflect.has(Object.prototype, prop)) { + return Reflect.get(target, prop, receiver) + } + return '' + }, + set() { + return true + } + } + + const elementProxyHandler: ProxyHandler = { + get(target, prop, receiver) { + if (typeof prop !== 'string') return Reflect.get(target, prop, receiver) + if (Reflect.has(target, prop)) + return Reflect.get(target, prop, receiver) + if (ELEMENT_STRING_PROPS.has(prop)) { + const tag = target.tagName?.toLowerCase() + if (prop === 'width' || prop === 'height') { + if (tag === 'canvas') return prop === 'width' ? 300 : 150 + if (tag === 'img') return 0 + return '' + } + return '' + } + if (ELEMENT_BOOL_PROPS.has(prop)) return false + if (ELEMENT_ZERO_PROPS.has(prop)) return 0 + if (prop === 'tabIndex') return -1 + return Reflect.get(target, prop, receiver) + } + } + + const createElement = (tagName: string): AnyObj => { + const tag = tagName.toLowerCase() + const proto = TAG_PROTO_MAP[tag] ?? HTMLElement.prototype + + const styleMock = new Proxy( + Object.create(CSSStyleDeclaration.prototype), + cssProxyHandler + ) + + const element: AnyObj = Object.create(proto) + element.tagName = tag.toUpperCase() + element.nodeName = element.tagName + element.style = styleMock + element.attributes = EMPTY_ARRAY + + if (tag === 'iframe') { + let _contentWindow: AnyObj | null = null + const buildIframe = () => { + if (_contentWindow) return + const subDocument: AnyObj = Object.create(HTMLDocument.prototype) + subDocument.URL = 'about:blank' + subDocument.referrer = options.url + subDocument.readyState = 'complete' + subDocument.defaultCharset = 'UTF-8' + subDocument.hidden = false + subDocument.createElement = createElement + subDocument.createEvent = (type: string) => + documentMock.createEvent(type) + subDocument.body = createElement('body') + subDocument.documentElement = createElement('html') + subDocument.getElementsByTagName = () => EMPTY_ARRAY + subDocument.addEventListener = () => {} + subDocument.removeEventListener = () => {} + subDocument.hasFocus = () => true + + const subWindow: AnyObj = Object.create(Window.prototype) + subWindow.document = subDocument + subWindow.location = locationMock + subWindow.origin = urlObj.origin + subWindow.navigator = navigatorMock + subWindow.performance = performanceMock + subWindow.length = 0 + subWindow.TEMPORARY = 0 + subWindow.PERSISTENT = 1 + subWindow.scrollY = 0 + subWindow.scrollX = 0 + subWindow.btoa = windowMock.btoa + subWindow.atob = windowMock.atob + subWindow.close = () => {} + Object.setPrototypeOf(subWindow, globalThis) + subWindow.window = subWindow + subWindow.self = subWindow + subWindow.top = windowProxy + subWindow.parent = windowProxy + subWindow.frames = subWindow + subDocument.defaultView = subWindow + + _contentWindow = makeWindowProxy(subWindow) + } + Object.defineProperty(element, 'contentWindow', { + get() { + buildIframe() + return _contentWindow + }, + configurable: true, + enumerable: true + }) + Object.defineProperty(element, 'contentDocument', { + get() { + buildIframe() + return _contentWindow?.document ?? null + }, + configurable: true, + enumerable: true + }) + } else if (tag === 'canvas') { + element.getContext = (type: string) => + type === '2d' ? CANVAS_2D_CTX : null + } + + return new Proxy(element, elementProxyHandler) + } + + const createEvent = (type: string): AnyObj => { + let constructorName = 'Event' + for (let i = 0; i < EVENT_TYPE_KEYS.length; i++) { + const key = EVENT_TYPE_KEYS[i] + if (key !== undefined && type.includes(key)) { + constructorName = EVENT_TYPE_MAP[key] ?? 'Event' + break + } + } + const event: AnyObj = Object.create(getEventCtorProto(constructorName)) + event.type = type + event.timeStamp = performance.now() + return event + } + + const documentMock: AnyObj = Object.create(HTMLDocument.prototype) + documentMock.URL = options.url + documentMock.referrer = options.referrer + documentMock.readyState = 'complete' + documentMock.defaultCharset = 'UTF-8' + documentMock.hidden = false + documentMock.location = locationMock + documentMock.body = createElement('body') + documentMock.documentElement = createElement('html') + documentMock.createElement = createElement + documentMock.createEvent = createEvent + documentMock.getElementsByTagName = (name: string) => { + const tag = name.toLowerCase() + if (tag === 'head' || tag === 'body' || tag === 'html') + return [createElement(tag)] + return EMPTY_ARRAY + } + documentMock.getElementsByTagNameNS = (_ns: string, name: string) => + documentMock.getElementsByTagName(name) + documentMock.addEventListener = () => {} + documentMock.removeEventListener = () => {} + documentMock.hasFocus = () => true + + const windowMock: AnyObj = Object.create(Window.prototype) + windowMock.document = documentMock + windowMock.location = locationMock + windowMock.origin = urlObj.origin + windowMock.navigator = navigatorMock + windowMock.performance = performanceMock + windowMock.name = '' + windowMock.innerHeight = 1080 + windowMock.innerWidth = 1920 + windowMock.outerHeight = 1080 + windowMock.outerWidth = 1920 + windowMock.screenX = 0 + windowMock.screenY = 0 + windowMock.screenLeft = 0 + windowMock.screenTop = 0 + windowMock.devicePixelRatio = 1 + windowMock.screen = Object.assign(Object.create(Screen.prototype), { + width: 1920, + height: 1080, + availWidth: 1920, + availHeight: 1080, + colorDepth: 24, + pixelDepth: 24 + }) + windowMock.length = 0 + windowMock.TEMPORARY = 0 + windowMock.PERSISTENT = 1 + windowMock.scrollY = 0 + windowMock.scrollX = 0 + windowMock.btoa = (str: string) => + Buffer.from(str, 'binary').toString('base64') + windowMock.atob = (str: string) => + Buffer.from(str, 'base64').toString('binary') + windowMock.close = () => {} + windowMock.getComputedStyle = (el: AnyObj) => el.style || {} + windowMock.cancelIdleCallback = () => {} + windowMock.requestIdleCallback = (cb: () => void) => setTimeout(cb, 1) + windowMock.credentialless = false + windowMock.offscreenBuffering = true + windowMock.isSecureContext = true + windowMock.reportError = (err: unknown) => { + console.error(err) + } + windowMock.history = { + length: 1, + scrollRestoration: 'auto', + state: null, + back() {}, + forward() {}, + go() {}, + pushState() {}, + replaceState() {} + } + windowMock.sharedStorage = {} + windowMock.scheduler = new Scheduler() + windowMock.fetchLater = () => DEFERRED_REQUEST_OBJ + + Object.assign(windowMock, { + EventTarget, + Window, + Document, + HTMLDocument, + Navigator, + Location, + Performance, + PluginArray, + MimeTypeArray, + CSSStyleDeclaration, + Screen, + Node, + Element, + HTMLElement, + HTMLIFrameElement, + HTMLCanvasElement, + HTMLImageElement, + HTMLDivElement, + HTMLBodyElement, + HTMLHtmlElement, + Scheduler + }) + + const makeWindowProxy = (winTarget: AnyObj) => + new Proxy(winTarget, { + has(target, prop) { + if (typeof prop !== 'string') return Reflect.has(target, prop) + if (Reflect.has(target, prop) || Reflect.has(globalThis, prop)) + return true + if (isConstructorName(prop)) return true + return ( + prop.length > 2 && + prop.charCodeAt(0) === 111 && + prop.charCodeAt(1) === 110 + ) + }, + getOwnPropertyDescriptor(target, prop) { + if (typeof prop !== 'string') + return Reflect.getOwnPropertyDescriptor(target, prop) + if (Reflect.has(target, prop)) + return Reflect.getOwnPropertyDescriptor(target, prop) + if (isConstructorName(prop)) { + return { + value: getDummyConstructor(prop), + writable: true, + enumerable: true, + configurable: true + } + } + if ( + prop.length > 2 && + prop.charCodeAt(0) === 111 && + prop.charCodeAt(1) === 110 + ) { + return { + value: null, + writable: true, + enumerable: true, + configurable: true + } + } + if (Reflect.has(globalThis, prop)) + return Reflect.getOwnPropertyDescriptor(globalThis, prop) + return Reflect.getOwnPropertyDescriptor(target, prop) + }, + get(target, prop, receiver) { + if (typeof prop !== 'string') + return Reflect.get(target, prop, receiver) + if ( + prop === 'window' || + prop === 'self' || + prop === 'top' || + prop === 'parent' || + prop === 'frames' + ) { + return receiver + } + if (Reflect.has(target, prop)) + return Reflect.get(target, prop, receiver) + if (isConstructorName(prop)) return getDummyConstructor(prop) + if ( + prop.length > 2 && + prop.charCodeAt(0) === 111 && + prop.charCodeAt(1) === 110 + ) + return null + if (Reflect.has(globalThis, prop)) + return Reflect.get(globalThis, prop) + return Reflect.get(target, prop, receiver) + } + }) + + // windowProxy must exist before iframe sub-windows reference it. + let windowProxy: AnyObj + windowProxy = makeWindowProxy(windowMock) + documentMock.defaultView = windowProxy + this.window = windowProxy + } +} diff --git a/src/sources/youtube/sabr/potoken.ts b/src/sources/youtube/sabr/potoken.ts index 6e0a34f5..f8590cab 100644 --- a/src/sources/youtube/sabr/potoken.ts +++ b/src/sources/youtube/sabr/potoken.ts @@ -299,7 +299,7 @@ export class PoTokenManager { private _idleTimer: NodeJS.Timeout | null = null /** - * Refreshes the idle timeout for JSDOM resources. + * Refreshes the idle timeout for NativeDOM resources. * @internal */ private _refreshIdleTimer(): void { @@ -309,7 +309,7 @@ export class PoTokenManager { logger( 'debug', 'PoToken', - 'Idle timeout reached. Cleaning up JSDOM resources.' + 'Idle timeout reached. Cleaning up NativeDOM resources.' ) this.reset() }, @@ -319,8 +319,8 @@ export class PoTokenManager { } /** - * Applies JSDOM environment to globalThis. - * @param dom - JSDOM instance. + * Applies NativeDOM environment to globalThis. + * @param dom - NativeDOM instance. * @internal */ private _applyDomGlobals(dom: { @@ -358,7 +358,7 @@ export class PoTokenManager { } /** - * Cleans up JSDOM and restores previous globals. + * Cleans up NativeDOM and restores previous globals. * @internal */ private _cleanupDom(): void { @@ -509,16 +509,28 @@ export class PoTokenManager { `VisitorData: ${this.visitorData?.slice(0, 20)}...` ) - this._cleanupDom() - const { JSDOM } = await import('jsdom') - this._dom = new JSDOM( - '', - { - url: 'https://www.youtube.com/', - referrer: 'https://www.youtube.com/', - userAgent: PO_CONFIG.userAgent - } as import('jsdom').ConstructorOptions + await this._initializeWithDom() + logger( + 'debug', + 'PoToken', + 'BotGuard initialization with NativeDOM complete' ) + } + + /** + * Internal helper to perform BotGuard initialization with NativeDOM. + * @internal + */ + private async _initializeWithDom(): Promise { + this._cleanupDom() + + const { NativeDOM } = await import('./nativeDOM.ts') + this._dom = new NativeDOM({ + url: 'https://www.youtube.com/', + referrer: 'https://www.youtube.com/', + userAgent: PO_CONFIG.userAgent + }) + this._applyDomGlobals(this._dom) logger('debug', 'PoToken', 'Fetching attestation challenge...') @@ -562,11 +574,17 @@ export class PoTokenManager { body: JSON.stringify([requestKey, botguardResponse]) }) - const response = (await integrityTokenResponse.json()) as [string, unknown] - if (typeof response[0] !== 'string') - throw new Error('Could not get integrity token') + const response = (await integrityTokenResponse.json()) as any + let token = '' + if (response && typeof response[0] === 'string') { + token = response[0] + } else if (response && typeof response[3] === 'string') { + token = response[3] + } + + if (!token) throw new Error('Could not get integrity token') - this.integrityToken = response[0] + this.integrityToken = token logger( 'debug', 'PoToken', From 3098b1110324760edff98af811bb99bd2edc41b5 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 31 May 2026 22:56:31 -0400 Subject: [PATCH 37/58] improve: enhanced youtube recovery and itag fallback - Implemented automatic audio quality fallback for consistent stream failures. - Added proportional byte position shifting to maintain audio timing. - Added client failure tracking to skip non-functional clients (e.g. AndroidVR). - Removed 60s drain timeout to prevent paused streams from being destroyed. - Fixed recovery race condition using an isRecovering flag. - Refactored with strict TypeScript types and removed loose casts. - Updated development version to 20260531.1 in package.json --- README.md | 2 +- dist/src/sources/youtube/YouTube.js | 90 ++++++++++++++++---- dist/src/sources/youtube/common.js | 1 + package.json | 2 +- src/sources/youtube/YouTube.ts | 120 ++++++++++++++++++++++----- src/sources/youtube/common.ts | 2 + src/typings/sources/youtube.types.ts | 4 + 7 files changed, 182 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index f72d46ba..76f6f52c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ However, some clients may not work properly, since NodeLink changes certain beha | [lava-lyra](https://github.com/ParrotXray/lava-lyra) | Python | Yes | Yes | v3 | | | [Hikari-ongaku](https://github.com/MPlatypus/hikari-ongaku) | Python | unknown | No | v1 and v2 | | | [Moonlink.js](https://github.com/1Lucas1apk/moonlink.js) | TypeScript | Yes | Yes | v1, v2, v3 | | -| [Magmastream](https://github.com/Blackfort-Hosting/magmastream) | TypeScript | unknown | No | v1 | | +| [Magmastream](https://github.com/Blackfort-Hosting/magmastream) | TypeScript | Yes | Yes | v1 and v3 | | | [Lavacord](https://github.com/lavacord/Lavacord) | TypeScript | unknown | No | v1 and v2 | | | [Shoukaku](https://github.com/Deivu/Shoukaku) | TypeScript | Yes | No | v1, v2, v3 | | | [Hoshimi](https://github.com/Ganyu-Studios/Hoshimi) | TypeScript | Yes | No | v1, v2, v3 | ;P | diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index ad7e0d57..912e7437 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -153,6 +153,8 @@ export default class YouTubeSource { activeStreams; /** Set of fallback-mirror lookup keys currently in flight, used to prevent infinite recursion loops. */ mirrorFallbackInFlight; + /** Map of track identifiers to a set of client names that failed to provide a playable URL. */ + failingClientsByTrack; /** YouTube innertube request context sent with every API call (device info, locale, visitor data). */ ytContext; // -- Public fields consumed by the framework -- @@ -194,6 +196,7 @@ export default class YouTubeSource { }); this.activeStreams = new Map(); this.mirrorFallbackInFlight = new Set(); + this.failingClientsByTrack = new Map(); this.ytContext = { client: { screenDensityFloat: 1, @@ -282,6 +285,7 @@ export default class YouTubeSource { if (this.oauth) this.oauth.cleanup?.(); this.cipherManager?.cleanup?.(); + this.failingClientsByTrack.clear(); } /** * Fetches visitor data and player script URL from YouTube embed pages. @@ -741,7 +745,12 @@ export default class YouTubeSource { if (!clientList.length) clientList = ['Web']; const clientErrors = []; + const failingClients = this.failingClientsByTrack.get(decodedTrack.identifier); for (const clientName of clientList) { + if (failingClients?.has(clientName)) { + logger('debug', 'YouTube', `Skipping known failing client ${clientName} for track ${decodedTrack.identifier}`); + continue; + } const client = this.clients[clientName]; if (!client) continue; @@ -752,6 +761,14 @@ export default class YouTubeSource { const urlData = await client.getTrackUrl(decodedTrack, this.ytContext, this.cipherManager, itag, proxyToUse); const proxyLatency = Date.now() - proxyStartTime; if (urlData.exception) { + const isNoStream = urlData.exception.cause === 'UpstreamNoStream'; + const isNotFound = urlData.exception.status === 404; + if (isNoStream || isNotFound) { + if (!this.failingClientsByTrack.has(decodedTrack.identifier)) { + this.failingClientsByTrack.set(decodedTrack.identifier, new Set()); + } + this.failingClientsByTrack.get(decodedTrack.identifier)?.add(clientName); + } this.reportProxyStatus(proxyToUse, false, urlData.exception.status || 500, proxyLatency); clientErrors.push({ client: clientName, @@ -798,7 +815,12 @@ export default class YouTubeSource { logger('debug', 'YouTube', `URL pre-flight check successful for client ${clientName}.`); const result = { ...urlData, - additionalData: { contentLength, proxy: proxyToUse } + additionalData: { + contentLength, + proxy: proxyToUse, + itag: urlData.itag, + formats: urlData.formats + } }; this.nodelink.trackCacheManager?.set('youtube', decodedTrack.identifier, result, 1000 * 60 * 60 * 5); return result; @@ -1391,6 +1413,10 @@ export default class YouTubeSource { let activeRequest = null; let recoverTimeout = null; let currentAdditionalData = additionalData; + let currentItag = currentAdditionalData?.itag; + let availableFormats = currentAdditionalData?.formats || []; + const failedItags = new Set(); + let isRecovering = false; const cleanup = () => { if (destroyed) return; @@ -1472,7 +1498,7 @@ export default class YouTubeSource { (statusCode ?? 0) >= 500) { logger('warn', 'YouTube', `Got ${statusCode} at pos ${position} → forcing recovery`); fetching = false; - recover(); + recover({ message: `HTTP ${statusCode}`, statusCode, name: 'Error' }); return; } throw new Error(`Range request failed: ${statusCode}`); @@ -1556,7 +1582,7 @@ export default class YouTubeSource { const isForbidden = causeError?.message?.includes('403') || causeError?.statusCode === 403; const isAborted = causeError?.message === 'aborted' || causeError?.code === 'ECONNRESET'; - if (!isForbidden && !isAborted && refreshes === 0) { + if (!isForbidden && refreshes === 0) { logger('debug', 'YouTube', `Retrying same URL for recovery first (cause: ${causeError?.message})...`); errors = 0; fetching = false; @@ -1578,37 +1604,65 @@ export default class YouTubeSource { if (isAborted && stream.writableNeedDrain) { logger('debug', 'YouTube', `Stream is paused/backed up, waiting for drain before recovery (cause: ${causeError?.message})`); await new Promise((resolve) => { - const onDrain = () => { - stream.off('drain', onDrain); + const onDrainOrEnd = () => { + cleanupListeners(); resolve(); }; - stream.once('drain', onDrain); - const timeout = setTimeout(() => { - stream.off('drain', onDrain); - resolve(); - }, 60000); - if (typeof timeout.unref === 'function') - timeout.unref(); + const cleanupListeners = () => { + stream.off('drain', onDrainOrEnd); + stream.off('close', onDrainOrEnd); + stream.off('error', onDrainOrEnd); + }; + stream.once('drain', onDrainOrEnd); + stream.once('close', onDrainOrEnd); + stream.once('error', onDrainOrEnd); }); if (destroyed || cancelSignal.aborted || stream.destroyed) return; - if (stream.writableNeedDrain) { - logger('debug', 'YouTube', 'Stream still backed up after drain wait, deferring recovery until resume'); - return; - } } try { - const newUrlData = await this.getTrackUrl(decodedTrack, null, true); + let itagToTry = null; + if (refreshes > 2 && currentItag) { + failedItags.add(currentItag); + logger('warn', 'YouTube', `Itag ${currentItag} failed consistently. Attempting quality fallback...`); + const currentMime = currentAdditionalData?.formats?.find((f) => f.itag === currentItag)?.mimeType || ''; + const isWebm = currentMime.includes('webm'); + const otherAudioFormats = availableFormats + .filter((f) => f.itag !== currentItag && + !failedItags.has(f.itag) && + f.mimeType?.includes('audio') && + (isWebm ? f.mimeType.includes('webm') : f.mimeType.includes('mp4'))) + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); + const bestFallback = otherAudioFormats[0]; + if (bestFallback) { + itagToTry = bestFallback.itag; + logger('info', 'YouTube', `Switching to itag ${itagToTry} (available: ${otherAudioFormats.map((f) => f.itag).join(', ')})`); + } + } + const newUrlData = await this.getTrackUrl(decodedTrack, itagToTry, true); if (destroyed || cancelSignal.aborted) return; if (newUrlData.exception || !newUrlData.url) { throw new Error('No valid URL from getTrackUrl'); } + const oldContentLength = currentAdditionalData?.contentLength || contentLength; + const newAdditionalData = newUrlData.additionalData; + const newContentLength = newAdditionalData?.contentLength; + if (oldContentLength && newContentLength && oldContentLength !== newContentLength) { + const ratio = newContentLength / oldContentLength; + const oldPos = position; + position = Math.floor(position * ratio); + contentLength = newContentLength; + logger('debug', 'YouTube', `Adjusted position for itag switch (${currentItag} -> ${newUrlData.itag || itagToTry}): ${oldPos} -> ${position} bytes (ratio: ${ratio.toFixed(4)})`); + } currentUrl = newUrlData.url; currentAdditionalData = newUrlData.additionalData; + currentItag = newUrlData.itag || itagToTry || currentItag; + if (newUrlData.formats) + availableFormats = newUrlData.formats; errors = 0; - logger('debug', 'YouTube', `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, cause: ${causeError?.message})`); + logger('debug', 'YouTube', `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, itag ${currentItag}, cause: ${causeError?.message})`); fetching = false; fetchNext(); } diff --git a/dist/src/sources/youtube/common.js b/dist/src/sources/youtube/common.js index 16392b6e..552f30da 100644 --- a/dist/src/sources/youtube/common.js +++ b/dist/src/sources/youtube/common.js @@ -2114,6 +2114,7 @@ export class BaseClient { url: directUrl, protocol: directUrl ? 'http' : null, format: resolveFormatStr(resolvedFormat?.mimeType), + itag: resolvedFormat?.itag, hlsUrl: streamingData.hlsManifestUrl || null, formats }; diff --git a/package.json b/package.json index 93e642fd..1ea864a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260523.1", + "version": "3.8.1-dev.20260531.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index 0764f23c..f1751297 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -213,6 +213,9 @@ export default class YouTubeSource { /** Set of fallback-mirror lookup keys currently in flight, used to prevent infinite recursion loops. */ private mirrorFallbackInFlight: Set + /** Map of track identifiers to a set of client names that failed to provide a playable URL. */ + private failingClientsByTrack: Map> + /** YouTube innertube request context sent with every API call (device info, locale, visitor data). */ private ytContext: YouTubeContext @@ -258,6 +261,7 @@ export default class YouTubeSource { }) this.activeStreams = new Map() this.mirrorFallbackInFlight = new Set() + this.failingClientsByTrack = new Map() this.ytContext = { client: { screenDensityFloat: 1, @@ -369,6 +373,7 @@ export default class YouTubeSource { if (this.oauth) (this.oauth as { cleanup?: () => void }).cleanup?.() ;(this.cipherManager as { cleanup?: () => void })?.cleanup?.() + this.failingClientsByTrack.clear() } /** * Fetches visitor data and player script URL from YouTube embed pages. @@ -1129,7 +1134,18 @@ export default class YouTubeSource { if (!clientList.length) clientList = ['Web'] const clientErrors: Array<{ client: string; message: string }> = [] + const failingClients = this.failingClientsByTrack.get(decodedTrack.identifier) + for (const clientName of clientList) { + if (failingClients?.has(clientName)) { + logger( + 'debug', + 'YouTube', + `Skipping known failing client ${clientName} for track ${decodedTrack.identifier}` + ) + continue + } + const client = this.clients[clientName] if (!client) continue @@ -1152,6 +1168,16 @@ export default class YouTubeSource { const proxyLatency = Date.now() - proxyStartTime if (urlData.exception) { + const isNoStream = urlData.exception.cause === 'UpstreamNoStream' + const isNotFound = urlData.exception.status === 404 + + if (isNoStream || isNotFound) { + if (!this.failingClientsByTrack.has(decodedTrack.identifier)) { + this.failingClientsByTrack.set(decodedTrack.identifier, new Set()) + } + this.failingClientsByTrack.get(decodedTrack.identifier)?.add(clientName) + } + this.reportProxyStatus( proxyToUse, false, @@ -1230,7 +1256,12 @@ export default class YouTubeSource { ) const result: TrackUrlData = { ...urlData, - additionalData: { contentLength, proxy: proxyToUse } + additionalData: { + contentLength, + proxy: proxyToUse, + itag: urlData.itag, + formats: urlData.formats + } } this.nodelink.trackCacheManager?.set( 'youtube', @@ -2120,6 +2151,10 @@ export default class YouTubeSource { | null = null let recoverTimeout: ReturnType | null = null let currentAdditionalData = additionalData + let currentItag = currentAdditionalData?.itag + let availableFormats = currentAdditionalData?.formats || [] + const failedItags = new Set() + let isRecovering = false const cleanup = () => { if (destroyed) return @@ -2227,7 +2262,7 @@ export default class YouTubeSource { `Got ${statusCode} at pos ${position} → forcing recovery` ) fetching = false - recover() + recover({ message: `HTTP ${statusCode}`, statusCode, name: 'Error' }) return } throw new Error(`Range request failed: ${statusCode}`) @@ -2337,7 +2372,7 @@ export default class YouTubeSource { causeError?.message === 'aborted' || (causeError as Error & { code?: string })?.code === 'ECONNRESET' - if (!isForbidden && !isAborted && refreshes === 0) { + if (!isForbidden && refreshes === 0) { logger( 'debug', 'YouTube', @@ -2370,30 +2405,58 @@ export default class YouTubeSource { `Stream is paused/backed up, waiting for drain before recovery (cause: ${causeError?.message})` ) await new Promise((resolve) => { - const onDrain = () => { - stream.off('drain', onDrain) + const onDrainOrEnd = () => { + cleanupListeners() resolve() } - stream.once('drain', onDrain) - const timeout = setTimeout(() => { - stream.off('drain', onDrain) - resolve() - }, 60000) - if (typeof timeout.unref === 'function') timeout.unref() + const cleanupListeners = () => { + stream.off('drain', onDrainOrEnd) + stream.off('close', onDrainOrEnd) + stream.off('error', onDrainOrEnd) + } + stream.once('drain', onDrainOrEnd) + stream.once('close', onDrainOrEnd) + stream.once('error', onDrainOrEnd) }) if (destroyed || cancelSignal.aborted || stream.destroyed) return - if (stream.writableNeedDrain) { + } + + try { + let itagToTry: number | null = null + + if (refreshes > 2 && currentItag) { + failedItags.add(currentItag) logger( - 'debug', + 'warn', 'YouTube', - 'Stream still backed up after drain wait, deferring recovery until resume' + `Itag ${currentItag} failed consistently. Attempting quality fallback...` ) - return + + const currentMime = currentAdditionalData?.formats?.find((f) => f.itag === currentItag)?.mimeType || '' + const isWebm = currentMime.includes('webm') + + const otherAudioFormats = availableFormats + .filter( + (f) => + f.itag !== currentItag && + !failedItags.has(f.itag) && + f.mimeType?.includes('audio') && + (isWebm ? f.mimeType.includes('webm') : f.mimeType.includes('mp4')) + ) + .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)) + + const bestFallback = otherAudioFormats[0] + if (bestFallback) { + itagToTry = bestFallback.itag as number + logger( + 'info', + 'YouTube', + `Switching to itag ${itagToTry} (available: ${otherAudioFormats.map((f) => f.itag).join(', ')})` + ) + } } - } - try { - const newUrlData = await this.getTrackUrl(decodedTrack, null, true) + const newUrlData = await this.getTrackUrl(decodedTrack, itagToTry, true) if (destroyed || cancelSignal.aborted) return @@ -2401,14 +2464,33 @@ export default class YouTubeSource { throw new Error('No valid URL from getTrackUrl') } + const oldContentLength = currentAdditionalData?.contentLength as number | undefined || contentLength + const newAdditionalData = newUrlData.additionalData as TrackUrlAdditionalData | undefined + const newContentLength = newAdditionalData?.contentLength as number | undefined + + if (oldContentLength && newContentLength && oldContentLength !== newContentLength) { + const ratio = newContentLength / oldContentLength + const oldPos = position + position = Math.floor(position * ratio) + contentLength = newContentLength + logger( + 'debug', + 'YouTube', + `Adjusted position for itag switch (${currentItag} -> ${newUrlData.itag || itagToTry}): ${oldPos} -> ${position} bytes (ratio: ${ratio.toFixed(4)})` + ) + } + currentUrl = newUrlData.url currentAdditionalData = newUrlData.additionalData as TrackUrlAdditionalData + currentItag = newUrlData.itag || itagToTry || currentItag + if (newUrlData.formats) availableFormats = newUrlData.formats + errors = 0 logger( 'debug', 'YouTube', - `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, cause: ${causeError?.message})` + `URL recovered for ${decodedTrack.title} (resume at ${position} bytes, attempt ${refreshes}, itag ${currentItag}, cause: ${causeError?.message})` ) fetching = false fetchNext() diff --git a/src/sources/youtube/common.ts b/src/sources/youtube/common.ts index 6e957f4a..dd113ee0 100644 --- a/src/sources/youtube/common.ts +++ b/src/sources/youtube/common.ts @@ -3108,6 +3108,8 @@ export abstract class BaseClient { format: resolveFormatStr(resolvedFormat?.mimeType as string | undefined), + itag: resolvedFormat?.itag, + hlsUrl: (streamingData.hlsManifestUrl as string | undefined) || null, formats diff --git a/src/typings/sources/youtube.types.ts b/src/typings/sources/youtube.types.ts index 1ad86286..908bd031 100644 --- a/src/typings/sources/youtube.types.ts +++ b/src/typings/sources/youtube.types.ts @@ -948,6 +948,8 @@ export interface TrackUrlData { hlsUrl?: string | null /** Array of available format entries with their metadata. */ formats?: FormatEntry[] + /** The specific itag used for this track URL. */ + itag?: number /** Additional metadata attached to the track URL (content length, proxy info, access tokens, etc.). */ additionalData?: Record /** New track information when resolution redirects to a different track (e.g., fallback sources). */ @@ -1049,6 +1051,8 @@ export interface TrackUrlAdditionalData { clientInfo?: unknown /** Available format entries used by SABR to select audio quality. */ formats?: FormatEntry[] + /** The specific itag used for this track URL. */ + itag?: number /** Start time offset in seconds for SABR stream initialization. */ startTime?: number /** Position callback used by SABR to report playback progress. */ From d6551ebacb1a768433553eb19366e5dad6db5422 Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 31 May 2026 23:30:18 -0400 Subject: [PATCH 38/58] improve: add rate limiting to dependency update checks - Implemented 10-minute check interval using CredentialManager for persistence. - Prevents excessive external requests to GitHub/NPM on frequent server restarts. - Optimized performance by skipping checks if the last one was recent. --- dist/package.json | 2 +- dist/src/index.js | 3 +- dist/src/utils.js | 64 ++++++++++++++++- src/index.ts | 4 ++ src/sources/gaana.ts | 5 +- src/utils.ts | 166 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 4 deletions(-) diff --git a/dist/package.json b/dist/package.json index 6472311a..4ab15ae8 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260523.1", + "version": "3.8.1-dev.20260531.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/dist/src/index.js b/dist/src/index.js index 3796a7dc..53b82f82 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -16,7 +16,7 @@ import RoutePlannerManager from "./managers/routePlannerManager.js"; import SessionManager from "./managers/sessionManager.js"; import StatsManager from "./managers/statsManager.js"; import { migrateConfig, persistConfig } from "./modules/config/configMigration.js"; -import { applyEnvOverrides, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, getGitInfo, getStats, getVersion, initLogger, logger, parseClient, verifyDiscordID } from "./utils.js"; +import { applyEnvOverrides, checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, getGitInfo, getStats, getVersion, initLogger, logger, parseClient, verifyDiscordID } from "./utils.js"; import 'dotenv/config'; import { GatewayEvents, MINIMUM_NODE_VERSION } from "./constants.js"; import ConfigValidationManager from "./managers/configValidationManager.js"; @@ -1834,6 +1834,7 @@ class NodelinkServer extends EventEmitter { } this.connectionManager?.start(); memoryTrace('start:ready'); + void checkDependencyUpdates(); return this; } /** diff --git a/dist/src/utils.js b/dist/src/utils.js index e49e4669..22bba6b9 100644 --- a/dist/src/utils.js +++ b/dist/src/utils.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import http from 'node:http'; import http2 from 'node:http2'; import https from 'node:https'; +import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { URL } from 'node:url'; @@ -1601,6 +1602,67 @@ async function makeRequest(urlString, options, nodelink) { } }); } +/** + * Checks for updates of core dependencies against the NPM registry. + * + * Logs a warning if an update is available for critical packages. + * @public + */ +async function checkDependencyUpdates() { + const coreDeps = [ + '@performanc/voice', + '@performanc/pwsl-server', + '@toddynnn/symphonia-decoder', + '@toddynnn/voice-opus', + '@ecliptia/faad2-wasm', + '@alexanderolsen/libsamplerate-js', + '@ecliptia/seekable-stream' + ]; + const require = createRequire(import.meta.url); + const updates = []; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + try { + await Promise.all(coreDeps.map(async (dep) => { + try { + let currentVersion = 'unknown'; + try { + const depPath = require.resolve(`${dep}/package.json`); + currentVersion = require(depPath).version; + } + catch { + return; + } + const response = await fetch(`https://registry.npmjs.org/${dep}/latest`, { + signal: controller.signal + }); + if (!response.ok) + return; + const data = (await response.json()); + const latestVersion = data.version; + if (currentVersion !== latestVersion && latestVersion) { + updates.push({ name: dep, current: currentVersion, latest: latestVersion }); + } + } + catch { + // Ignore individual fetch/resolve failures + } + })); + if (updates.length > 0) { + logger('warn', 'Server', 'The following core dependencies have updates available:'); + for (const update of updates) { + logger('warn', 'Server', ` - ${update.name}: ${update.current} -> \x1b[1m\x1b[32m${update.latest}\x1b[0m (Update recommended for stability)`); + } + logger('warn', 'Server', 'Run "npm install" or update your package.json to get the latest improvements.'); + } + } + catch (error) { + logger('debug', 'Server', `Failed to check dependency updates: ${error instanceof Error ? error.message : String(error)}`); + } + finally { + clearTimeout(timeout); + } +} /** * Checks for git updates against the upstream branch. * @@ -1922,4 +1984,4 @@ async function fetchSponsorBlockSegments(videoId, categories, actionTypes, apiBa return []; } } -export { applyEnvOverrides, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, getGitInfo, getStats, getVersion, http1makeRequest, initLogger, logger, makeRequest, parseClient, parseSemver, sendErrorResponse, sendResponse, validateProperty, verifyDiscordID, verifyMethod }; +export { applyEnvOverrides, checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, getGitInfo, getStats, getVersion, http1makeRequest, initLogger, logger, makeRequest, parseClient, parseSemver, sendErrorResponse, sendResponse, validateProperty, verifyDiscordID, verifyMethod }; diff --git a/src/index.ts b/src/index.ts index e9c3e174..068649d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { } from './modules/config/configMigration.ts' import { applyEnvOverrides, + checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, @@ -2643,6 +2644,9 @@ class NodelinkServer extends EventEmitter { this.connectionManager?.start() memoryTrace('start:ready') + + void checkDependencyUpdates(this.credentialManager) + return this } diff --git a/src/sources/gaana.ts b/src/sources/gaana.ts index 2c064255..5f99dfc3 100644 --- a/src/sources/gaana.ts +++ b/src/sources/gaana.ts @@ -1014,7 +1014,10 @@ export default class GaanaSource { query = '' ): Promise | null> { const url = `${API_URL}?${new URLSearchParams( - Object.entries(params).map(([key, value]) => [key, String(value)]) + Object.entries(params).map(([key, value]) => [key, String(value)]) as [ + string, + string + ][] ).toString()}` const proxy = this.getProxyConfig() diff --git a/src/utils.ts b/src/utils.ts index 77fd314a..e4313c77 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import fs from 'node:fs' import http from 'node:http' import http2 from 'node:http2' import https from 'node:https' +import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' import { URL } from 'node:url' @@ -1962,6 +1963,170 @@ async function makeRequest( }) } +/** + * Checks for updates of core dependencies against the NPM registry. + * + * Logs a warning if an update is available for critical packages. + * @public + */ +/** + * Checks for updates of core dependencies against the NPM registry and GitHub. + * + * Logs a warning if an update is available for critical packages. + * @param credentialManager - Persistence manager to rate-limit checks. + * @public + */ +async function checkDependencyUpdates( + credentialManager?: any +): Promise { + const CHECK_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes + const now = Date.now() + + if (credentialManager) { + const lastCheck = credentialManager.get<{ ts: number }>( + 'runtime.dependencies.lastCheck' + ) + if (lastCheck && now - lastCheck.ts < CHECK_INTERVAL_MS) { + return + } + } + + const coreDeps = [ + '@performanc/voice', + '@performanc/pwsl-server', + '@toddynnn/symphonia-decoder', + '@toddynnn/voice-opus', + '@ecliptia/faad2-wasm', + '@alexanderolsen/libsamplerate-js', + '@ecliptia/seekable-stream' + ] + + const require = createRequire(import.meta.url) + const updates: Array<{ + name: string + current: string + latest: string + source: 'NPM' | 'GitHub' + }> = [] + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const deps = (packageJson as any).dependencies || {} + + await Promise.all( + coreDeps.map(async (dep) => { + try { + const declaredVersion = deps[dep] || '' + let currentVersionStr = 'unknown' + try { + const depPath = require.resolve(`${dep}/package.json`) + currentVersionStr = require(depPath).version + } catch { + return + } + + let latestVersionStr = '' + let source: 'NPM' | 'GitHub' = 'NPM' + + if ( + declaredVersion.startsWith('github:') || + (declaredVersion.includes('/') && !declaredVersion.includes(':')) + ) { + source = 'GitHub' + const repo = declaredVersion.replace('github:', '').split('#')[0] + const branch = declaredVersion.split('#')[1] || 'main' + const response = await fetch( + `https://raw.githubusercontent.com/${repo}/${branch}/package.json`, + { signal: controller.signal } + ) + if (response.ok) { + const data = (await response.json()) as { version: string } + latestVersionStr = data.version + } + } else if ( + !declaredVersion.includes(':') && + !declaredVersion.includes('/') + ) { + const response = await fetch( + `https://registry.npmjs.org/${dep}/latest`, + { + signal: controller.signal + } + ) + if (response.ok) { + const data = (await response.json()) as { version: string } + latestVersionStr = data.version + } + } + + if (!latestVersionStr || currentVersionStr === latestVersionStr) return + + const current = parseSemver(currentVersionStr) + const latest = parseSemver(latestVersionStr) + + if (current && latest) { + const isNewer = + latest.major > current.major || + (latest.major === current.major && latest.minor > current.minor) || + (latest.major === current.major && + latest.minor === current.minor && + latest.patch > current.patch) + + if (isNewer) { + updates.push({ + name: dep, + current: currentVersionStr, + latest: latestVersionStr, + source + }) + } + } + } catch { + // Ignore individual failures + } + }) + ) + + if (credentialManager) { + credentialManager.set( + 'runtime.dependencies.lastCheck', + { ts: now }, + 24 * 60 * 60 * 1000 + ) + } + + if (updates.length > 0) { + logger( + 'warn', + 'Server', + 'The following core dependencies have updates available:' + ) + for (const update of updates) { + logger( + 'warn', + 'Server', + ` - ${update.name}: ${update.current} -> \x1b[1m\x1b[32m${update.latest}\x1b[0m (${update.source})` + ) + } + logger( + 'warn', + 'Server', + 'Update recommended for stability. Run "npm install" to update.' + ) + } + } catch (error) { + logger( + 'debug', + 'Server', + `Failed to check dependency updates: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + clearTimeout(timeout) + } +} + /** * Checks for git updates against the upstream branch. * @@ -2392,6 +2557,7 @@ async function fetchSponsorBlockSegments( export { applyEnvOverrides, + checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, From 5e8ba18bbf38448a47eda9c00b776a27d5b69c0e Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 31 May 2026 23:45:32 -0400 Subject: [PATCH 39/58] improve: implement persistent results cache for dependency checks - Replicated Node.js version check logic for dependencies. - Added disk-based caching of latest versions to avoid redundant network requests. - Ensured comparison and logging always occur at startup using cached or fresh data. - Guaranteed log transparency by never skipping the update status report. --- src/utils.ts | 249 ++++++++++++++++++++++++++++----------------------- 1 file changed, 139 insertions(+), 110 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index e4313c77..92658ebf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1979,18 +1979,6 @@ async function makeRequest( async function checkDependencyUpdates( credentialManager?: any ): Promise { - const CHECK_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes - const now = Date.now() - - if (credentialManager) { - const lastCheck = credentialManager.get<{ ts: number }>( - 'runtime.dependencies.lastCheck' - ) - if (lastCheck && now - lastCheck.ts < CHECK_INTERVAL_MS) { - return - } - } - const coreDeps = [ '@performanc/voice', '@performanc/pwsl-server', @@ -2002,128 +1990,169 @@ async function checkDependencyUpdates( ] const require = createRequire(import.meta.url) - const updates: Array<{ - name: string - current: string - latest: string - source: 'NPM' | 'GitHub' - }> = [] + const localVersions: Array<{ name: string; version: string }> = [] - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) + for (const dep of coreDeps) { + try { + const depPath = require.resolve(`${dep}/package.json`) + const version = require(depPath).version + localVersions.push({ name: dep, version }) + } catch { + // Ignore if package not found + } + } - try { - const deps = (packageJson as any).dependencies || {} + if (localVersions.length > 0) { + logger( + 'debug', + 'Server', + `Installed core packages: ${localVersions.map((v) => `${v.name}@${v.version}`).join(', ')}` + ) + } - await Promise.all( - coreDeps.map(async (dep) => { - try { - const declaredVersion = deps[dep] || '' - let currentVersionStr = 'unknown' - try { - const depPath = require.resolve(`${dep}/package.json`) - currentVersionStr = require(depPath).version - } catch { - return - } + const CHECK_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes + const now = Date.now() + let latestVersions: Record = {} + let isCacheValid = false - let latestVersionStr = '' - let source: 'NPM' | 'GitHub' = 'NPM' + if (credentialManager) { + const cache = credentialManager.get<{ + ts: number + versions: Record + }>('runtime.dependencies.latest') + + if (cache && now - cache.ts < CHECK_INTERVAL_MS) { + latestVersions = cache.versions + isCacheValid = true + } + } - if ( - declaredVersion.startsWith('github:') || - (declaredVersion.includes('/') && !declaredVersion.includes(':')) - ) { - source = 'GitHub' - const repo = declaredVersion.replace('github:', '').split('#')[0] - const branch = declaredVersion.split('#')[1] || 'main' - const response = await fetch( - `https://raw.githubusercontent.com/${repo}/${branch}/package.json`, - { signal: controller.signal } - ) - if (response.ok) { - const data = (await response.json()) as { version: string } - latestVersionStr = data.version - } - } else if ( - !declaredVersion.includes(':') && - !declaredVersion.includes('/') - ) { - const response = await fetch( - `https://registry.npmjs.org/${dep}/latest`, - { - signal: controller.signal + if (!isCacheValid) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const deps = (packageJson as any).dependencies || {} + + await Promise.all( + coreDeps.map(async (dep) => { + try { + const declaredVersion = deps[dep] || '' + let latestVersionStr = '' + let source: 'NPM' | 'GitHub' = 'NPM' + + if ( + declaredVersion.startsWith('github:') || + (declaredVersion.includes('/') && !declaredVersion.includes(':')) + ) { + source = 'GitHub' + const repo = declaredVersion.replace('github:', '').split('#')[0] + const branch = declaredVersion.split('#')[1] || 'main' + const response = await fetch( + `https://raw.githubusercontent.com/${repo}/${branch}/package.json`, + { signal: controller.signal } + ) + if (response.ok) { + const data = (await response.json()) as { version: string } + latestVersionStr = data.version + } + } else if ( + !declaredVersion.includes(':') && + !declaredVersion.includes('/') + ) { + const response = await fetch( + `https://registry.npmjs.org/${dep}/latest`, + { + signal: controller.signal + } + ) + if (response.ok) { + const data = (await response.json()) as { version: string } + latestVersionStr = data.version } - ) - if (response.ok) { - const data = (await response.json()) as { version: string } - latestVersionStr = data.version } - } - if (!latestVersionStr || currentVersionStr === latestVersionStr) return - - const current = parseSemver(currentVersionStr) - const latest = parseSemver(latestVersionStr) - - if (current && latest) { - const isNewer = - latest.major > current.major || - (latest.major === current.major && latest.minor > current.minor) || - (latest.major === current.major && - latest.minor === current.minor && - latest.patch > current.patch) - - if (isNewer) { - updates.push({ - name: dep, - current: currentVersionStr, - latest: latestVersionStr, - source - }) + if (latestVersionStr) { + latestVersions[dep] = { version: latestVersionStr, source } } + } catch { + // Ignore individual failures } - } catch { - // Ignore individual failures - } - }) - ) - - if (credentialManager) { - credentialManager.set( - 'runtime.dependencies.lastCheck', - { ts: now }, - 24 * 60 * 60 * 1000 + }) ) - } - if (updates.length > 0) { + if (credentialManager && Object.keys(latestVersions).length > 0) { + credentialManager.set( + 'runtime.dependencies.latest', + { ts: now, versions: latestVersions }, + 24 * 60 * 60 * 1000 + ) + } + } catch (error) { logger( - 'warn', + 'debug', 'Server', - 'The following core dependencies have updates available:' + `Failed to check dependency updates: ${error instanceof Error ? error.message : String(error)}` ) - for (const update of updates) { - logger( - 'warn', - 'Server', - ` - ${update.name}: ${update.current} -> \x1b[1m\x1b[32m${update.latest}\x1b[0m (${update.source})` - ) + } finally { + clearTimeout(timeout) + } + } + + const updates: Array<{ + name: string + current: string + latest: string + source: 'NPM' | 'GitHub' + }> = [] + + for (const local of localVersions) { + const latest = latestVersions[local.name] + if (!latest || local.version === latest.version) continue + + const currentSemver = parseSemver(local.version) + const latestSemver = parseSemver(latest.version) + + if (currentSemver && latestSemver) { + const isNewer = + latestSemver.major > currentSemver.major || + (latestSemver.major === currentSemver.major && + latestSemver.minor > currentSemver.minor) || + (latestSemver.major === currentSemver.major && + latestSemver.minor === currentSemver.minor && + latestSemver.patch > currentSemver.patch) + + if (isNewer) { + updates.push({ + name: local.name, + current: local.version, + latest: latest.version, + source: latest.source + }) } + } + } + + if (updates.length > 0) { + logger( + 'warn', + 'Server', + 'The following core dependencies have updates available:' + ) + for (const update of updates) { logger( 'warn', 'Server', - 'Update recommended for stability. Run "npm install" to update.' + ` - ${update.name}: ${update.current} -> \x1b[1m\x1b[32m${update.latest}\x1b[0m (${update.source})` ) } - } catch (error) { logger( - 'debug', + 'warn', 'Server', - `Failed to check dependency updates: ${error instanceof Error ? error.message : String(error)}` + 'Update recommended for stability. Run "npm install" to update.' ) - } finally { - clearTimeout(timeout) + } else if (Object.keys(latestVersions).length > 0) { + logger('debug', 'Server', 'All core packages are up to date.') } } From 17ac8a9205b00f66d2660eaedd9e8b35f951a15f Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 31 May 2026 23:53:19 -0400 Subject: [PATCH 40/58] improve: promote core package version logs to info level - Changed 'Installed core packages' log level from debug to info. - Changed 'All core packages are up to date' log level from debug to info. - Recognized these as fundamental pieces of information for server status. --- dist/src/index.js | 2 +- dist/src/utils.js | 141 +++++++++++++++++++++++++++++++++++----------- src/index.ts | 2 +- src/utils.ts | 9 +-- 4 files changed, 114 insertions(+), 40 deletions(-) diff --git a/dist/src/index.js b/dist/src/index.js index 53b82f82..092d86d4 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -1834,7 +1834,7 @@ class NodelinkServer extends EventEmitter { } this.connectionManager?.start(); memoryTrace('start:ready'); - void checkDependencyUpdates(); + void checkDependencyUpdates(this.credentialManager ?? undefined); return this; } /** diff --git a/dist/src/utils.js b/dist/src/utils.js index 22bba6b9..fc6b3976 100644 --- a/dist/src/utils.js +++ b/dist/src/utils.js @@ -1608,7 +1608,14 @@ async function makeRequest(urlString, options, nodelink) { * Logs a warning if an update is available for critical packages. * @public */ -async function checkDependencyUpdates() { +/** + * Checks for updates of core dependencies against the NPM registry and GitHub. + * + * Logs a warning if an update is available for critical packages. + * @param credentialManager - Persistence manager to rate-limit checks. + * @public + */ +async function checkDependencyUpdates(credentialManager) { const coreDeps = [ '@performanc/voice', '@performanc/pwsl-server', @@ -1619,48 +1626,114 @@ async function checkDependencyUpdates() { '@ecliptia/seekable-stream' ]; const require = createRequire(import.meta.url); - const updates = []; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - try { - await Promise.all(coreDeps.map(async (dep) => { - try { - let currentVersion = 'unknown'; + const localVersions = []; + for (const dep of coreDeps) { + try { + const depPath = require.resolve(`${dep}/package.json`); + const version = require(depPath).version; + localVersions.push({ name: dep, version }); + } + catch { + // Ignore if package not found + } + } + if (localVersions.length > 0) { + logger('info', 'Server', `Installed core packages: ${localVersions.map((v) => `${v.name}@${v.version}`).join(', ')}`); + } + const CHECK_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + const now = Date.now(); + let latestVersions = {}; + let isCacheValid = false; + if (credentialManager) { + const cache = credentialManager.get('runtime.dependencies.latest'); + if (cache && now - cache.ts < CHECK_INTERVAL_MS) { + latestVersions = cache.versions; + isCacheValid = true; + } + } + if (!isCacheValid) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const deps = packageJson.dependencies || {}; + await Promise.all(coreDeps.map(async (dep) => { try { - const depPath = require.resolve(`${dep}/package.json`); - currentVersion = require(depPath).version; + const declaredVersion = deps[dep] || ''; + let latestVersionStr = ''; + let source = 'NPM'; + if (declaredVersion.startsWith('github:') || + (declaredVersion.includes('/') && !declaredVersion.includes(':'))) { + source = 'GitHub'; + const repo = declaredVersion.replace('github:', '').split('#')[0]; + const branch = declaredVersion.split('#')[1] || 'main'; + const response = await fetch(`https://raw.githubusercontent.com/${repo}/${branch}/package.json`, { signal: controller.signal }); + if (response.ok) { + const data = (await response.json()); + latestVersionStr = data.version; + } + } + else if (!declaredVersion.includes(':') && + !declaredVersion.includes('/')) { + const response = await fetch(`https://registry.npmjs.org/${dep}/latest`, { + signal: controller.signal + }); + if (response.ok) { + const data = (await response.json()); + latestVersionStr = data.version; + } + } + if (latestVersionStr) { + latestVersions[dep] = { version: latestVersionStr, source }; + } } catch { - return; + // Ignore individual failures } - const response = await fetch(`https://registry.npmjs.org/${dep}/latest`, { - signal: controller.signal - }); - if (!response.ok) - return; - const data = (await response.json()); - const latestVersion = data.version; - if (currentVersion !== latestVersion && latestVersion) { - updates.push({ name: dep, current: currentVersion, latest: latestVersion }); - } - } - catch { - // Ignore individual fetch/resolve failures + })); + if (credentialManager && Object.keys(latestVersions).length > 0) { + credentialManager.set('runtime.dependencies.latest', { ts: now, versions: latestVersions }, 24 * 60 * 60 * 1000); } - })); - if (updates.length > 0) { - logger('warn', 'Server', 'The following core dependencies have updates available:'); - for (const update of updates) { - logger('warn', 'Server', ` - ${update.name}: ${update.current} -> \x1b[1m\x1b[32m${update.latest}\x1b[0m (Update recommended for stability)`); + } + catch (error) { + logger('debug', 'Server', `Failed to check dependency updates: ${error instanceof Error ? error.message : String(error)}`); + } + finally { + clearTimeout(timeout); + } + } + const updates = []; + for (const local of localVersions) { + const latest = latestVersions[local.name]; + if (!latest || local.version === latest.version) + continue; + const currentSemver = parseSemver(local.version); + const latestSemver = parseSemver(latest.version); + if (currentSemver && latestSemver) { + const isNewer = latestSemver.major > currentSemver.major || + (latestSemver.major === currentSemver.major && + latestSemver.minor > currentSemver.minor) || + (latestSemver.major === currentSemver.major && + latestSemver.minor === currentSemver.minor && + latestSemver.patch > currentSemver.patch); + if (isNewer) { + updates.push({ + name: local.name, + current: local.version, + latest: latest.version, + source: latest.source + }); } - logger('warn', 'Server', 'Run "npm install" or update your package.json to get the latest improvements.'); } } - catch (error) { - logger('debug', 'Server', `Failed to check dependency updates: ${error instanceof Error ? error.message : String(error)}`); + if (updates.length > 0) { + logger('warn', 'Server', 'The following core dependencies have updates available:'); + for (const update of updates) { + logger('warn', 'Server', ` - ${update.name}: ${update.current} -> \x1b[1m\x1b[32m${update.latest}\x1b[0m (${update.source})`); + } + logger('warn', 'Server', 'Update recommended for stability. Run "npm install" to update.'); } - finally { - clearTimeout(timeout); + else if (Object.keys(latestVersions).length > 0) { + logger('info', 'Server', 'All core packages are up to date.'); } } /** diff --git a/src/index.ts b/src/index.ts index 068649d7..97d071a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2645,7 +2645,7 @@ class NodelinkServer extends EventEmitter { this.connectionManager?.start() memoryTrace('start:ready') - void checkDependencyUpdates(this.credentialManager) + void checkDependencyUpdates(this.credentialManager ?? undefined) return this } diff --git a/src/utils.ts b/src/utils.ts index 92658ebf..233a7fbf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,7 @@ import https from 'node:https' import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' -import { URL } from 'node:url' +import { URL, fileURLToPath } from 'node:url' import util from 'node:util' import zlib from 'node:zlib' @@ -23,6 +23,7 @@ import type { ApiRequest, ApiResponse } from './typings/api/api.types.ts' +import type CredentialManager from './managers/credentialManager.ts' import type { ClientInfo } from './typings/shared.types.ts' import type { BestMatchCandidate, @@ -1977,7 +1978,7 @@ async function makeRequest( * @public */ async function checkDependencyUpdates( - credentialManager?: any + credentialManager?: CredentialManager ): Promise { const coreDeps = [ '@performanc/voice', @@ -2004,7 +2005,7 @@ async function checkDependencyUpdates( if (localVersions.length > 0) { logger( - 'debug', + 'info', 'Server', `Installed core packages: ${localVersions.map((v) => `${v.name}@${v.version}`).join(', ')}` ) @@ -2152,7 +2153,7 @@ async function checkDependencyUpdates( 'Update recommended for stability. Run "npm install" to update.' ) } else if (Object.keys(latestVersions).length > 0) { - logger('debug', 'Server', 'All core packages are up to date.') + logger('info', 'Server', 'All core packages are up to date.') } } From 8b1ada1c5e31db3fbcf9382f372aaf4048b271c1 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Mon, 1 Jun 2026 12:55:11 -0300 Subject: [PATCH 41/58] update: disable monochrome by default this source is really unstable during the past 2 months, so it will be disabled by default. --- config.default.ts | 2 +- dist/config.default.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.default.ts b/config.default.ts index 817ed535..e680f7c9 100644 --- a/config.default.ts +++ b/config.default.ts @@ -580,7 +580,7 @@ export const config: NodelinkConfig = { }, monochrome: { - enabled: true, + enabled: false, instances: [], streamingInstances: [], quality: 'HI_RES_LOSSLESS', diff --git a/dist/config.default.js b/dist/config.default.js index ba1b27ff..ddb84a97 100644 --- a/dist/config.default.js +++ b/dist/config.default.js @@ -549,7 +549,7 @@ export const config = { allowExplicit: true }, monochrome: { - enabled: true, + enabled: false, instances: [], streamingInstances: [], quality: 'HI_RES_LOSSLESS', From 3e9d045fda42b10801fd04e97cc3d69231334fe6 Mon Sep 17 00:00:00 2001 From: ToddyTheNoobDud Date: Thu, 4 Jun 2026 10:21:48 -0300 Subject: [PATCH 42/58] improve: refactor bun support & move to a new file Moved all bun's related server handling to a new file, so the code can be more organized when maintaining both node and bun. This also helps unify the logic from both sides without many specific changes. refactored cleanupBunServer to send proper close frames instead of force killing the TCP, this should fix some weird session ID behavior on the client side. Refactored the session resume handler, before if a client sent a session-id which points to a non-resumable session, it would be set to null during upgrade, this would also cause the weird behavior on client side. Now it will fall back to create a new session ID. Added an early check on the heartbeat system that checks for bun, since bun has sendPings natively, it prevents sending double pings. --- dist/package.json | 2 +- dist/src/index.js | 634 ++++++------------------ dist/src/server/bunServer.js | 549 +++++++++++++++++++++ dist/src/typings/index.types.js | 15 +- dist/src/utils.js | 10 - package.json | 2 +- src/index.ts | 848 ++++++++------------------------ src/server/bunServer.ts | 726 +++++++++++++++++++++++++++ src/typings/index.types.ts | 39 +- src/utils.ts | 24 +- 10 files changed, 1642 insertions(+), 1207 deletions(-) create mode 100644 dist/src/server/bunServer.js create mode 100644 src/server/bunServer.ts diff --git a/dist/package.json b/dist/package.json index 4ab15ae8..d26195f9 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260531.1", + "version": "3.8.1-dev.20260604.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/dist/src/index.js b/dist/src/index.js index 092d86d4..5e64a0e3 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -17,6 +17,7 @@ import SessionManager from "./managers/sessionManager.js"; import StatsManager from "./managers/statsManager.js"; import { migrateConfig, persistConfig } from "./modules/config/configMigration.js"; import { applyEnvOverrides, checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, getGitInfo, getStats, getVersion, initLogger, logger, parseClient, verifyDiscordID } from "./utils.js"; +import { cleanupBunServer, createBunServer } from "./server/bunServer.js"; import 'dotenv/config'; import { GatewayEvents, MINIMUM_NODE_VERSION } from "./constants.js"; import ConfigValidationManager from "./managers/configValidationManager.js"; @@ -271,102 +272,6 @@ if (!cluster.isWorker) { } await checkForUpdates(); memoryTrace('bootstrap:after-check-for-updates'); -/** - * Wrapper for Bun's ServerWebSocket that implements EventEmitter - * Provides compatibility with Node.js WebSocket implementations - */ -class BunSocketWrapper extends EventEmitter { - ws; - remoteAddress; - /** - * Creates a new BunSocketWrapper - * @param ws - Bun ServerWebSocket instance - */ - constructor(ws) { - super(); - this.ws = ws; - this.remoteAddress = ws?.data?.remoteAddress || 'unknown'; - } - /** - * Sends data through the WebSocket connection - * @param data - Data to send - * @returns True if sent successfully - */ - /** - * Sends data through the WebSocket connection - * @param data - Data to send - * @returns True if sent successfully - * @public - */ - send(data) { - try { - const r = this.ws.send(data); - return r !== 0; - } - catch { - return false; - } - } - /** - * Sends a WebSocket ping frame - * @param data - Optional ping data - * @returns True if sent successfully - * @public - */ - ping(data) { - try { - this.ws.ping?.(data); - return true; - } - catch { - return false; - } - } - /** - * Closes the connection. - * - * Here is a list of close codes: - * - `1000` means "normal closure" **(default)** - * - `1009` means a message was too big and was rejected - * - `1011` means the server encountered an error - * - `1012` means the server is restarting - * - `1013` means the server is too busy or the client is rate-limited - * - `4000` through `4999` are reserved for applications (you can use it!) - * - * To close the connection abruptly, use `terminate()`. - * - * @param code The close code to send - * @param reason The close reason to send - * @public - */ - close(code, reason) { - this.ws.close(code, reason); - } - /** - * Terminates the connection immediately - * @public - */ - terminate() { - this.ws.close(1000, 'Terminated'); - } - /** - * Internal handler for received messages - * @param message - Message data - * @internal - */ - _handleMessage(message) { - this.emit('message', message); - } - /** - * Internal handler for connection close events - * @param code - Close code - * @param reason - Close reason - * @internal - */ - _handleClose(code, reason) { - this.emit('close', code, reason); - } -} /** * Main NodeLink server class * Handles WebSocket connections, audio sources, and player management @@ -548,12 +453,18 @@ class NodelinkServer extends EventEmitter { await this._persistenceManagersInitPromise; } /** - * Starts the heartbeat interval to keep WebSocket connections alive + * Starts the heartbeat interval to keep WebSocket connections alive. + * + * No-op when running under `Bun.serve` because Bun handles WebSocket pings + * natively via `websocket.sendPings: true`. Running both would double the + * keepalive traffic. * @internal */ _startHeartbeat() { if (this._heartbeatInterval) return; + if (this._usingBunServer) + return; this._heartbeatInterval = setInterval(() => { for (const session of this.sessions.activeSessions.values()) { if (session.socket && !session.isPaused) { @@ -707,60 +618,64 @@ class NodelinkServer extends EventEmitter { return originalOn(event, listener); }; logger('debug', 'Resume', `Processing websocket connection. oldSessionId: ${oldSessionId}`); + let session = null; if (oldSessionId) { - const session = this.sessions.resume(oldSessionId, socket); - if (session) { - logger('info', 'Server', `\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.on('close', (...args) => { - const code = args[0]; - const reason = args[1]; - if (!this.sessions.has(oldSessionId)) - return; - const session = this.sessions.get(oldSessionId); - if (!session) - return; - logger('info', 'Server', `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${clientInfo.version}\x1b[0m disconnected with code ${code} and reason: ${reason || 'without reason'}`); - if (session.resuming) { - this.sessions.pause(oldSessionId); - } - else { - this.sessions.shutdown(oldSessionId); - } - const sessionCount = this.sessions.activeSessions?.size || 0; - this.statsManager.setWebsocketConnections(sessionCount); - }); - socket.send(JSON.stringify({ - op: 'ready', - resumed: true, - sessionId: oldSessionId - })); - while (session.eventQueue.length > 0) { - const event = session.eventQueue.shift(); - if (event) - socket.send(event); + session = this.sessions.resume(oldSessionId, socket); + if (!session) { + logger('warn', 'Server', `Session-ID provided by ${clientInfo.name} does not exist or is not resumable: ${oldSessionId}, creating a new session`); + } + } + if (session) { + logger('info', 'Server', `\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.on('close', (...args) => { + const code = args[0]; + const reason = args[1]; + if (!this.sessions.has(oldSessionId)) + return; + const session = this.sessions.get(oldSessionId); + if (!session) + return; + logger('info', 'Server', `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${clientInfo.version}\x1b[0m disconnected with code ${code} and reason: ${reason || 'without reason'}`); + if (session.resuming) { + this.sessions.pause(oldSessionId); } - for (const [playerKey, playerInfo] of session.players.players.entries()) { - if (this.workerManager) { - const worker = this.workerManager.getWorkerForGuild(playerKey); - if (worker) { - this.workerManager.execute(worker, 'playerCommand', { - sessionId: session.id, - guildId: playerInfo.guildId, - command: 'forceUpdate', - args: [] - }); - } - } - else { - playerInfo._sendUpdate(); - } + else { + this.sessions.shutdown(oldSessionId); } const sessionCount = this.sessions.activeSessions?.size || 0; this.statsManager.setWebsocketConnections(sessionCount); + }); + socket.send(JSON.stringify({ + op: 'ready', + resumed: true, + sessionId: oldSessionId + })); + while (session.eventQueue.length > 0) { + const event = session.eventQueue.shift(); + if (event) + socket.send(event); } + for (const [playerKey, playerInfo] of session.players.players.entries()) { + if (this.workerManager) { + const worker = this.workerManager.getWorkerForGuild(playerKey); + if (worker) { + this.workerManager.execute(worker, 'playerCommand', { + sessionId: session.id, + guildId: playerInfo.guildId, + command: 'forceUpdate', + args: [] + }); + } + } + else { + playerInfo._sendUpdate(); + } + } + const sessionCount = this.sessions.activeSessions?.size || 0; + this.statsManager.setWebsocketConnections(sessionCount); } else { const sessionId = this.sessions.create(request, socket, clientInfo); @@ -937,275 +852,90 @@ class NodelinkServer extends EventEmitter { }, allocEveryMs); } }); - } - /** - * Creates and configures Bun HTTP server with WebSocket support - * @internal - */ - _createBunServer() { - const port = this.options.server.port; - const host = this.options.server.host || '0.0.0.0'; - const password = this.options.server.password; - const self = this; - logger('warn', 'Server', 'Running with Bun.serve, remember this is experimental!'); - this.server = Bun.serve({ - port, - hostname: host, - maxRequestBodySize: 1024 * 1024 * 50, - async fetch(req, server) { - const url = new URL(req.url); - const pathname = url.pathname.endsWith('/') - ? url.pathname.slice(0, -1) - : url.pathname; - if (pathname === '/v4/profiler/socket') { - const remoteAddress = server.requestIP(req)?.address || 'unknown'; - const isInternal = /^(::1|localhost|127\.0\.0\.1)/.test(remoteAddress); - const endpoint = self.options.cluster?.endpoint || {}; - const patchEnabled = endpoint.patchEnabled === true; - const allowExternalPatch = endpoint.allowExternalPatch === true; - const expectedCode = typeof endpoint.code === 'string' && endpoint.code.length > 0 - ? endpoint.code - : 'CAPYBARA'; - const providedCode = url.searchParams.get('code') || - req.headers.get('x-nodelink-code') || - req.headers.get('x-worker-code'); - if (!patchEnabled) { - return new Response('Profiler socket endpoint is disabled.', { - status: 403, - statusText: 'Forbidden' - }); - } - if (!allowExternalPatch && !isInternal) { - return new Response('External profiler socket access is blocked.', { - status: 403, - statusText: 'Forbidden' - }); - } - if (!providedCode || providedCode !== expectedCode) { - return new Response('Invalid or missing profiler code.', { - status: 403, - statusText: 'Forbidden' - }); - } - const success = server.upgrade(req, { - data: { - clientInfo: { name: 'ProfilerUI', version: '1' }, - sessionId: null, - reqHeaders: Object.fromEntries(req.headers), - remoteAddress, - url: req.url - } - }); - if (success) - return undefined; - return new Response('WebSocket upgrade failed', { status: 400 }); + this.socket?.on('/v4/websocket/voice', (socket, request, _clientInfo, _sessionId, guildId) => { + socket.guildId = guildId; + if (!this.options.playback.voiceReceive?.enabled) { + try { + socket.close(1008, 'Voice receive disabled'); } - if (pathname === '/v4/websocket') { - const remoteAddress = server.requestIP(req)?.address || 'unknown'; - const clientAddress = `[External] (${remoteAddress})`; - const clientName = req.headers.get('client-name'); - const auth = req.headers.get('authorization'); - const userId = req.headers.get('user-id'); - const sessionId = req.headers.get('session-id'); - if (auth !== password) { - logger('warn', 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid password provided: ${auth || 'None'}`); - return new Response('Invalid password provided.', { - status: 401, - statusText: 'Unauthorized', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }); - } - if (!clientName) { - logger('warn', 'Server', `Missing client-name from ${clientAddress}`); - return new Response('Invalid or missing Client-Name header.', { - status: 400, - statusText: 'Bad Request', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }); - } - if (!userId || !verifyDiscordID(userId)) { - logger('warn', 'Server', `Invalid user ID from ${clientAddress}`); - return new Response('Invalid or missing User-Id header.', { - status: 400, - statusText: 'Bad Request', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }); - } - const clientInfo = parseClient(clientName); - if (!clientInfo) { - logger('warn', 'Server', `Invalid client-name from ${clientAddress}`); - return new Response('Invalid or missing Client-Name header.', { - status: 400, - statusText: 'Bad Request', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }); + catch { } + return; + } + logger('info', 'Voice', `Voice websocket connected from ${request.socket?.remoteAddress || 'unknown'} | guild ${guildId}`); + this.registerVoiceSocket(guildId, socket); + }); + this.socket?.on('/v4/websocket/youtube/live', (socket, request, _clientInfo, _sessionId, id) => { + let videoId = id; + socket.guildId = id; // Tag it with videoId or guildId equivalent + if (/^\d{17,20}$/.test(id)) { + const player = this.sessions.getPlayer(id); + if (player?.track?.info?.sourceName?.includes('youtube')) { + videoId = player.track.info.identifier; + } + } + else if (id.length > 50) { + try { + const decoded = decodeTrack(id); + if (decoded?.info?.sourceName?.includes('youtube')) { + videoId = decoded.info.identifier; } - const success = server.upgrade(req, { - data: { - clientInfo, - sessionId, - reqHeaders: Object.fromEntries(req.headers), - remoteAddress, - url: req.url - } - }); - if (success) - return undefined; - return new Response('WebSocket upgrade failed', { - status: 400, - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }); } - return new Promise((resolve) => { - const reqShim = { - method: req.method, - url: url.pathname + url.search, - headers: Object.fromEntries(req.headers), - socket: { remoteAddress: server.requestIP(req)?.address }, - on: (event, cb) => { - if (event === 'data') { - req - .arrayBuffer() - .then((buf) => { - cb(Buffer.from(buf)); - if (reqShim._endCb) - reqShim._endCb(); - }) - .catch(() => { }); - } - if (event === 'end') { - reqShim._endCb = cb; - } - } - }; - const resShim = { - _status: 200, - _headers: {}, - _body: [], - writeHead(status, headers) { - this._status = status; - if (headers) - Object.assign(this._headers, headers); - }, - setHeader(name, value) { - this._headers[name] = value; - }, - getHeader(name) { - return this._headers[name]; - }, - end(data) { - if (data) - this._body.push(data); - const finalBody = Buffer.concat(this._body.map((chunk) => Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - const headers = new Headers(); - for (const [key, value] of Object.entries(this._headers)) { - if (Array.isArray(value)) { - for (const v of value) - headers.append(key, v); - } - else if (value !== undefined) { - headers.set(key, String(value)); - } - } - const response = new Response(finalBody, { - status: this._status, - headers - }); - resolve(response); - }, - write(data) { - if (data) - this._body.push(data); - } - }; - void getRequestHandler() - .then((handler) => handler(self, reqShim, resShim)) - .catch((error) => { - logger('error', 'Server', `Failed to handle Bun request: ${error.message}`); - if (!resShim._status || resShim._status < 400) { - resShim.writeHead(500, { 'Content-Type': 'text/plain' }); - } - resShim.end('Internal Server Error'); + catch (_e) { } + } + if (!this.sourceWorkerManager) { + const yt = this.sources?.getSource('youtube'); + if (!yt) { + socket.close(1008, 'YouTube source not enabled'); + return; + } + const liveChatFn = yt.handleLiveChat; + if (typeof liveChatFn === 'function') { + liveChatFn.call(yt, socket, videoId); + } + else { + socket.close(1008, 'YouTube live chat not supported'); + } + return; + } + logger('info', 'YouTube-LiveChat', `Delegating live chat for video: ${videoId} to worker`); + const resShim = { + headersSent: false, + send: (data) => { + const payload = Buffer.isBuffer(data) + ? data + : Buffer.from(String(data)); + socket.sendFrame?.(payload, { + len: payload.length, + fin: true, + opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 }); - }); - }, - websocket: { - sendPings: true, - data: {}, - open(ws) { - if (!ws.data) - return; - const wrapper = new BunSocketWrapper(ws); - ws.data.wrapper = wrapper; - const { clientInfo, sessionId, reqHeaders } = ws.data; - const reqShim = { - headers: reqHeaders, - url: ws.data.url, - socket: { remoteAddress: ws.data.remoteAddress } - }; - let pathname = '/v4/websocket'; - try { - pathname = new URL(ws.data.url).pathname; - } - catch { } - if (pathname === '/v4/profiler/socket') { - logger('info', 'ProfilerSocket', `Profiler socket connected from [External] (${ws.data.remoteAddress})`); - self.socket?.emit('/v4/profiler/socket', wrapper, reqShim, null, null); - return; - } - logger('info', 'Server', `\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}`); - let eventName = '/v4/websocket'; - let guildId = null; - let liveId = null; - try { - const url = new URL(ws.data.url); - const voiceMatch = url.pathname.match(/^\/v4\/websocket\/voice\/([A-Za-z0-9]+)\/?$/); - const liveMatch = url.pathname.match(/^\/v4\/websocket\/youtube\/live\/([^/]+)\/?$/); - if (voiceMatch) { - if (!self.options.playback.voiceReceive?.enabled) { - try { - wrapper.close(1008, 'Voice receive disabled'); - } - catch { } - return; - } - eventName = '/v4/websocket/voice'; - guildId = voiceMatch[1]; - } - else if (liveMatch) { - eventName = '/v4/websocket/youtube/live'; - liveId = liveMatch[1]; - } - } - catch { } - if (self.socket) { - self.socket.emit(eventName, wrapper, reqShim, clientInfo, sessionId, guildId || liveId); - } }, - message(ws, message) { - ws.data?.wrapper?._handleMessage(message); + writeHead: (status) => { + if (status !== 200) + socket.close(1011, 'Worker failed'); }, - close(ws, code, reason) { - ws.data?.wrapper?._handleClose(code, reason); - } - } + write: (data) => { + const payload = Buffer.isBuffer(data) + ? data + : Buffer.from(String(data)); + socket.sendFrame?.(payload, { + len: payload.length, + fin: true, + opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 + }); + }, + end: () => socket.close(1000, 'Finished'), + on: (event, cb) => socket.on(event, cb) + }; + this.sourceWorkerManager.delegate(request, resShim, 'loadLiveChat', { videoId }, { isWebSocket: true }); }); - logger('started', 'Server', `Successfully listening on ${host}:${port} (Bun Native)`); + } + /** + * Creates and configures Bun HTTP server with WebSocket support + * @internal + */ + _createBunServer() { + this.server = createBunServer(this, getRequestHandler); } /** * Creates HTTP server (Node.js or Bun) @@ -1400,83 +1130,6 @@ class NodelinkServer extends EventEmitter { return rejectUpgrade(404, 'Not Found', 'Invalid path for WebSocket upgrade.'); } }); - this.socket?.on('/v4/websocket/voice', (socket, request, _clientInfo, _sessionId, guildId) => { - socket.guildId = guildId; - if (!this.options.playback.voiceReceive?.enabled) { - try { - socket.close(1008, 'Voice receive disabled'); - } - catch { } - return; - } - logger('info', 'Voice', `Voice websocket connected from ${request.socket?.remoteAddress || 'unknown'} | guild ${guildId}`); - this.registerVoiceSocket(guildId, socket); - }); - this.socket?.on('/v4/websocket/youtube/live', (socket, request, _clientInfo, _sessionId, id) => { - let videoId = id; - socket.guildId = id; // Tag it with videoId or guildId equivalent - if (/^\d{17,20}$/.test(id)) { - const player = this.sessions.getPlayer(id); - if (player?.track?.info?.sourceName?.includes('youtube')) { - videoId = player.track.info.identifier; - } - } - else if (id.length > 50) { - try { - const decoded = decodeTrack(id); - if (decoded?.info?.sourceName?.includes('youtube')) { - videoId = decoded.info.identifier; - } - } - catch (_e) { } - } - if (!this.sourceWorkerManager) { - const yt = this.sources?.getSource('youtube'); - if (!yt) { - socket.close(1008, 'YouTube source not enabled'); - return; - } - const liveChatFn = yt.handleLiveChat; - if (typeof liveChatFn === 'function') { - liveChatFn.call(yt, socket, videoId); - } - else { - socket.close(1008, 'YouTube live chat not supported'); - } - return; - } - logger('info', 'YouTube-LiveChat', `Delegating live chat for video: ${videoId} to worker`); - const resShim = { - headersSent: false, - send: (data) => { - const payload = Buffer.isBuffer(data) - ? data - : Buffer.from(String(data)); - socket.sendFrame?.(payload, { - len: payload.length, - fin: true, - opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 - }); - }, - writeHead: (status) => { - if (status !== 200) - socket.close(1011, 'Worker failed'); - }, - write: (data) => { - const payload = Buffer.isBuffer(data) - ? data - : Buffer.from(String(data)); - socket.sendFrame?.(payload, { - len: payload.length, - fin: true, - opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 - }); - }, - end: () => socket.close(1000, 'Finished'), - on: (event, cb) => socket.on(event, cb) - }; - this.sourceWorkerManager.delegate(request, resShim, 'loadLiveChat', { videoId }, { isWebSocket: true }); - }); } /** * Starts listening on configured port and host @@ -1610,16 +1263,7 @@ class NodelinkServer extends EventEmitter { */ async _cleanupWebSocketServer() { if (this._usingBunServer && this.server) { - try { - logger('info', 'WebSocket', 'Stopping Bun server...'); - await this.server.stop(true); - this.server.unref(); - logger('info', 'WebSocket', 'Bun server stopped successfully'); - } - catch (e) { - const error = e; - logger('error', 'WebSocket', `Error stopping Bun server: ${error?.message ?? String(e)}`); - } + await cleanupBunServer(this, this.server); return; } if (this.socket) { @@ -1796,7 +1440,7 @@ class NodelinkServer extends EventEmitter { if (startOptions.isClusterWorker) { logger('info', 'Server', 'Running as cluster worker — waiting for sockets from master.'); process.on('message', (msg, handle) => { - if (!msg || msg.type !== 'sticky-session') + if (msg?.type !== 'sticky-session') return; if (!handle) return; diff --git a/dist/src/server/bunServer.js b/dist/src/server/bunServer.js new file mode 100644 index 00000000..3af4c6ed --- /dev/null +++ b/dist/src/server/bunServer.js @@ -0,0 +1,549 @@ +import { EventEmitter } from 'node:events'; +import { logger, parseClient, verifyDiscordID } from "../utils.js"; +const VOICE_PATH_RE = /^\/v4\/websocket\/voice\/([A-Za-z0-9]+)\/?$/; +const LIVE_PATH_RE = /^\/v4\/websocket\/youtube\/live\/([^/]+)\/?$/; +/** + * Wrapper for Bun's ServerWebSocket that implements EventEmitter + * Provides compatibility with Node.js WebSocket implementations + */ +export class BunSocketWrapper extends EventEmitter { + ws; + remoteAddress; + /** + * Creates a new BunSocketWrapper + * @param ws - Bun ServerWebSocket instance + */ + constructor(ws) { + super(); + this.ws = ws; + this.remoteAddress = ws?.data?.remoteAddress || 'unknown'; + } + /** + * Sends data through the WebSocket connection + * @param data - Data to send + * @returns True if sent successfully + * @public + */ + send(data) { + try { + const r = this.ws.send(data); + return r !== 0; + } + catch { + return false; + } + } + /** + * Sends a WebSocket ping frame + * @param data - Optional ping data + * @returns True if sent successfully + * @public + */ + ping(data) { + try { + this.ws.ping?.(data); + return true; + } + catch { + return false; + } + } + /** + * Closes the connection. + * + * - `1000` means "normal closure" **(default)** + * - `1009` means a message was too big and was rejected + * - `1011` means the server encountered an error + * - `1012` means the server is restarting + * - `1013` means the server is too busy or the client is rate-limited + * - `4000` through `4999` are reserved for applications (you can use it!) + * + * To close the connection abruptly, use `terminate()`. + * + * @param code The close code to send + * @param reason The close reason to send + * @public + */ + close(code, reason) { + this.ws.close(code, reason); + } + /** + * Terminates the connection immediately + * @public + */ + terminate() { + this.ws.close(1000, 'Terminated'); + } + /** + * Internal handler for received messages + * @param message - Message data + * @internal + */ + _handleMessage(message) { + this.emit('message', message); + } + /** + * Internal handler for connection close events + * @param code - Close code + * @param reason - Close reason + * @internal + */ + _handleClose(code, reason) { + this.emit('close', code, reason); + } +} +/** + * Creates and configures Bun HTTP server with WebSocket support + * @param context - Server context (NodelinkServer subset) + * @param getRequestHandler - Lazy loader for the API request handler + * @returns The created Bun server instance + * @internal + */ +export function createBunServer(context, getRequestHandler) { + const port = context.options.server.port; + const host = context.options.server.host || '0.0.0.0'; + const password = context.options.server.password; + logger('warn', 'Server', 'Running with Bun.serve, remember this is experimental!'); + const server = Bun.serve({ + port, + hostname: host, + maxRequestBodySize: 1024 * 1024 * 50, + idleTimeout: 60, + error(error) { + logger('error', 'Server', `HTTP server error: ${error instanceof Error ? error.message : String(error)}`); + return new Response('Internal Server Error', { status: 500 }); + }, + async fetch(req, server) { + const url = new URL(req.url); + const pathname = url.pathname.endsWith('/') + ? url.pathname.slice(0, -1) + : url.pathname; + if (pathname === '/v4/profiler/socket') { + const remoteAddress = server.requestIP(req)?.address || 'unknown'; + const isInternal = /^(::1|localhost|127\.0\.0\.1)/.test(remoteAddress); + const endpoint = context.options.cluster?.endpoint || {}; + const patchEnabled = endpoint.patchEnabled === true; + const allowExternalPatch = endpoint.allowExternalPatch === true; + const expectedCode = typeof endpoint.code === 'string' && endpoint.code.length > 0 + ? endpoint.code + : 'CAPYBARA'; + const providedCode = url.searchParams.get('code') || + req.headers.get('x-nodelink-code') || + req.headers.get('x-worker-code'); + if (!patchEnabled) { + return new Response('Profiler socket endpoint is disabled.', { + status: 403, + statusText: 'Forbidden' + }); + } + if (!allowExternalPatch && !isInternal) { + return new Response('External profiler socket access is blocked.', { + status: 403, + statusText: 'Forbidden' + }); + } + if (!providedCode || providedCode !== expectedCode) { + return new Response('Invalid or missing profiler code.', { + status: 403, + statusText: 'Forbidden' + }); + } + const success = server.upgrade(req, { + data: { + clientInfo: { name: 'ProfilerUI', version: '1' }, + sessionId: null, + reqHeaders: Object.fromEntries(req.headers), + remoteAddress, + url: req.url, + pathname, + eventName: '/v4/profiler/socket', + routeId: null + } + }); + if (success) + return undefined; + return new Response('WebSocket upgrade failed', { status: 400 }); + } + const voiceMatch = pathname.match(VOICE_PATH_RE); + const liveMatch = pathname.match(LIVE_PATH_RE); + const isMainWs = pathname === '/v4/websocket'; + if (isMainWs || voiceMatch || liveMatch) { + const remoteAddress = server.requestIP(req)?.address || 'unknown'; + const clientAddress = `[External] (${remoteAddress})`; + const clientName = req.headers.get('client-name'); + const auth = req.headers.get('authorization'); + const userId = req.headers.get('user-id'); + let sessionId = req.headers.get('session-id'); + if (auth !== password) { + logger('warn', 'Server', `Unauthorized connection attempt from ${clientAddress} - Invalid password provided: ${auth || 'None'}`); + return new Response('Invalid password provided.', { + status: 401, + statusText: 'Unauthorized', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }); + } + if (!clientName) { + logger('warn', 'Server', `Missing client-name from ${clientAddress}`); + return new Response('Invalid or missing Client-Name header.', { + status: 400, + statusText: 'Bad Request', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }); + } + if (!userId || !verifyDiscordID(userId)) { + logger('warn', 'Server', `Invalid user ID from ${clientAddress}`); + return new Response('Invalid or missing User-Id header.', { + status: 400, + statusText: 'Bad Request', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }); + } + const clientInfo = parseClient(clientName); + if (!clientInfo) { + logger('warn', 'Server', `Invalid client-name from ${clientAddress}`); + return new Response('Invalid or missing Client-Name header.', { + status: 400, + statusText: 'Bad Request', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }); + } + let eventName = '/v4/websocket'; + let routeId = null; + if (voiceMatch) { + if (!context.options.playback.voiceReceive?.enabled) { + return new Response('Voice receive disabled.', { + status: 404, + statusText: 'Not Found', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }); + } + eventName = '/v4/websocket/voice'; + routeId = voiceMatch[1] ?? null; + } + else if (liveMatch) { + eventName = '/v4/websocket/youtube/live'; + routeId = liveMatch[1] ?? null; + } + if (sessionId && !context.sessions.resumableSessions.has(sessionId)) { + logger('warn', 'Server', `Session-ID provided by ${clientAddress} does not exist or is not resumable: ${sessionId}, creating a new session`); + sessionId = null; + } + const success = server.upgrade(req, { + data: { + clientInfo, + sessionId, + reqHeaders: Object.fromEntries(req.headers), + remoteAddress, + url: req.url, + pathname, + eventName, + routeId + } + }); + if (success) + return undefined; + return new Response('WebSocket upgrade failed', { + status: 400, + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }); + } + return new Promise((resolve) => { + const dataListeners = []; + const endListeners = []; + const errorListeners = []; + let bodyTriggered = false; + const triggerBodyRead = () => { + if (bodyTriggered) + return; + bodyTriggered = true; + const hasBody = req.headers.get('content-length') || + req.headers.get('transfer-encoding'); + if (!hasBody && (req.method === 'GET' || req.method === 'HEAD')) { + queueMicrotask(() => { + for (const cb of endListeners) { + try { + cb(); + } + catch (e) { + logger('debug', 'Server', `Bun reqShim end listener threw: ${e.message}`); + } + } + }); + return; + } + req + .arrayBuffer() + .then((buf) => { + const chunk = Buffer.from(buf); + if (chunk.length > 0) { + for (const cb of dataListeners) { + try { + cb(chunk); + } + catch (e) { + logger('debug', 'Server', `Bun reqShim data listener threw: ${e.message}`); + } + } + } + for (const cb of endListeners) { + try { + cb(); + } + catch (e) { + logger('debug', 'Server', `Bun reqShim end listener threw: ${e.message}`); + } + } + }) + .catch((err) => { + const error = err instanceof Error ? err : new Error(String(err)); + if (errorListeners.length > 0) { + for (const cb of errorListeners) { + try { + cb(error); + } + catch { } + } + } + else { + logger('debug', 'Server', `Bun request body read failed: ${error.message}`); + for (const cb of endListeners) { + try { + cb(); + } + catch { } + } + } + }); + }; + const reqShim = { + method: req.method, + url: url.pathname + url.search, + headers: Object.fromEntries(req.headers), + socket: { remoteAddress: server.requestIP(req)?.address }, + on: (event, cb) => { + if (event === 'data') { + dataListeners.push(cb); + triggerBodyRead(); + } + else if (event === 'end') { + endListeners.push(cb); + triggerBodyRead(); + } + else if (event === 'error') { + errorListeners.push(cb); + } + } + }; + const resShim = { + _status: 200, + _headers: {}, + _body: [], + writeHead(status, headers) { + this._status = status; + if (headers) + Object.assign(this._headers, headers); + }, + setHeader(name, value) { + this._headers[name] = value; + }, + getHeader(name) { + return this._headers[name]; + }, + end(data) { + if (data) + this._body.push(data); + let finalBody; + if (this._body.length === 0) { + finalBody = ''; + } + else if (this._body.length === 1 && + Buffer.isBuffer(this._body[0])) { + finalBody = this._body[0]; + } + else { + finalBody = Buffer.concat(this._body.map((chunk) => Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + } + const headers = new Headers(); + for (const [key, value] of Object.entries(this._headers)) { + if (Array.isArray(value)) { + for (const v of value) + headers.append(key, v); + } + else if (value !== undefined) { + headers.set(key, String(value)); + } + } + const response = new Response(finalBody, { + status: this._status, + headers + }); + resolve(response); + }, + write(data) { + if (data) + this._body.push(data); + } + }; + void getRequestHandler() + .then((handler) => handler(context, reqShim, resShim)) + .catch((error) => { + logger('error', 'Server', `Failed to handle Bun request: ${error.message}`); + if (!resShim._status || resShim._status < 400) { + resShim.writeHead(500, { 'Content-Type': 'text/plain' }); + } + resShim.end('Internal Server Error'); + }); + }); + }, + websocket: { + sendPings: true, + idleTimeout: 120, + maxPayloadLength: 1024 * 1024 * 16, + data: {}, + open(ws) { + if (!ws.data) { + try { + ws.close(1011, 'Missing socket data'); + } + catch { } + return; + } + const wrapper = new BunSocketWrapper(ws); + ws.data.wrapper = wrapper; + const { clientInfo, sessionId, reqHeaders, pathname, eventName, routeId } = ws.data; + const reqShim = { + headers: reqHeaders, + url: ws.data.url, + socket: { remoteAddress: ws.data.remoteAddress } + }; + if (pathname === '/v4/profiler/socket') { + logger('info', 'ProfilerSocket', `Profiler socket connected from [External] (${ws.data.remoteAddress})`); + context.socket?.emit('/v4/profiler/socket', wrapper, reqShim, null, null); + return; + } + logger('info', 'Server', `\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}`); + if (context.socket) { + context.socket.emit(eventName ?? '/v4/websocket', wrapper, reqShim, clientInfo, sessionId, routeId ?? null); + } + }, + message(ws, message) { + const wrapper = ws.data?.wrapper; + if (!wrapper) { + logger('debug', 'WebSocket', `Bun message received without wrapper (remote: ${ws.data?.remoteAddress || 'unknown'})`); + return; + } + wrapper._handleMessage(message); + }, + close(ws, code, reason) { + const wrapper = ws.data?.wrapper; + if (!wrapper) { + logger('debug', 'WebSocket', `Bun close received without wrapper (code: ${code}, remote: ${ws.data?.remoteAddress || 'unknown'})`); + return; + } + wrapper._handleClose(code, reason); + }, + // `error` is documented in Bun.serve docs but missing from + // bun-types@1.3.14 typings; cast keeps runtime behaviour while + // satisfying the type checker. + ...{ + error(ws, err) { + logger('error', 'WebSocket', `Bun WebSocket error from ${ws.data?.remoteAddress || 'unknown'}: ${err.message}`); + const wrapper = ws.data?.wrapper; + if (wrapper && wrapper.listenerCount('error') > 0) { + try { + wrapper.emit('error', err); + } + catch { } + } + } + } + } + }); + logger('started', 'Server', `Successfully listening on ${host}:${port} (Bun Native)`); + return server; +} +/** + * Cleans up Bun WebSocket server resources + * @param context - Server context + * @param server - The Bun server instance to clean up + * @internal + */ +export async function cleanupBunServer(context, server) { + try { + logger('info', 'WebSocket', 'Stopping Bun server...'); + // Gracefully close every active session with the same code Node uses, + // so clients see a clean 1000 close frame and reconnect normally. + // Without this, Bun.stop(true) tears TCP connections down without + // sending close frames, surfacing as ECONNRESET on the client. + let closedCount = 0; + for (const session of context.sessions.activeSessions.values()) { + if (!session.socket) + continue; + try { + session.socket.close(1000, 'Server shutdown'); + closedCount++; + } + catch (_e) { + try { + ; + session.socket.destroy?.(); + } + catch (_destroyErr) { + logger('debug', 'WebSocket', `Failed to close/destroy socket for session ${session.id}`); + } + } + } + context.sessions.activeSessions.clear(); + context.sessions.resumableSessions.clear(); + logger('info', 'WebSocket', `Signalled close to ${closedCount} WebSocket connection(s)`); + // Prefer graceful stop so the close frames above flush. If clients + // don't drain within 1.5s (slow networks, half-open peers), fall + // back to force-stop so shutdown still completes promptly. + await new Promise((resolveStop) => { + let settled = false; + const finish = () => { + if (settled) + return; + settled = true; + resolveStop(); + }; + const forceTimer = setTimeout(() => { + server.stop(true).then(finish, finish); + }, 1500); + server.stop(false).then(() => { + clearTimeout(forceTimer); + finish(); + }, () => { + clearTimeout(forceTimer); + server.stop(true).then(finish, finish); + }); + }); + try { + server.unref(); + } + catch { } + logger('info', 'WebSocket', 'Bun server stopped successfully'); + } + catch (e) { + const error = e; + logger('error', 'WebSocket', `Error stopping Bun server: ${error?.message ?? String(e)}`); + } +} diff --git a/dist/src/typings/index.types.js b/dist/src/typings/index.types.js index c30d3027..cb0ff5c3 100644 --- a/dist/src/typings/index.types.js +++ b/dist/src/typings/index.types.js @@ -1,14 +1 @@ -/** - * BunSocketWrapper class declaration for type exports - * @public - */ -export class BunSocketWrapper { - ws; - remoteAddress; - send; - ping; - close; - terminate; - _handleMessage; - _handleClose; -} +export {}; diff --git a/dist/src/utils.js b/dist/src/utils.js index fc6b3976..8ab622ee 100644 --- a/dist/src/utils.js +++ b/dist/src/utils.js @@ -12,7 +12,6 @@ import util from 'node:util'; import zlib from 'node:zlib'; import packageJson from '../package.json' with { type: 'json' }; import { DEFAULT_MAX_REDIRECTS, DISCORD_ID_REGEX, REDIRECT_STATUS_CODES, SEMVER_PATTERN } from "./constants.js"; -const isBun = typeof process !== 'undefined' && process.versions?.bun; /** * Reference to the runtime NodeLink instance stored on the global object. * @@ -1362,15 +1361,6 @@ async function makeRequest(urlString, options, nodelink) { if (_redirectsFollowed >= maxRedirects) { return Promise.reject(new Error(`Too many redirects (${maxRedirects}) for ${urlString}`)); } - // fall back to HTTP/1 for Bun requests - // Note: bun v1.3.12, crashes with "authority" argument must be a type of string, object or URL. received type Number (825110816) - // Crashes the source worker ^^, could be related to monochrome's request or anything else that uses http/2 - // UPDATE: Bun v1.3.13 has fixed this crash, since it was released today as this commit, i will be checking the version but can be removed later. - if (isBun && - process.versions.bun.localeCompare('1.3.13', undefined, { numeric: true }) < - 0) { - return http1makeRequest(urlString, options); - } if (options.network?.proxy) { return http1makeRequest(urlString, options); } diff --git a/package.json b/package.json index 9380c98d..461044a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260531.1", + "version": "3.8.1-dev.20260604.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", diff --git a/src/index.ts b/src/index.ts index 97d071a6..49aac43a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,8 +27,8 @@ import { parseClient, verifyDiscordID } from './utils.ts' +import { cleanupBunServer, createBunServer } from './server/bunServer.ts' import 'dotenv/config' -import type { ServerWebSocket } from 'bun' import { GatewayEvents, MINIMUM_NODE_VERSION } from './constants.ts' import ConfigValidationManager from './managers/configValidationManager.ts' import type ConnectionManager from './managers/connectionManager.ts' @@ -49,11 +49,9 @@ import type { } from './typings/config/config.types.ts' import type { AudioInterceptorExtension, - BunSocketData, ConfigLoadError, FilterExtension, GitInfo, - IBunSocketWrapper, NodelinkServer as INodelinkServer, NodelinkExtensions, NodelinkServerType, @@ -63,7 +61,6 @@ import type { PlayerInterceptorExtension, PlayerManagerConstructor, RequestShim, - ResponseShim, RouteExtension, SessionSocket, SourceExtension, @@ -71,7 +68,7 @@ import type { VoiceRelay, WebSocketInterceptorExtension } from './typings/index.types.ts' -import type { ClientInfo, IPCMessage, ReqShim } from './typings/shared.types.ts' +import type { ClientInfo, IPCMessage } from './typings/shared.types.ts' import { parseVoiceFrameHeader } from './voice/voiceFrames.ts' import { createVoiceRelay } from './voice/voiceRelay.ts' @@ -449,6 +446,7 @@ initLogger( const isBun = typeof Bun !== 'undefined' + if (!cluster.isWorker) { const ascii = ` ▄ ████▄ ██▄ ▄███▄ █ ▄█ ▄ █ █▀ @@ -464,108 +462,6 @@ if (!cluster.isWorker) { await checkForUpdates() memoryTrace('bootstrap:after-check-for-updates') -/** - * Wrapper for Bun's ServerWebSocket that implements EventEmitter - * Provides compatibility with Node.js WebSocket implementations - */ -class BunSocketWrapper extends EventEmitter implements IBunSocketWrapper { - ws: ServerWebSocket - remoteAddress: string - - /** - * Creates a new BunSocketWrapper - * @param ws - Bun ServerWebSocket instance - */ - constructor(ws: ServerWebSocket) { - super() - this.ws = ws - this.remoteAddress = ws?.data?.remoteAddress || 'unknown' - } - - /** - * Sends data through the WebSocket connection - * @param data - Data to send - * @returns True if sent successfully - */ - /** - * Sends data through the WebSocket connection - * @param data - Data to send - * @returns True if sent successfully - * @public - */ - send(data: string | Buffer): boolean { - try { - const r = this.ws.send(data) - return r !== 0 - } catch { - return false - } - } - - /** - * Sends a WebSocket ping frame - * @param data - Optional ping data - * @returns True if sent successfully - * @public - */ - ping(data?: string | Buffer): boolean { - try { - this.ws.ping?.(data) - return true - } catch { - return false - } - } - - /** - * Closes the connection. - * - * Here is a list of close codes: - * - `1000` means "normal closure" **(default)** - * - `1009` means a message was too big and was rejected - * - `1011` means the server encountered an error - * - `1012` means the server is restarting - * - `1013` means the server is too busy or the client is rate-limited - * - `4000` through `4999` are reserved for applications (you can use it!) - * - * To close the connection abruptly, use `terminate()`. - * - * @param code The close code to send - * @param reason The close reason to send - * @public - */ - close(code?: number, reason?: string): void { - this.ws.close(code, reason) - } - - /** - * Terminates the connection immediately - * @public - */ - terminate(): void { - this.ws.close(1000, 'Terminated') - } - - /** - * Internal handler for received messages - * @param message - Message data - * @internal - */ - _handleMessage(message: string | Buffer): void { - this.emit('message', message) - } - - /** - * Internal handler for connection close events - * @param code - Close code - * @param reason - Close reason - * @internal - */ - _handleClose(code: number, reason: string): void { - this.emit('close', code, reason) - } -} - /** * Main NodeLink server class * Handles WebSocket connections, audio sources, and player management @@ -792,11 +688,16 @@ class NodelinkServer extends EventEmitter { } /** - * Starts the heartbeat interval to keep WebSocket connections alive + * Starts the heartbeat interval to keep WebSocket connections alive. + * + * No-op when running under `Bun.serve` because Bun handles WebSocket pings + * natively via `websocket.sendPings: true`. Running both would double the + * keepalive traffic. * @internal */ _startHeartbeat() { if (this._heartbeatInterval) return + if (this._usingBunServer) return this._heartbeatInterval = setInterval(() => { for (const session of this.sessions.activeSessions.values()) { @@ -1006,84 +907,93 @@ class NodelinkServer extends EventEmitter { 'Resume', `Processing websocket connection. oldSessionId: ${oldSessionId}` ) + let session = null if (oldSessionId) { - const session = this.sessions.resume(oldSessionId, socket) + session = this.sessions.resume(oldSessionId, socket) - if (session) { + if (!session) { logger( - 'info', + 'warn', 'Server', - `\x1b[36m${clientInfo.name}\x1b[0m${ - clientInfo.version - ? `/\x1b[32mv${clientInfo.version}\x1b[0m` - : '' - } resumed session with ID: ${oldSessionId}` + `Session-ID provided by ${clientInfo.name} does not exist or is not resumable: ${oldSessionId}, creating a new session` ) - this.statsManager.incrementSessionResume(clientInfo.name, true) - - socket.on('close', (...args: (string | number | Buffer)[]) => { - const code = args[0] as number - const reason = args[1] as string - if (!this.sessions.has(oldSessionId)) return - - const session = this.sessions.get(oldSessionId) - if (!session) return + } + } - logger( - 'info', - 'Server', - `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${ - clientInfo.version - }\x1b[0m disconnected with code ${code} and reason: ${ - reason || 'without reason' - }` - ) + if (session) { + logger( + 'info', + 'Server', + `\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) - if (session.resuming) { - this.sessions.pause(oldSessionId) - } else { - this.sessions.shutdown(oldSessionId) - } + socket.on('close', (...args: (string | number | Buffer)[]) => { + const code = args[0] as number + const reason = args[1] as string + if (!this.sessions.has(oldSessionId)) return - const sessionCount = this.sessions.activeSessions?.size || 0 - this.statsManager.setWebsocketConnections(sessionCount) - }) + const session = this.sessions.get(oldSessionId) + if (!session) return - socket.send( - JSON.stringify({ - op: 'ready', - resumed: true, - sessionId: oldSessionId - }) + logger( + 'info', + 'Server', + `\x1b[36m${clientInfo.name}\x1b[0m/\x1b[32mv${ + clientInfo.version + }\x1b[0m disconnected with code ${code} and reason: ${ + reason || 'without reason' + }` ) - while (session.eventQueue.length > 0) { - const event = session.eventQueue.shift() - if (event) socket.send(event) - } - - for (const [ - playerKey, - playerInfo - ] of session.players.players.entries()) { - if (this.workerManager) { - const worker = this.workerManager.getWorkerForGuild(playerKey) - if (worker) { - this.workerManager.execute(worker, 'playerCommand', { - sessionId: session.id, - guildId: playerInfo.guildId, - command: 'forceUpdate', - args: [] - }) - } - } else { - playerInfo._sendUpdate() - } + if (session.resuming) { + this.sessions.pause(oldSessionId) + } else { + this.sessions.shutdown(oldSessionId) } const sessionCount = this.sessions.activeSessions?.size || 0 this.statsManager.setWebsocketConnections(sessionCount) + }) + + socket.send( + JSON.stringify({ + op: 'ready', + resumed: true, + sessionId: oldSessionId + }) + ) + + while (session.eventQueue.length > 0) { + const event = session.eventQueue.shift() + if (event) socket.send(event) + } + + for (const [ + playerKey, + playerInfo + ] of session.players.players.entries()) { + if (this.workerManager) { + const worker = this.workerManager.getWorkerForGuild(playerKey) + if (worker) { + this.workerManager.execute(worker, 'playerCommand', { + sessionId: session.id, + guildId: playerInfo.guildId, + command: 'forceUpdate', + args: [] + }) + } + } else { + playerInfo._sendUpdate() + } } + + const sessionCount = this.sessions.activeSessions?.size || 0 + this.statsManager.setWebsocketConnections(sessionCount) } else { const sessionId = this.sessions.create( request as unknown as RequestShim, @@ -1314,366 +1224,133 @@ class NodelinkServer extends EventEmitter { } } ) - } - - /** - * Creates and configures Bun HTTP server with WebSocket support - * @internal - */ - _createBunServer() { - const port = this.options.server.port - const host = this.options.server.host || '0.0.0.0' - const password = this.options.server.password - const self = this - logger( - 'warn', - 'Server', - 'Running with Bun.serve, remember this is experimental!' - ) - - this.server = Bun.serve({ - port, - hostname: host, - maxRequestBodySize: 1024 * 1024 * 50, - - async fetch(req, server) { - const url = new URL(req.url) - const pathname = url.pathname.endsWith('/') - ? url.pathname.slice(0, -1) - : url.pathname - - if (pathname === '/v4/profiler/socket') { - const remoteAddress = server.requestIP(req)?.address || 'unknown' - const isInternal = /^(::1|localhost|127\.0\.0\.1)/.test(remoteAddress) - const endpoint = self.options.cluster?.endpoint || {} - const patchEnabled = endpoint.patchEnabled === true - const allowExternalPatch = endpoint.allowExternalPatch === true - const expectedCode = - typeof endpoint.code === 'string' && endpoint.code.length > 0 - ? endpoint.code - : 'CAPYBARA' - const providedCode = - url.searchParams.get('code') || - req.headers.get('x-nodelink-code') || - req.headers.get('x-worker-code') - - if (!patchEnabled) { - return new Response('Profiler socket endpoint is disabled.', { - status: 403, - statusText: 'Forbidden' - }) - } - if (!allowExternalPatch && !isInternal) { - return new Response('External profiler socket access is blocked.', { - status: 403, - statusText: 'Forbidden' - }) - } - if (!providedCode || providedCode !== expectedCode) { - return new Response('Invalid or missing profiler code.', { - status: 403, - statusText: 'Forbidden' - }) - } - - const success = server.upgrade(req, { - data: { - clientInfo: { name: 'ProfilerUI', version: '1' }, - sessionId: null, - reqHeaders: Object.fromEntries(req.headers), - remoteAddress, - url: req.url - } - }) + this.socket?.on( + '/v4/websocket/voice', + ( + socket: SessionSocket, + request: RequestShim, + _clientInfo: ClientInfo, + _sessionId: string, + guildId: string + ) => { + socket.guildId = guildId - if (success) return undefined - return new Response('WebSocket upgrade failed', { status: 400 }) + if (!this.options.playback.voiceReceive?.enabled) { + try { + socket.close(1008, 'Voice receive disabled') + } catch {} + return } - if (pathname === '/v4/websocket') { - const remoteAddress = server.requestIP(req)?.address || 'unknown' - const clientAddress = `[External] (${remoteAddress})` - - const clientName = req.headers.get('client-name') - const auth = req.headers.get('authorization') - const userId = req.headers.get('user-id') - const sessionId = req.headers.get('session-id') - - if (auth !== password) { - logger( - 'warn', - 'Server', - `Unauthorized connection attempt from ${clientAddress} - Invalid password provided: ${auth || 'None'}` - ) - return new Response('Invalid password provided.', { - status: 401, - statusText: 'Unauthorized', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }) - } + logger( + 'info', + 'Voice', + `Voice websocket connected from ${request.socket?.remoteAddress || 'unknown'} | guild ${guildId}` + ) - if (!clientName) { - logger( - 'warn', - 'Server', - `Missing client-name from ${clientAddress}` - ) - return new Response('Invalid or missing Client-Name header.', { - status: 400, - statusText: 'Bad Request', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }) - } + this.registerVoiceSocket(guildId, socket) + } + ) - if (!userId || !verifyDiscordID(userId)) { - logger('warn', 'Server', `Invalid user ID from ${clientAddress}`) - return new Response('Invalid or missing User-Id header.', { - status: 400, - statusText: 'Bad Request', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }) - } + this.socket?.on( + '/v4/websocket/youtube/live', + ( + socket: SessionSocket, + request: RequestShim, + _clientInfo: ClientInfo, + _sessionId: string, + id: string + ) => { + let videoId = id + socket.guildId = id // Tag it with videoId or guildId equivalent - const clientInfo = parseClient(clientName) as ClientInfo | null - if (!clientInfo) { - logger( - 'warn', - 'Server', - `Invalid client-name from ${clientAddress}` - ) - return new Response('Invalid or missing Client-Name header.', { - status: 400, - statusText: 'Bad Request', - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' - } - }) + if (/^\d{17,20}$/.test(id)) { + const player = this.sessions.getPlayer(id) + if (player?.track?.info?.sourceName?.includes('youtube')) { + videoId = player.track.info.identifier } - - const success = server.upgrade(req, { - data: { - clientInfo, - sessionId, - reqHeaders: Object.fromEntries(req.headers), - remoteAddress, - url: req.url - } - }) - - if (success) return undefined - return new Response('WebSocket upgrade failed', { - status: 400, - headers: { - 'Nodelink-Api-Version': '4', - IamNodelink: 'true' + } else if (id.length > 50) { + try { + const decoded = decodeTrack(id) + if (decoded?.info?.sourceName?.includes('youtube')) { + videoId = decoded.info.identifier } - }) + } catch (_e) {} } - return new Promise((resolve) => { - interface RequestShimInternal extends RequestShim { - _endCb?: () => void + if (!this.sourceWorkerManager) { + const yt = this.sources?.getSource('youtube') + if (!yt) { + socket.close(1008, 'YouTube source not enabled') + return } - - const reqShim: RequestShimInternal = { - method: req.method, - url: url.pathname + url.search, - headers: Object.fromEntries(req.headers), - socket: { remoteAddress: server.requestIP(req)?.address }, - on: (event: string, cb: (data: Buffer) => void) => { - if (event === 'data') { - req - .arrayBuffer() - .then((buf: ArrayBuffer) => { - cb(Buffer.from(buf)) - if (reqShim._endCb) reqShim._endCb() - }) - .catch(() => {}) - } - if (event === 'end') { - reqShim._endCb = cb as () => void - } - } + const liveChatFn = yt.handleLiveChat + if (typeof liveChatFn === 'function') { + liveChatFn.call(yt, socket, videoId) + } else { + socket.close(1008, 'YouTube live chat not supported') } + return + } - const resShim: ResponseShim = { - _status: 200, - _headers: {}, - _body: [], - writeHead( - status: number, - headers?: Record - ) { - this._status = status - if (headers) Object.assign(this._headers, headers) - }, - setHeader(name: string, value: string | string[]) { - this._headers[name] = value - }, - getHeader(name: string) { - return this._headers[name] as string | string[] | undefined - }, - end(data?: string | Buffer) { - if (data) this._body.push(data) - const finalBody = Buffer.concat( - this._body.map((chunk) => - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - ) - ) - - const headers = new Headers() - for (const [key, value] of Object.entries(this._headers)) { - if (Array.isArray(value)) { - for (const v of value) headers.append(key, v) - } else if (value !== undefined) { - headers.set(key, String(value)) - } - } - - const response = new Response(finalBody, { - status: this._status, - headers - }) - resolve(response) - }, - write(data: string | Buffer) { - if (data) this._body.push(data) - } - } + logger( + 'info', + 'YouTube-LiveChat', + `Delegating live chat for video: ${videoId} to worker` + ) - void getRequestHandler() - .then((handler) => - handler( - self as unknown as Parameters[0], - reqShim as unknown as RequestShim, - resShim - ) - ) - .catch((error: Error) => { - logger( - 'error', - 'Server', - `Failed to handle Bun request: ${error.message}` - ) - if (!resShim._status || resShim._status < 400) { - resShim.writeHead(500, { 'Content-Type': 'text/plain' }) - } - resShim.end('Internal Server Error') + const resShim = { + headersSent: false, + send: (data: string | Buffer) => { + const payload = Buffer.isBuffer(data) + ? data + : Buffer.from(String(data)) + socket.sendFrame?.(payload, { + len: payload.length, + fin: true, + opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 }) - }) - }, - - websocket: { - sendPings: true, - data: {} as BunSocketData, - open(ws) { - if (!ws.data) return - const wrapper = new BunSocketWrapper(ws) - ws.data.wrapper = wrapper - - const { clientInfo, sessionId, reqHeaders } = ws.data - - const reqShim: ReqShim = { - headers: reqHeaders as Record, - url: ws.data.url, - socket: { remoteAddress: ws.data.remoteAddress } - } - - let pathname = '/v4/websocket' - try { - pathname = new URL(ws.data.url).pathname - } catch {} - - if (pathname === '/v4/profiler/socket') { - logger( - 'info', - 'ProfilerSocket', - `Profiler socket connected from [External] (${ws.data.remoteAddress})` - ) - self.socket?.emit( - '/v4/profiler/socket', - wrapper, - reqShim, - null, - null - ) - return - } - - logger( - 'info', - 'Server', - `\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}` - ) - - let eventName = '/v4/websocket' - let guildId = null - let liveId = null - try { - const url = new URL(ws.data.url) - const voiceMatch = url.pathname.match( - /^\/v4\/websocket\/voice\/([A-Za-z0-9]+)\/?$/ - ) - const liveMatch = url.pathname.match( - /^\/v4\/websocket\/youtube\/live\/([^/]+)\/?$/ - ) - - if (voiceMatch) { - if (!self.options.playback.voiceReceive?.enabled) { - try { - wrapper.close(1008, 'Voice receive disabled') - } catch {} - return - } - eventName = '/v4/websocket/voice' - guildId = voiceMatch[1] - } else if (liveMatch) { - eventName = '/v4/websocket/youtube/live' - liveId = liveMatch[1] - } - } catch {} - - if (self.socket) { - self.socket.emit( - eventName, - wrapper, - reqShim, - clientInfo, - sessionId, - guildId || liveId - ) - } - }, - message(ws: ServerWebSocket, message: string | Buffer) { - ws.data?.wrapper?._handleMessage(message) - }, - close( - ws: ServerWebSocket, - code: number, - reason: string - ) { - ws.data?.wrapper?._handleClose(code, reason) + }, + writeHead: (status: number) => { + if (status !== 200) socket.close(1011, 'Worker failed') + }, + write: (data: string | Buffer) => { + const payload = Buffer.isBuffer(data) + ? data + : Buffer.from(String(data)) + socket.sendFrame?.(payload, { + len: payload.length, + fin: true, + opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 + }) + }, + end: () => socket.close(1000, 'Finished'), + on: ( + event: string, + cb: (...args: (string | Buffer | number)[]) => void + ) => socket.on(event, cb) } + + this.sourceWorkerManager.delegate( + request, + resShim, + 'loadLiveChat', + { videoId }, + { isWebSocket: true } + ) } - }) + ) + } - logger( - 'started', - 'Server', - `Successfully listening on ${host}:${port} (Bun Native)` + /** + * Creates and configures Bun HTTP server with WebSocket support + * @internal + */ + _createBunServer() { + this.server = createBunServer( + this as unknown as import('./server/bunServer.ts').BunServerContext, + getRequestHandler ) } @@ -2047,123 +1724,6 @@ class NodelinkServer extends EventEmitter { } } ) - - this.socket?.on( - '/v4/websocket/voice', - ( - socket: SessionSocket, - request: RequestShim, - _clientInfo: ClientInfo, - _sessionId: string, - guildId: string - ) => { - socket.guildId = guildId - - if (!this.options.playback.voiceReceive?.enabled) { - try { - socket.close(1008, 'Voice receive disabled') - } catch {} - return - } - - logger( - 'info', - 'Voice', - `Voice websocket connected from ${request.socket?.remoteAddress || 'unknown'} | guild ${guildId}` - ) - - this.registerVoiceSocket(guildId, socket) - } - ) - - this.socket?.on( - '/v4/websocket/youtube/live', - ( - socket: SessionSocket, - request: RequestShim, - _clientInfo: ClientInfo, - _sessionId: string, - id: string - ) => { - let videoId = id - socket.guildId = id // Tag it with videoId or guildId equivalent - - if (/^\d{17,20}$/.test(id)) { - const player = this.sessions.getPlayer(id) - if (player?.track?.info?.sourceName?.includes('youtube')) { - videoId = player.track.info.identifier - } - } else if (id.length > 50) { - try { - const decoded = decodeTrack(id) - if (decoded?.info?.sourceName?.includes('youtube')) { - videoId = decoded.info.identifier - } - } catch (_e) {} - } - - if (!this.sourceWorkerManager) { - const yt = this.sources?.getSource('youtube') - if (!yt) { - socket.close(1008, 'YouTube source not enabled') - return - } - const liveChatFn = yt.handleLiveChat - if (typeof liveChatFn === 'function') { - liveChatFn.call(yt, socket, videoId) - } else { - socket.close(1008, 'YouTube live chat not supported') - } - return - } - - logger( - 'info', - 'YouTube-LiveChat', - `Delegating live chat for video: ${videoId} to worker` - ) - - const resShim = { - headersSent: false, - send: (data: string | Buffer) => { - const payload = Buffer.isBuffer(data) - ? data - : Buffer.from(String(data)) - socket.sendFrame?.(payload, { - len: payload.length, - fin: true, - opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 - }) - }, - writeHead: (status: number) => { - if (status !== 200) socket.close(1011, 'Worker failed') - }, - write: (data: string | Buffer) => { - const payload = Buffer.isBuffer(data) - ? data - : Buffer.from(String(data)) - socket.sendFrame?.(payload, { - len: payload.length, - fin: true, - opcode: Buffer.isBuffer(data) ? 0x02 : 0x01 - }) - }, - end: () => socket.close(1000, 'Finished'), - on: ( - event: string, - cb: (...args: (string | Buffer | number)[]) => void - ) => socket.on(event, cb) - } - - this.sourceWorkerManager.delegate( - request, - resShim, - 'loadLiveChat', - { videoId }, - { isWebSocket: true } - ) - } - ) } /** @@ -2343,29 +1903,13 @@ class NodelinkServer extends EventEmitter { */ async _cleanupWebSocketServer(): Promise { if (this._usingBunServer && this.server) { - try { - logger('info', 'WebSocket', 'Stopping Bun server...') - await ( - this.server as { - stop: (force: boolean) => Promise - unref: () => void - } - ).stop(true) - ;( - this.server as { - stop: (force: boolean) => Promise - unref: () => void - } - ).unref() - logger('info', 'WebSocket', 'Bun server stopped successfully') - } catch (e) { - const error = e as Error - logger( - 'error', - 'WebSocket', - `Error stopping Bun server: ${error?.message ?? String(e)}` - ) - } + await cleanupBunServer( + this as unknown as import('./server/bunServer.ts').BunServerContext, + this.server as { + stop: (force?: boolean) => Promise + unref: () => void + } + ) return } @@ -2607,7 +2151,7 @@ class NodelinkServer extends EventEmitter { 'Running as cluster worker — waiting for sockets from master.' ) process.on('message', (msg: IPCMessage | { type: string }, handle) => { - if (!msg || msg.type !== 'sticky-session') return + if (msg?.type !== 'sticky-session') return if (!handle) return try { try { diff --git a/src/server/bunServer.ts b/src/server/bunServer.ts new file mode 100644 index 00000000..fbc8c9ff --- /dev/null +++ b/src/server/bunServer.ts @@ -0,0 +1,726 @@ +import { EventEmitter } from 'node:events' +import type { ServerWebSocket } from 'bun' +import type { NodelinkConfig } from '../typings/config/config.types.ts' +import type { + BunSocketData, + IBunSocketWrapper, + NodelinkSocketType, + RequestShim, + ResponseShim, + SessionSocket +} from '../typings/index.types.ts' +import type { ClientInfo, ReqShim } from '../typings/shared.types.ts' +import { logger, parseClient, verifyDiscordID } from '../utils.ts' + +const VOICE_PATH_RE = /^\/v4\/websocket\/voice\/([A-Za-z0-9]+)\/?$/ +const LIVE_PATH_RE = /^\/v4\/websocket\/youtube\/live\/([^/]+)\/?$/ + +type RequestHandler = ( + nodelink: import('../typings/api/api.types.ts').ApiNodelinkServer, + req: RequestShim, + res: ResponseShim +) => Promise + +/** + * Context required by the Bun server functions. + * Subset of NodelinkServer properties that the Bun code reads. + * @internal + */ +export interface BunServerContext { + options: NodelinkConfig + sessions: { + resumableSessions: Map + activeSessions: Map + } + socket: NodelinkSocketType +} + +/** + * Wrapper for Bun's ServerWebSocket that implements EventEmitter + * Provides compatibility with Node.js WebSocket implementations + */ +export class BunSocketWrapper + extends EventEmitter + implements IBunSocketWrapper +{ + ws: ServerWebSocket + remoteAddress: string + + /** + * Creates a new BunSocketWrapper + * @param ws - Bun ServerWebSocket instance + */ + constructor(ws: ServerWebSocket) { + super() + this.ws = ws + this.remoteAddress = ws?.data?.remoteAddress || 'unknown' + } + + /** + * Sends data through the WebSocket connection + * @param data - Data to send + * @returns True if sent successfully + * @public + */ + send(data: string | Buffer): boolean { + try { + const r = this.ws.send(data) + return r !== 0 + } catch { + return false + } + } + + /** + * Sends a WebSocket ping frame + * @param data - Optional ping data + * @returns True if sent successfully + * @public + */ + ping(data?: string | Buffer): boolean { + try { + this.ws.ping?.(data) + return true + } catch { + return false + } + } + + /** + * Closes the connection. + * + * - `1000` means "normal closure" **(default)** + * - `1009` means a message was too big and was rejected + * - `1011` means the server encountered an error + * - `1012` means the server is restarting + * - `1013` means the server is too busy or the client is rate-limited + * - `4000` through `4999` are reserved for applications (you can use it!) + * + * To close the connection abruptly, use `terminate()`. + * + * @param code The close code to send + * @param reason The close reason to send + * @public + */ + close(code?: number, reason?: string): void { + this.ws.close(code, reason) + } + + /** + * Terminates the connection immediately + * @public + */ + terminate(): void { + this.ws.close(1000, 'Terminated') + } + + /** + * Internal handler for received messages + * @param message - Message data + * @internal + */ + _handleMessage(message: string | Buffer): void { + this.emit('message', message) + } + + /** + * Internal handler for connection close events + * @param code - Close code + * @param reason - Close reason + * @internal + */ + _handleClose(code: number, reason: string): void { + this.emit('close', code, reason) + } +} + +/** + * Creates and configures Bun HTTP server with WebSocket support + * @param context - Server context (NodelinkServer subset) + * @param getRequestHandler - Lazy loader for the API request handler + * @returns The created Bun server instance + * @internal + */ +export function createBunServer( + context: BunServerContext, + getRequestHandler: () => Promise +): ReturnType { + const port = context.options.server.port + const host = context.options.server.host || '0.0.0.0' + const password = context.options.server.password + + logger( + 'warn', + 'Server', + 'Running with Bun.serve, remember this is experimental!' + ) + + const server = Bun.serve({ + port, + hostname: host, + maxRequestBodySize: 1024 * 1024 * 50, + idleTimeout: 60, + + error(error) { + logger( + 'error', + 'Server', + `HTTP server error: ${error instanceof Error ? error.message : String(error)}` + ) + return new Response('Internal Server Error', { status: 500 }) + }, + + async fetch(req, server) { + const url = new URL(req.url) + const pathname = url.pathname.endsWith('/') + ? url.pathname.slice(0, -1) + : url.pathname + + if (pathname === '/v4/profiler/socket') { + const remoteAddress = server.requestIP(req)?.address || 'unknown' + const isInternal = /^(::1|localhost|127\.0\.0\.1)/.test(remoteAddress) + const endpoint = context.options.cluster?.endpoint || {} + const patchEnabled = endpoint.patchEnabled === true + const allowExternalPatch = endpoint.allowExternalPatch === true + const expectedCode = + typeof endpoint.code === 'string' && endpoint.code.length > 0 + ? endpoint.code + : 'CAPYBARA' + const providedCode = + url.searchParams.get('code') || + req.headers.get('x-nodelink-code') || + req.headers.get('x-worker-code') + + if (!patchEnabled) { + return new Response('Profiler socket endpoint is disabled.', { + status: 403, + statusText: 'Forbidden' + }) + } + if (!allowExternalPatch && !isInternal) { + return new Response('External profiler socket access is blocked.', { + status: 403, + statusText: 'Forbidden' + }) + } + if (!providedCode || providedCode !== expectedCode) { + return new Response('Invalid or missing profiler code.', { + status: 403, + statusText: 'Forbidden' + }) + } + + const success = server.upgrade(req, { + data: { + clientInfo: { name: 'ProfilerUI', version: '1' }, + sessionId: null, + reqHeaders: Object.fromEntries(req.headers), + remoteAddress, + url: req.url, + pathname, + eventName: '/v4/profiler/socket', + routeId: null + } + }) + + if (success) return undefined + return new Response('WebSocket upgrade failed', { status: 400 }) + } + + const voiceMatch = pathname.match(VOICE_PATH_RE) + const liveMatch = pathname.match(LIVE_PATH_RE) + const isMainWs = pathname === '/v4/websocket' + + if (isMainWs || voiceMatch || liveMatch) { + const remoteAddress = server.requestIP(req)?.address || 'unknown' + const clientAddress = `[External] (${remoteAddress})` + + const clientName = req.headers.get('client-name') + const auth = req.headers.get('authorization') + const userId = req.headers.get('user-id') + let sessionId = req.headers.get('session-id') + + if (auth !== password) { + logger( + 'warn', + 'Server', + `Unauthorized connection attempt from ${clientAddress} - Invalid password provided: ${auth || 'None'}` + ) + return new Response('Invalid password provided.', { + status: 401, + statusText: 'Unauthorized', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }) + } + + if (!clientName) { + logger('warn', 'Server', `Missing client-name from ${clientAddress}`) + return new Response('Invalid or missing Client-Name header.', { + status: 400, + statusText: 'Bad Request', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }) + } + + if (!userId || !verifyDiscordID(userId)) { + logger('warn', 'Server', `Invalid user ID from ${clientAddress}`) + return new Response('Invalid or missing User-Id header.', { + status: 400, + statusText: 'Bad Request', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }) + } + + const clientInfo = parseClient(clientName) as ClientInfo | null + if (!clientInfo) { + logger('warn', 'Server', `Invalid client-name from ${clientAddress}`) + return new Response('Invalid or missing Client-Name header.', { + status: 400, + statusText: 'Bad Request', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }) + } + + let eventName = '/v4/websocket' + let routeId: string | null = null + if (voiceMatch) { + if (!context.options.playback.voiceReceive?.enabled) { + return new Response('Voice receive disabled.', { + status: 404, + statusText: 'Not Found', + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }) + } + eventName = '/v4/websocket/voice' + routeId = voiceMatch[1] ?? null + } else if (liveMatch) { + eventName = '/v4/websocket/youtube/live' + routeId = liveMatch[1] ?? null + } + + if (sessionId && !context.sessions.resumableSessions.has(sessionId)) { + logger( + 'warn', + 'Server', + `Session-ID provided by ${clientAddress} does not exist or is not resumable: ${sessionId}, creating a new session` + ) + sessionId = null + } + + const success = server.upgrade(req, { + data: { + clientInfo, + sessionId, + reqHeaders: Object.fromEntries(req.headers), + remoteAddress, + url: req.url, + pathname, + eventName, + routeId + } + }) + + if (success) return undefined + return new Response('WebSocket upgrade failed', { + status: 400, + headers: { + 'Nodelink-Api-Version': '4', + IamNodelink: 'true' + } + }) + } + + return new Promise((resolve) => { + const dataListeners: Array<(data: Buffer) => void> = [] + const endListeners: Array<() => void> = [] + const errorListeners: Array<(err: Error) => void> = [] + let bodyTriggered = false + + const triggerBodyRead = () => { + if (bodyTriggered) return + bodyTriggered = true + + const hasBody = + req.headers.get('content-length') || + req.headers.get('transfer-encoding') + if (!hasBody && (req.method === 'GET' || req.method === 'HEAD')) { + queueMicrotask(() => { + for (const cb of endListeners) { + try { + cb() + } catch (e) { + logger( + 'debug', + 'Server', + `Bun reqShim end listener threw: ${(e as Error).message}` + ) + } + } + }) + return + } + + req + .arrayBuffer() + .then((buf: ArrayBuffer) => { + const chunk = Buffer.from(buf) + if (chunk.length > 0) { + for (const cb of dataListeners) { + try { + cb(chunk) + } catch (e) { + logger( + 'debug', + 'Server', + `Bun reqShim data listener threw: ${(e as Error).message}` + ) + } + } + } + for (const cb of endListeners) { + try { + cb() + } catch (e) { + logger( + 'debug', + 'Server', + `Bun reqShim end listener threw: ${(e as Error).message}` + ) + } + } + }) + .catch((err: unknown) => { + const error = err instanceof Error ? err : new Error(String(err)) + if (errorListeners.length > 0) { + for (const cb of errorListeners) { + try { + cb(error) + } catch {} + } + } else { + logger( + 'debug', + 'Server', + `Bun request body read failed: ${error.message}` + ) + for (const cb of endListeners) { + try { + cb() + } catch {} + } + } + }) + } + + const reqShim: RequestShim = { + method: req.method, + url: url.pathname + url.search, + headers: Object.fromEntries(req.headers), + socket: { remoteAddress: server.requestIP(req)?.address }, + on: (event: string, cb: (data: Buffer) => void) => { + if (event === 'data') { + dataListeners.push(cb) + triggerBodyRead() + } else if (event === 'end') { + endListeners.push(cb as unknown as () => void) + triggerBodyRead() + } else if (event === 'error') { + errorListeners.push(cb as unknown as (err: Error) => void) + } + } + } + + const resShim: ResponseShim = { + _status: 200, + _headers: {}, + _body: [], + writeHead( + status: number, + headers?: Record + ) { + this._status = status + if (headers) Object.assign(this._headers, headers) + }, + setHeader(name: string, value: string | string[]) { + this._headers[name] = value + }, + getHeader(name: string) { + return this._headers[name] as string | string[] | undefined + }, + end(data?: string | Buffer) { + if (data) this._body.push(data) + let finalBody: Buffer | string + if (this._body.length === 0) { + finalBody = '' + } else if ( + this._body.length === 1 && + Buffer.isBuffer(this._body[0]) + ) { + finalBody = this._body[0] as Buffer + } else { + finalBody = Buffer.concat( + this._body.map((chunk) => + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ) + ) + } + + const headers = new Headers() + for (const [key, value] of Object.entries(this._headers)) { + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v) + } else if (value !== undefined) { + headers.set(key, String(value)) + } + } + + const response = new Response(finalBody, { + status: this._status, + headers + }) + resolve(response) + }, + write(data: string | Buffer) { + if (data) this._body.push(data) + } + } + + void getRequestHandler() + .then((handler) => + handler( + context as unknown as import('../typings/api/api.types.ts').ApiNodelinkServer, + reqShim as unknown as RequestShim, + resShim + ) + ) + .catch((error: Error) => { + logger( + 'error', + 'Server', + `Failed to handle Bun request: ${error.message}` + ) + if (!resShim._status || resShim._status < 400) { + resShim.writeHead(500, { 'Content-Type': 'text/plain' }) + } + resShim.end('Internal Server Error') + }) + }) + }, + + websocket: { + sendPings: true, + idleTimeout: 120, + maxPayloadLength: 1024 * 1024 * 16, + data: {} as BunSocketData, + open(ws) { + if (!ws.data) { + try { + ws.close(1011, 'Missing socket data') + } catch {} + return + } + const wrapper = new BunSocketWrapper(ws) + ws.data.wrapper = wrapper + + const { + clientInfo, + sessionId, + reqHeaders, + pathname, + eventName, + routeId + } = ws.data + + const reqShim: ReqShim = { + headers: reqHeaders as Record, + url: ws.data.url, + socket: { remoteAddress: ws.data.remoteAddress } + } + + if (pathname === '/v4/profiler/socket') { + logger( + 'info', + 'ProfilerSocket', + `Profiler socket connected from [External] (${ws.data.remoteAddress})` + ) + context.socket?.emit( + '/v4/profiler/socket', + wrapper, + reqShim, + null, + null + ) + return + } + + logger( + 'info', + 'Server', + `\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}` + ) + + if (context.socket) { + context.socket.emit( + eventName ?? '/v4/websocket', + wrapper, + reqShim, + clientInfo, + sessionId, + routeId ?? null + ) + } + }, + message(ws: ServerWebSocket, message: string | Buffer) { + const wrapper = ws.data?.wrapper + if (!wrapper) { + logger( + 'debug', + 'WebSocket', + `Bun message received without wrapper (remote: ${ws.data?.remoteAddress || 'unknown'})` + ) + return + } + wrapper._handleMessage(message) + }, + close(ws: ServerWebSocket, code: number, reason: string) { + const wrapper = ws.data?.wrapper + if (!wrapper) { + logger( + 'debug', + 'WebSocket', + `Bun close received without wrapper (code: ${code}, remote: ${ws.data?.remoteAddress || 'unknown'})` + ) + return + } + wrapper._handleClose(code, reason) + }, + // `error` is documented in Bun.serve docs but missing from + // bun-types@1.3.14 typings; cast keeps runtime behaviour while + // satisfying the type checker. + ...({ + error(ws: ServerWebSocket, err: Error) { + logger( + 'error', + 'WebSocket', + `Bun WebSocket error from ${ws.data?.remoteAddress || 'unknown'}: ${err.message}` + ) + const wrapper = ws.data?.wrapper + if (wrapper && wrapper.listenerCount('error') > 0) { + try { + wrapper.emit('error', err) + } catch {} + } + } + } as Record) + } + }) + + logger( + 'started', + 'Server', + `Successfully listening on ${host}:${port} (Bun Native)` + ) + + return server +} + +/** + * Cleans up Bun WebSocket server resources + * @param context - Server context + * @param server - The Bun server instance to clean up + * @internal + */ +export async function cleanupBunServer( + context: BunServerContext, + server: { stop: (force?: boolean) => Promise; unref: () => void } +): Promise { + try { + logger('info', 'WebSocket', 'Stopping Bun server...') + + // Gracefully close every active session with the same code Node uses, + // so clients see a clean 1000 close frame and reconnect normally. + // Without this, Bun.stop(true) tears TCP connections down without + // sending close frames, surfacing as ECONNRESET on the client. + let closedCount = 0 + for (const session of context.sessions.activeSessions.values()) { + if (!session.socket) continue + try { + session.socket.close(1000, 'Server shutdown') + closedCount++ + } catch (_e) { + try { + ;(session.socket as SessionSocket).destroy?.() + } catch (_destroyErr) { + logger( + 'debug', + 'WebSocket', + `Failed to close/destroy socket for session ${session.id}` + ) + } + } + } + context.sessions.activeSessions.clear() + context.sessions.resumableSessions.clear() + logger( + 'info', + 'WebSocket', + `Signalled close to ${closedCount} WebSocket connection(s)` + ) + + // Prefer graceful stop so the close frames above flush. If clients + // don't drain within 1.5s (slow networks, half-open peers), fall + // back to force-stop so shutdown still completes promptly. + await new Promise((resolveStop) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + resolveStop() + } + + const forceTimer = setTimeout(() => { + server.stop(true).then(finish, finish) + }, 1500) + + server.stop(false).then( + () => { + clearTimeout(forceTimer) + finish() + }, + () => { + clearTimeout(forceTimer) + server.stop(true).then(finish, finish) + } + ) + }) + + try { + server.unref() + } catch {} + logger('info', 'WebSocket', 'Bun server stopped successfully') + } catch (e) { + const error = e as Error + logger( + 'error', + 'WebSocket', + `Error stopping Bun server: ${error?.message ?? String(e)}` + ) + } +} diff --git a/src/typings/index.types.ts b/src/typings/index.types.ts index e86558ac..64e41afc 100644 --- a/src/typings/index.types.ts +++ b/src/typings/index.types.ts @@ -46,7 +46,28 @@ export interface BunSocketData { * Socket wrapper instance for event handling * @internal */ - wrapper?: BunSocketWrapper + wrapper?: IBunSocketWrapper + + /** + * Pre-parsed pathname from the upgrade request URL + * @internal + */ + pathname?: string + + /** + * Pre-resolved event name to emit on the internal socket bus + * (`/v4/websocket`, `/v4/websocket/voice`, `/v4/websocket/youtube/live`, + * or `/v4/profiler/socket`) + * @internal + */ + eventName?: string + + /** + * Pre-extracted route identifier (guildId for voice, videoId/encoded track + * for live chat); null when not applicable + * @internal + */ + routeId?: string | null } /** @@ -1340,19 +1361,3 @@ export interface NodelinkServer { */ extensions?: NodelinkExtensions } - -/** - * BunSocketWrapper class declaration for type exports - * @public - */ -export class BunSocketWrapper { - ws!: ServerWebSocket - remoteAddress!: string - - send!: (data: string | Buffer) => boolean - ping!: (data?: string | Buffer) => boolean - close!: (code?: number, reason?: string) => void - terminate!: () => void - _handleMessage!: (message: string | Buffer) => void - _handleClose!: (code: number, reason: string) => void -} diff --git a/src/utils.ts b/src/utils.ts index 233a7fbf..a0b4e3dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,7 @@ import https from 'node:https' import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' -import { URL, fileURLToPath } from 'node:url' +import { URL } from 'node:url' import util from 'node:util' import zlib from 'node:zlib' @@ -18,12 +18,12 @@ import { REDIRECT_STATUS_CODES, SEMVER_PATTERN } from './constants.ts' +import type CredentialManager from './managers/credentialManager.ts' import type { ApiHttpMethod, ApiRequest, ApiResponse } from './typings/api/api.types.ts' -import type CredentialManager from './managers/credentialManager.ts' import type { ClientInfo } from './typings/shared.types.ts' import type { BestMatchCandidate, @@ -45,8 +45,6 @@ import type { declare const __BUILD_GIT_INFO__: GitInfo | undefined -const isBun = typeof process !== 'undefined' && process.versions?.bun - /** * Reference to the runtime NodeLink instance stored on the global object. * @@ -1677,17 +1675,6 @@ async function makeRequest( new Error(`Too many redirects (${maxRedirects}) for ${urlString}`) ) } - // fall back to HTTP/1 for Bun requests - // Note: bun v1.3.12, crashes with "authority" argument must be a type of string, object or URL. received type Number (825110816) - // Crashes the source worker ^^, could be related to monochrome's request or anything else that uses http/2 - // UPDATE: Bun v1.3.13 has fixed this crash, since it was released today as this commit, i will be checking the version but can be removed later. - if ( - isBun && - process.versions.bun.localeCompare('1.3.13', undefined, { numeric: true }) < - 0 - ) { - return http1makeRequest(urlString, options) - } if (options.network?.proxy) { return http1makeRequest(urlString, options) @@ -2013,7 +2000,10 @@ async function checkDependencyUpdates( const CHECK_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes const now = Date.now() - let latestVersions: Record = {} + let latestVersions: Record< + string, + { version: string; source: 'NPM' | 'GitHub' } + > = {} let isCacheValid = false if (credentialManager) { @@ -2153,7 +2143,7 @@ async function checkDependencyUpdates( 'Update recommended for stability. Run "npm install" to update.' ) } else if (Object.keys(latestVersions).length > 0) { - logger('info', 'Server', 'All core packages are up to date.') + logger('info', 'Server', 'All core packages are up to date.') } } From 97976b083b1300ae2260af947f83562ffd333a0d Mon Sep 17 00:00:00 2001 From: 1Lucas1apk Date: Sun, 7 Jun 2026 13:08:38 -0400 Subject: [PATCH 43/58] fix: update bandcamp stream parsing and regex to prevent fallback --- dist/package.json | 10 +- dist/src/sources/bandcamp.js | 270 ++++++---- dist/src/typings/sources/bandcamp.types.js | 5 + package.json | 10 +- src/sources/bandcamp.ts | 569 +++++++++------------ src/typings/sources/bandcamp.types.ts | 114 +++++ 6 files changed, 546 insertions(+), 432 deletions(-) create mode 100644 dist/src/typings/sources/bandcamp.types.js create mode 100644 src/typings/sources/bandcamp.types.ts diff --git a/dist/package.json b/dist/package.json index d26195f9..4d19dd51 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,6 +1,6 @@ { "name": "nodelink", - "version": "3.8.1-dev.20260604.1", + "version": "3.8.1-dev.20260607.1", "scripts": { "build": "tsc --incremental false", "start": "node --dns-result-order=ipv4first --import tsx src/index.ts", @@ -29,14 +29,14 @@ "proxy-agent": "^8.0.1" }, "devDependencies": { - "@biomejs/biome": "^2.4.15", + "@biomejs/biome": "^2.4.16", "@commitlint/cli": "20.5.0", "@commitlint/config-conventional": "20.5.0", - "@types/bun": "^1.3.13", - "@types/node": "^25.6.2", + "@types/bun": "^1.3.14", + "@types/node": "^25.9.2", "dotenv": "^17.4.2", "husky": "9.1.7", - "tsx": "^4.21.0", + "tsx": "^4.22.4", "typescript": "^6.0.3" } } diff --git a/dist/src/sources/bandcamp.js b/dist/src/sources/bandcamp.js index 52066e0f..4f853c80 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -1,14 +1,11 @@ import { PassThrough, pipeline } from 'node:stream'; import { encodeTrack, logger, makeRequest } from "../utils.js"; const BANDCAMP_BASE_URL = 'https://bandcamp.com'; +const BANDCAMP_SEARCH_API_URL = `${BANDCAMP_BASE_URL}/api/bcsearch_public_api/1/autocomplete_elastic`; const BANDCAMP_TRACK_PATTERN = /^https?:\/\/([^/]+)\.bandcamp\.com\/(track|album)\/([^/?]+)/; -const SEARCH_RESULT_REGEX = /