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/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/biome.json b/biome.json index c364a77f..62d14c98 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "formatter": { "enabled": true, @@ -20,7 +20,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "style": { "useConst": "error" }, diff --git a/config.default.js b/config.default.js deleted file mode 100644 index 01e3f7c0..00000000 --- a/config.default.js +++ /dev/null @@ -1,667 +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 - quality: 'HI_RES_LOSSLESS' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW - }, - googledrive: { - 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_aes256_gcm_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/config.default.ts b/config.default.ts new file mode 100644 index 00000000..e680f7c9 --- /dev/null +++ b/config.default.ts @@ -0,0 +1,791 @@ +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: true + }, + + 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: false, + instances: [], + streamingInstances: [], + quality: 'HI_RES_LOSSLESS', + qobuzQuality: 6 + }, + + 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 diff --git a/dist/config.default.js b/dist/config.default.js index c4732495..ddb84a97 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: true }, 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,117 @@ 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: false, + instances: [], + streamingInstances: [], + quality: 'HI_RES_LOSSLESS', + qobuzQuality: 6 }, - 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 +648,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,27 +658,11 @@ 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: { + googledrive: { enabled: true, - instances: [], // (optional) list of API instances - streamingInstances: [], // (optional) list of streaming instances - quality: 'HI_RES_LOSSLESS' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW + cookies: '' }, - googledrive: { + tiktok: { enabled: true } }, @@ -498,7 +676,6 @@ export default { }, musixmatch: { enabled: true - // signatureSecret: '' }, deezer: { enabled: true @@ -527,128 +704,12 @@ export default { enabled: true } }, - audio: { - quality: 'high', // high, medium, low, lowest - encryption: 'aead_aes256_gcm_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: { @@ -657,11 +718,7 @@ export default { maxLayersMix: 5, autoCleanup: true }, - plugins: [ - /* { - name: 'nodelink-sample-plugin', - source: 'local' - } */ - ], + plugins: [], pluginConfig: {} }; +export default config; diff --git a/dist/package.json b/dist/package.json index 642296c5..66f3ba9f 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,10 +1,10 @@ { "name": "nodelink", - "version": "3.8.0-dev.20260422.1", + "version": "3.8.1-dev.20260621.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", @@ -21,24 +21,22 @@ "@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.0.2", - "mp4box": "^2.3.0", + "mp4box": "^2.4.1", "prom-client": "^15.1.3", - "proxy-agent": "^8.0.1" + "proxy-agent": "^8.0.2" }, "devDependencies": { - "@biomejs/biome": "^2.4.12", - "@commitlint/cli": "20.5.0", - "@commitlint/config-conventional": "20.5.0", - "@types/bun": "^1.3.12", - "@types/jsdom": "^28.0.1", - "@types/node": "^25.6.0", + "@biomejs/biome": "^2.5.0", + "@commitlint/cli": "21.0.2", + "@commitlint/config-conventional": "21.0.2", + "@types/bun": "^1.3.14", + "@types/node": "^26.0.0", "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/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/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/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 3c49e4ec..fd2bfbb2 100644 --- a/dist/src/api/loadTracks.js +++ b/dist/src/api/loadTracks.js @@ -67,6 +67,13 @@ 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 { @@ -126,7 +133,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/profiler.ui.js b/dist/src/api/profiler.ui.js index fee87c3a..e7d11329 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,18 @@ 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) + + // 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)) @@ -656,6 +668,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) 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/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/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 720b2751..3d806550 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -15,7 +15,9 @@ import WebSocketServer from '@performanc/pwsl-server'; import RoutePlannerManager from "./managers/routePlannerManager.js"; import SessionManager from "./managers/sessionManager.js"; import StatsManager from "./managers/statsManager.js"; -import { applyEnvOverrides, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, getGitInfo, getStats, getVersion, initLogger, logger, parseClient, verifyDiscordID } from "./utils.js"; +import { migrateConfig, persistConfig } from "./modules/config/configMigration.js"; +import { cleanupBunServer, createBunServer } from "./server/bunServer.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"; @@ -26,7 +28,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'; @@ -152,28 +156,93 @@ 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') { +const loadConfig = async () => { + const resolveRootConfigUrl = (fileName) => pathToFileURL(resolvePath(process.cwd(), fileName)).href; + 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 { - 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.'); + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.ts'))); + baseConfig = resolveConfigExport(module, 'config.default.ts'); } - catch (e2) { - console.error('[ERROR] Config: Failed to load config.default.js. Please make sure it exists.'); - throw e2; + catch { + try { + const module = await import(__rewriteRelativeImportExtension(resolveRootConfigUrl('config.default.js'))); + baseConfig = resolveConfigExport(module, 'config.default.js'); + defaultFileName = 'config.default.js'; + } + catch { + throw new Error('[ERROR] Config: Base configuration (config.default.ts/js) not found.'); + } } - } - else { - 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 applyEnvOverrides(config); const clusterEnabled = @@ -198,107 +267,13 @@ if (!cluster.isWorker) { █ █ █ ▀████ █ █ █▄ ▄▀ ███▄ ▐█ █ █ █ █ █ v${getVersion()} █ █ █ ███▀ ▀███▀ ▀ ▐ █ █ █ █ Powered by PerformanC; █ ██ █ ██ ▀ rewritten by 1Lucas1.apk; + maintained by 1Lucas1.apk & ToddyTheNoobDud `; process.stdout.write(`\x1b[32m${ascii}\x1b[0m\n`); } await checkForUpdates(); +await checkDependencyUpdates(); 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 @@ -314,6 +289,7 @@ class NodelinkServer extends EventEmitter { lyrics; meanings; _sourceInitPromise; + proxyManager; routePlanner; credentialManager; trackCacheManager; @@ -360,6 +336,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; @@ -395,8 +372,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 }); @@ -478,12 +455,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) { @@ -637,60 +620,62 @@ 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); @@ -867,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.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) @@ -1294,7 +1094,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) { @@ -1330,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.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 @@ -1443,12 +1166,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) @@ -1540,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) { @@ -1726,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; @@ -1773,8 +1487,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; @@ -1897,7 +1611,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); @@ -1991,6 +1705,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/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 01a80231..cc90e97c 100644 --- a/dist/src/managers/configValidationManager.js +++ b/dist/src/managers/configValidationManager.js @@ -1,929 +1,250 @@ -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 { 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. + */ export default class ConfigValidationManager { - options; - warnings = []; - 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) + 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 } + } }, - 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' + 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 } + } + } + } }, - { - path: 'sources.audius.apiKey', - expected: 'string', - value: au.apiKey, - validate: (v) => v === undefined || typeof v === 'string' + logging: { + type: 'object', + props: { + level: { type: 'string', default: 'info' }, + file: { + type: 'object', + props: { + enabled: { type: 'boolean', default: false }, + path: { type: 'string', default: 'logs' } + } + } + } }, - { - path: 'sources.audius.apiSecret', - expected: 'string', - value: au.apiSecret, - validate: (v) => v === undefined || typeof v === 'string' + connection: { + type: 'object', + props: { + interval: { type: 'number', integer: true, min: 1000 }, + timeout: { type: 'number', integer: true, min: 1000 } + } }, - 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 + rateLimit: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + global: { type: 'object' }, + perIp: { type: 'object' } + } }, - { - path: 'sources.eternalbox.maxRandomBranchChance', - expected: 'number between 0 and 1 (inclusive)', - value: e.maxRandomBranchChance, - validate: (v) => typeof v === 'number' && v >= 0 && v <= 1 + dosProtection: { + type: 'object', + props: { + enabled: { type: 'boolean', default: true }, + thresholds: { type: 'object' }, + mitigation: { type: 'object' } + } }, - 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); + 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 } + } } - 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 + }, + 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 } } - ]; - } - if (!('enabled' in filters)) { - return [ - { - path: 'filters.enabled', - expected: 'object of filter flags', - value: undefined, - validate: () => 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 } } - ]; - } - if (filters.enabled === null || typeof filters.enabled !== 'object') { - return [ - { - path: 'filters.enabled', - expected: 'object of filter flags', - value: filters.enabled, - validate: () => false + }, + api: { + type: 'object', + props: { + enableTrackStreamEndpoint: { type: 'boolean', default: false }, + enableLoadStreamEndpoint: { type: 'boolean', default: false }, + metrics: { type: 'object', optional: true } } - ]; - } - 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.` - }); + }, + experimental: { + type: 'object', + props: { + enableHoloTracks: { type: 'boolean', default: false } } - 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) + }, + 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 } }; - } - 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..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.connection || {}; + this.config = + nodelink.options.network?.connection || nodelink.options.connection || {}; this.interval = null; this.status = 'unknown'; this.metrics = { timestamp: Date.now() }; @@ -83,6 +84,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..d1602dcf 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; -}; +const _isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); /** * 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,215 +95,28 @@ 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) { - const optionsCandidate = options; - const serverCandidate = optionsCandidate.server; - const server = isRecord(serverCandidate) - ? serverCandidate - : null; - const password = server && typeof server.password === 'string' ? server.password : null; + static _resolvePassword(options) { + const password = options.server?.password; if (!password) { throw new Error('CredentialManager requires options.server.password'); } 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..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?.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/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/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 051d50cc..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?.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 a4cb38a5..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.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 ac97e2f9..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.defaultSearchSource) - ? this.nodelink.options.defaultSearchSource - : [this.nodelink.options.defaultSearchSource]; + 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.unifiedSearchSources || [ - '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}`); @@ -326,10 +325,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..567c63ee 100644 --- a/dist/src/managers/trackCacheManager.js +++ b/dist/src/managers/trackCacheManager.js @@ -1,21 +1,11 @@ -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; 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; -}; +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. @@ -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,27 +57,32 @@ 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) { - 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; + /** + * Deletes a cached value by source/identifier. + * @param source - Source name (e.g., "youtube"). + * @param identifier - Track identifier. + */ + delete(source, identifier) { + const key = `${source}:${identifier}`; + const deleted = this.cache.delete(key); + if (deleted) + this.save(); + return deleted; + } + static _resolveCacheOptions(options) { + 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; @@ -184,93 +92,11 @@ export default class TrackCacheManager { : DEFAULT_CLEANUP_INTERVAL_MS; return { maxEntries, cleanupIntervalMs }; } - _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; + static _resolvePassword(options) { + const password = options.server?.password; if (!password) { throw new Error('TrackCacheManager requires options.server.password'); } 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/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/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/modules/spotifyAuth.js b/dist/src/modules/spotifyAuth.js index bbe9fd20..7c134e40 100644 --- a/dist/src/modules/spotifyAuth.js +++ b/dist/src/modules/spotifyAuth.js @@ -119,7 +119,9 @@ async function getServerTime(spDc) { const data = typeof res.body === 'string' ? JSON.parse(res.body) : res.body; - return typeof data.serverTime === 'number' ? data.serverTime : Date.now(); + return typeof data.serverTime === 'number' + ? data.serverTime * 1000 + : Date.now(); } catch { return Date.now(); @@ -159,9 +161,8 @@ function generateTOTP(secretHex, timestampMs, step = 30) { */ async function performTokenRequest(secret, version, spDc, productType) { const isWebPlayer = productType === 'web-player'; - const serverTimeMs = isWebPlayer ? Date.now() : await getServerTime(spDc); - const localTimeMs = Date.now(); - const totpLocal = generateTOTP(secret, localTimeMs, 30); + const serverTimeMs = await getServerTime(spDc); + const totpLocal = generateTOTP(secret, serverTimeMs, 30); const totpServer = generateTOTP(secret, serverTimeMs, 900); const url = new URL('https://open.spotify.com/api/token'); url.searchParams.append('reason', 'init'); 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/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..c30b94d0 100644 --- a/dist/src/playback/filters/dsp/delay.js +++ b/dist/src/playback/filters/dsp/delay.js @@ -20,7 +20,7 @@ export default class DelayLine { * @param sample - The PCM sample to write. */ write(sample) { - this.buffer.writeInt16LE(sample, this.writeIndex * 2); + this.buffer?.writeInt16LE(sample, this.writeIndex * 2); this.writeIndex = (this.writeIndex + 1) % this.size; } /** @@ -31,12 +31,15 @@ export default class DelayLine { read(delayInSamples) { const safeDelay = Math.max(0, Math.min(Math.floor(delayInSamples), this.size - 1)); const readIndex = (this.writeIndex - safeDelay + this.size) % this.size; - return this.buffer.readInt16LE(readIndex * 2); + return this.buffer?.readInt16LE(readIndex * 2) ?? 0; } /** * Clears the delay line buffer. */ clear() { - this.buffer.fill(0); + this.buffer?.fill(0); + } + destroy() { + this.buffer = null; } } diff --git a/dist/src/playback/filters/dsp/float64Delay.js b/dist/src/playback/filters/dsp/float64Delay.js index f509456d..757564c2 100644 --- a/dist/src/playback/filters/dsp/float64Delay.js +++ b/dist/src/playback/filters/dsp/float64Delay.js @@ -27,7 +27,10 @@ export class Float64DelayLine { * no clamping or truncation. */ write(sample) { - this.buffer[this.writeIndex] = sample; + const buf = this.buffer; + if (!buf) + return; + buf[this.writeIndex] = sample; this.writeIndex = (this.writeIndex + 1) % this.size; } /** @@ -43,15 +46,15 @@ export class Float64DelayLine { if (delayInSamples <= 0) { // Reading current write position (most recent sample) const idx = (this.writeIndex - 1 + this.size) % this.size; - return this.buffer[idx] ?? 0; + return this.buffer?.[idx] ?? 0; } const clamped = Math.min(delayInSamples, this.size - 1); const intDelay = Math.floor(clamped); const frac = clamped - intDelay; const idx0 = (this.writeIndex - intDelay - 1 + this.size * 2) % this.size; const idx1 = (idx0 - 1 + this.size) % this.size; - const s0 = this.buffer[idx0] ?? 0; - const s1 = this.buffer[idx1] ?? 0; + const s0 = this.buffer?.[idx0] ?? 0; + const s1 = this.buffer?.[idx1] ?? 0; // Linear interpolation: (1-frac)×s0 + frac×s1 return s0 + frac * (s1 - s0); } @@ -59,6 +62,9 @@ export class Float64DelayLine { * Clears the delay line (zero-fill). */ clear() { - this.buffer.fill(0); + this.buffer?.fill(0); + } + destroy() { + 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..c16fc94c 100644 --- a/dist/src/playback/filters/phonograph.js +++ b/dist/src/playback/filters/phonograph.js @@ -34,15 +34,24 @@ class InterpDelayLine { this.buf = new Float32Array(size); } clear() { - this.buf.fill(0); + this.buf?.fill(0); this.w = 0; } + destroy() { + this.buf = null; + } write(x) { - this.buf[this.w] = x; - this.w = (this.w + 1) % this.buf.length; + const buf = this.buf; + if (!buf) + return; + buf[this.w] = x; + this.w = (this.w + 1) % buf.length; } read(delaySamples) { - const n = this.buf.length; + const buf = this.buf; + if (!buf) + return 0; + const n = buf.length; let r = this.w - delaySamples; while (r < 0) r += n; @@ -51,7 +60,7 @@ class InterpDelayLine { const i0 = r | 0; const i1 = (i0 + 1) % n; const frac = r - i0; - return (this.buf[i0] ?? 0) * (1 - frac) + (this.buf[i1] ?? 0) * frac; + return (buf[i0] ?? 0) * (1 - frac) + (buf[i1] ?? 0) * frac; } } class Biquad { @@ -337,4 +346,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..1d76f060 100644 --- a/dist/src/playback/filters/reverb.js +++ b/dist/src/playback/filters/reverb.js @@ -40,17 +40,24 @@ class CombFilter { * No clamping inside the loop — full Float64 precision. */ process(input) { - const output = this.buffer[this.writeIndex] ?? 0; + const buf = this.buffer; + if (!buf) + return 0; + const output = buf[this.writeIndex] ?? 0; this.filterStore = output * this.damp2 + this.filterStore * this.damp1; - this.buffer[this.writeIndex] = input + this.filterStore * this.feedback; + buf[this.writeIndex] = input + this.filterStore * this.feedback; this.writeIndex = (this.writeIndex + 1) % this.size; return output; } clear() { - this.buffer.fill(0); + this.buffer?.fill(0); this.filterStore = 0; this.writeIndex = 0; } + destroy() { + this.buffer = null; + this.filterStore = 0; + } } /** * Applies a Freeverb/Schroeder-based reverb effect. @@ -265,4 +272,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/hls/HLSHandler.js b/dist/src/playback/hls/HLSHandler.js index a2ffbab9..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.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 eb20de65..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.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/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/dist/src/playback/player.js b/dist/src/playback/player.js index 813c3860..3ba68333 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -1,13 +1,12 @@ import { PassThrough } from 'node:stream'; +import { setTimeout as sleep } from 'node:timers/promises'; import { SeekError } from '@ecliptia/seekable-stream'; import discordVoice from '@performanc/voice'; import { EndReasons, GatewayEvents } from "../constants.js"; import { logger } from "../utils.js"; let createAudioResource = null; let createSeekeableAudioResource = null; -const env = process.env; -const trackFinishMemoryTraceEnabled = env.NODELINK_TRACK_FINISH_MEMORY_TRACE?.toLowerCase() === 'true'; -const trackFinishForceGcEnabled = env.NODELINK_TRACK_FINISH_FORCE_GC?.toLowerCase() === 'true'; +const trackFinishMemoryTraceEnabled = process.env.NODELINK_TRACK_FINISH_MEMORY_TRACE?.toLowerCase() === 'true'; async function getStreamProcessor() { if (createAudioResource && createSeekeableAudioResource) return; @@ -87,6 +86,7 @@ export class Player { _isStopping = false; _pausedAtPosition = undefined; stuckRecoveryCount = 0; + _positionAtRecoveryStart = 0; static MAX_STUCK_RECOVERY_ATTEMPTS = 3; constructor(options) { if (!options.nodelink || @@ -99,12 +99,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', @@ -114,10 +114,12 @@ 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 = {}) => { @@ -142,24 +144,32 @@ 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) => { + logger('debug', 'Player', `waitEvent: Started waiting for '${event}' on guild ${this.guildId} (timeout: ${timeout}ms)`); + const conn = this.connection; + if (!conn) { + logger('warn', 'Player', `waitEvent: Aborted waiting for '${event}' on guild ${this.guildId} (no connection)`); + return reject(new Error('No connection available for waitEvent')); + } const handler = (_, payload) => { const typedPayload = payload; if (!filter || filter(typedPayload)) { clearTimeout(timeoutId); - this.connection?.off(event, handler); + conn.off(event, handler); + logger('debug', 'Player', `waitEvent: Resolved '${event}' for guild ${this.guildId}`); resolve(typedPayload); } }; const timeoutId = setTimeout(() => { - this.connection?.off(event, handler); + conn.off(event, handler); + logger('warn', 'Player', `waitEvent: Timeout waiting for '${event}' on guild ${this.guildId}`); reject(new Error(`Event ${event} timed out after ${timeout}ms for guild ${this.guildId}`)); }, timeout); - this.connection?.on(event, handler); + conn.on(event, handler); }); } _getAudioOptions() { - return this.nodelink.options.audio; + return this.nodelink.options.playback.audio; } _getFilterTransitions() { return this._getAudioOptions()?.filterTransitions; @@ -174,7 +184,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, @@ -222,12 +232,15 @@ export class Player { _initConnection() { if (this.connection || this.destroying) return; + logger('debug', 'Player', `[Connection] Initializing voice connection for guild ${this.guildId} (Session: ${this.session.id})`); this.connection = discordVoice.joinVoiceChannel({ 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.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); @@ -235,23 +248,22 @@ export class Player { this.connection.on('playerStateChange', (_, s) => this._onPlay(s)); this.connection.on('error', (err) => { 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(); + process.nextTick(() => { + if (this.destroying) + return; + const playerReason = this.connection?.playerState?.reason; + if (playerReason === 'reconnecting') { + logger('warn', 'Player', `Voice connection error for guild ${this.guildId} is a recoverable reconnection (playerState.reason=${playerReason}). Deferring to library.`); + return; } - }; - audioStream.on('data', dataHandler); - audioStream.once('close', () => { - audioStream.off('data', dataHandler); - }); - audioStream.once('end', () => { - audioStream.off('data', dataHandler); + this._onError(err); }); }); + 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,12 +274,13 @@ 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}`); this.emitEvent(GatewayEvents.PLAYER_CONNECTED, { guildId: this.guildId, - voice: { ...this.voice } + voice: structuredClone(this.voice) }); if (this.track && this.isPaused && this.connection?.audioStream) { this.isPaused = false; @@ -275,17 +288,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: structuredClone(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 }); } @@ -316,7 +343,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.trackStuckThresholdMs + 1; + this._stuckTime = + (this.nodelink.options.playback.trackStuckThresholdMs ?? 0) + 1; this._sendUpdate(); return; } @@ -363,17 +391,17 @@ export class Player { this._emitTrackEnd(endReason); this._resetTrack(); this._traceTrackFinishMemory('after-reset'); - this._scheduleTrackFinishGcProbe(); } 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 +414,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: structuredClone(this.voice) + }); + } } /** * Handles playback errors and emits exception events. @@ -492,6 +527,7 @@ export class Player { * Destroys and dereferences current audio stream to avoid lingering references. */ _cleanupCurrentAudioStream(context) { + logger('debug', 'Player', `[Cleanup] Triggering stream cleanup for guild ${this.guildId}. Context: ${context}`); const conn = this.connection; const audioStream = conn?.audioStream; if (!audioStream) @@ -502,33 +538,27 @@ export class Player { catch { // Ignore cleanup errors } + if (audioStream.destroyed) { + if (conn) + conn.audioStream = null; + return; + } try { audioStream.destroy?.(); + if (Array.isArray(audioStream.pipes)) { + for (const pipe of audioStream.pipes) { + pipe.destroy?.(); + } + } + conn?.udp?.flush?.(); } 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. - */ - _scheduleTrackFinishGcProbe() { - if (!trackFinishForceGcEnabled) - return; - const gcFn = global.gc; - if (typeof gcFn !== 'function') - return; - const timer = setTimeout(() => { - try { - gcFn(); - gcFn(); - } - catch { } - this._traceTrackFinishMemory('after-gc'); - }, 0); - timer.unref?.(); + finally { + if (conn) + conn.audioStream = null; + } } /** * Emits TRACK_START and related events after resolving Holo tracks. @@ -592,7 +622,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 { @@ -600,8 +630,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; } @@ -649,7 +679,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(); @@ -675,9 +705,16 @@ export class Player { const track = urlData?.newTrack ? urlData?.newTrack?.info : info; + logger('debug', 'Player', `Fetching stream resource from source for guild ${this.guildId}`, { + source: track.sourceName, + url: urlData.url + }); const fetched = await this.nodelink.sources.getTrackStream(track, urlData.url, urlData.protocol, additionalData); - if (fetched.exception) + if (fetched.exception) { + logger('error', 'Player', `Stream resource fetch failed for guild ${this.guildId}`, fetched.exception); return fetched; + } + logger('debug', 'Player', `Successfully fetched stream resource for guild ${this.guildId}`); const fetchedStream = fetched.stream; const totalBytesRaw = urlData.additionalData?.contentLength ?? null; const totalBytesNum = Number(totalBytesRaw); @@ -726,6 +763,7 @@ export class Player { }; streamForResource.on('close', cleanupListeners); streamForResource.on('error', cleanupListeners); + streamForResource.on('end', cleanupListeners); streamForResource._cleanupListeners = cleanupListeners; } const resource = audioResourceFactory(this.guildId, streamForResource, fetched.type || urlData.format, this.nodelink, this.filters, this.volumePercent / 100, this.audioMixer, false, this.loudnessNormalizer); @@ -743,11 +781,12 @@ 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}`); } } - const threshold = this.nodelink.options.trackStuckThresholdMs; + const threshold = this.nodelink.options.playback.trackStuckThresholdMs ?? 0; if (threshold > 0 && !this.isUpdatingTrack && !this._isStopping && @@ -755,12 +794,20 @@ export class Player { !this._isResuming && !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 && this.connStatus === 'connected') { const stuckTime = this._stuckTime; this._stuckTime = 0; + const pipelineStream = this._getAudioStream(); + if (pipelineStream?.isPipelineFinished?.()) { + logger('debug', 'Player', `Player for guild ${this.guildId} is starving but the network stream has finished. Treating as natural trackEnd.`); + this._emitTrackEnd(EndReasons.FINISHED); + this._resetTrack(); + return false; + } if (this.streamInfo?.format === 'mp4') { logger('error', 'Player', `Player for guild ${this.guildId} is stuck on an MP4 track. Emitting TRACK_STUCK without recovery.`); this.emitEvent(GatewayEvents.TRACK_STUCK, { @@ -803,8 +850,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,7 +874,13 @@ export class Player { }); this._isRecovering = true; this.stuckRecoveryCount++; - this.seek(this._lastPosition, this.track.endTime, true) + this._positionAtRecoveryStart = position; + if (this.track.info.identifier && this.track.info.sourceName) { + this.nodelink.trackCacheManager?.delete(this.track.info.sourceName, this.track.info.identifier); + } + const isStream = this.track.info.isStream; + const recoveryPosition = isStream ? 0 : this._lastPosition; + this.seek(recoveryPosition, this.track.endTime, true) .then((success) => { if (success) { logger('info', 'Player', `Player for guild ${this.guildId} recovered successfully.`); @@ -865,8 +917,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(); @@ -890,16 +946,19 @@ export class Player { if (success) { logger('debug', 'Player', `[SponsorBlock][${this.guildId}] Successfully jumped to ${segment.end}ms`); this.emitEvent(GatewayEvents.SPONSORBLOCK_SEGMENT_SKIPPED, { - segment, - skippedMs + track: this.track, + segment }); } else { logger('warn', 'Player', `[SponsorBlock][${this.guildId}] Failed to jump to ${segment.end}ms for segment ${segment.uuid}`); + // fallback: temporarily mute or un-stick if it fails + this.sponsorBlock.lastSkippedUuid = null; } }) .catch((err) => { logger('error', 'Player', `[SponsorBlock][${this.guildId}] Error while seeking to segment end:`, err); + this.sponsorBlock.lastSkippedUuid = null; }); return true; } @@ -913,7 +972,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; @@ -921,75 +982,85 @@ export class Player { /** * Starts playback for the current track. */ - async _startPlayback(startTime = 0) { - if (!this.track) - return false; - const trackInfo = { - ...this.track.info, - audioTrackId: this.track.audioTrackId - }; - const urlData = await this.nodelink.sources.getTrackUrl(trackInfo, undefined, this._isRecovering); - const urlDataWithFormats = urlData; + async _connectAndPlayStream(urlData, position, cleanupReason, fadingAction, playLogMessage) { if (!this.track) return false; - this.streamInfo = { ...urlData, trackInfo: this.track.info }; - logger('debug', 'Player', `Got track URL for guild ${this.guildId}`, { - urlData: { - ...urlData, - formats: urlDataWithFormats.formats - ? `[${urlDataWithFormats.formats.length} format(s) omitted]` - : undefined - } - }); - if (urlData.exception) { - const err = new Error(urlData.exception.message); - this._onError(err); - return false; - } if (!this.connection) { this._initConnection(); } if (!this.connection?.udpInfo?.secretKey) { logger('debug', 'Player', `Waiting for voice connection to be ready for guild ${this.guildId}`); - await this.waitEvent('stateChange', (s) => s.status === 'connected' && !!this.connection?.udpInfo?.secretKey); + try { + await this.waitEvent('stateChange', (s) => s.status === 'connected' && !!this.connection?.udpInfo?.secretKey); + } + catch (err) { + logger('warn', 'Player', `Timeout or error while waiting for voice connection on guild ${this.guildId}:`, err); + } } if (!this.connection?.udpInfo?.secretKey) { - logger('error', 'Player', `Voice connection for guild ${this.guildId} is not ready, cannot start playback.`); - this._onError(new Error('Voice connection is not ready.')); + const errorMessage = `Voice connection for guild ${this.guildId} is not ready (missing UDP info). Aborting playback.`; + logger('error', 'Player', errorMessage); + this._onError(new Error(errorMessage)); return false; } - const fetched = await this._fetchResource(this.track.info, urlData, startTime); + const fetched = await this._fetchResource(this.track.info, urlData, position); if ('exception' in fetched) { const err = new Error(fetched.exception.message); this._onError(err); return false; } - this._cleanupCurrentAudioStream('start-playback'); + this._cleanupCurrentAudioStream(cleanupReason); const resource = fetched.stream; if (this.volumePercent !== 100) { resource.setVolume(this.volumePercent / 100); } - this._fading('trackStartArm', { resource }); - this._fading('trackEndSchedule', { startPosition: startTime || 0 }); + this._fading(fadingAction, { resource }); this.setFilters(this.filters); - logger('debug', 'Player', `Playing resource for guild ${this.guildId}`); - this._stuckTime = 0; + logger('debug', 'Player', playLogMessage); this.connection.play(resource); await this.waitEvent('playerStateChange', (s) => s.status === 'playing'); - this._lyricsBasePosition = startTime || 0; + this._lyricsBasePosition = position; this._lyricsBasePackets = this.connection?.statistics?.packetsExpected ?? 0; + return true; + } + async _startPlayback(startTime = 0) { + if (!this.track) + return false; + const trackInfo = { + ...this.track.info, + audioTrackId: this.track.audioTrackId + }; + const urlData = await this.nodelink.sources.getTrackUrl(trackInfo, undefined, this._isRecovering); + if (!this.track) + return false; + this.streamInfo = { ...urlData, trackInfo: this.track.info }; + logger('debug', 'Player', `Got track URL for guild ${this.guildId}`, { + urlData + }); + if (urlData.exception) { + const err = new Error(urlData.exception.message); + this._onError(err); + return false; + } + const result = await this._connectAndPlayStream(urlData, startTime, 'start-playback', 'trackStartArm', `Playing resource for guild ${this.guildId}`); + if (!result) + return false; + this._fading('trackEndSchedule', { startPosition: startTime || 0 }); + this._stuckTime = 0; if (this.track.info.sourceName === 'youtube' || this.track.info.sourceName === 'ytmusic') { 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"); 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; } @@ -1023,6 +1094,7 @@ export class Player { * @returns True when the request is accepted (actual start is async). */ async play({ encoded, info, userData, audioTrackId, noReplace = false, startTime, endTime = 0 }) { + logger('debug', 'Player', `[Action: play] Method invoked for guild ${this.guildId} with track ${info.identifier}`); return new Promise((resolve) => { this.isUpdatingTrack = true; try { @@ -1087,10 +1159,20 @@ export class Player { * @returns True when the seek succeeds; false otherwise. */ async seek(position, endTime, forceLegacy = false) { - if (this.destroying || !this.track) + logger('debug', 'Player', `[Action: seek] Method invoked for guild ${this.guildId} with target position: ${position}ms`); + if (this.destroying || !this.track) { + logger('debug', 'Player', `[Action: seek] Aborted for guild ${this.guildId}: destroying=${this.destroying}, hasTrack=${!!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 (seekPosition === 0 && !this._isRecovering && @@ -1110,7 +1192,6 @@ export class Player { let seekPromise; if (!this.streamInfo?.url) { logger('debug', 'Player', 'No stream info URL available for seek. awaiting getTrackUrl.'); - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); await sleep(1600); if (!this.streamInfo?.url) { logger('debug', 'Player', 'Still no stream info URL available for seek.'); @@ -1168,6 +1249,11 @@ export class Player { } return result; } + catch (e) { + logger('error', 'Player', `Seek failed for guild ${this.guildId}`, e); + this._onError(e); + return false; + } finally { this._isSeeking = false; } @@ -1181,26 +1267,22 @@ export class Player { logger('debug', 'Player', `Seeking using source (native) to ${position}ms for guild ${this.guildId}`); this.position = position; this.track.endTime = endTime; - let previousSession = null; let reuseUrlData = null; if (this.streamInfo?.protocol === 'sabr' && this.connection?.audioStream) { - const inputStream = this.connection.audioStream - ?.pipes?.[0]; - if (inputStream && typeof inputStream.getSessionState === 'function') { - previousSession = inputStream.getSessionState(); - if (previousSession) { - logger('debug', 'Player', `Extracted SABR session state: rn=${previousSession.requestNumber}, hasCookie=${!!previousSession.nextRequestPolicy?.playbackCookie}`); - reuseUrlData = { - protocol: this.streamInfo.protocol, - url: this.streamInfo.url, - additionalData: { - ...this.streamInfo.additionalData, - previousSession, - startTime: position - } - }; - logger('debug', 'Player', `Reusing existing SABR streaming URL for seek to maintain session`); - } + const inputStream = this.connection.audioStream?.pipes?.[0]; + const previousSession = inputStream?.getSessionState?.(); + if (previousSession) { + logger('debug', 'Player', `Extracted SABR session state: rn=${previousSession.requestNumber}, hasCookie=${!!previousSession.nextRequestPolicy?.playbackCookie}`); + reuseUrlData = { + protocol: this.streamInfo.protocol, + url: this.streamInfo.url, + additionalData: { + ...this.streamInfo.additionalData, + previousSession, + startTime: position + } + }; + logger('debug', 'Player', `Reusing existing SABR streaming URL for seek to maintain session`); } } const trackInfo = { @@ -1214,43 +1296,16 @@ export class Player { this._onError(err); return false; } - if (!this.connection) { - this._initConnection(); - } - if (!this.connection?.udpInfo?.secretKey) { - await this.waitEvent('stateChange', (s) => s.status === 'connected' && !!this.connection?.udpInfo?.secretKey); - } - if (!this.connection?.udpInfo?.secretKey) { - const errorMessage = `Voice connection for guild ${this.guildId} is not ready (missing UDP info). Aborting playback.`; - logger('error', 'Player', errorMessage); - this._onError(new Error(errorMessage)); + const result = await this._connectAndPlayStream(urlData, position, 'source-seek', 'seekPrepare', `Playing resource for guild ${this.guildId} after source seek`); + if (!result) return false; - } - const fetched = await this._fetchResource(this.track.info, urlData, position); - if ('exception' in fetched) { - const err = new Error(fetched.exception.message); - this._onError(err); - return false; - } - this._cleanupCurrentAudioStream('source-seek'); - const resource = fetched.stream; - if (this.volumePercent !== 100) { - resource.setVolume(this.volumePercent / 100); - } - this._fading('seekPrepare', { resource }); - this.setFilters(this.filters); - logger('debug', 'Player', `Playing resource for guild ${this.guildId} after source seek`); - this.connection.play(resource); - await this.waitEvent('playerStateChange', (s) => s.status === 'playing'); - this._lyricsBasePosition = position; - this._lyricsBasePackets = this.connection?.statistics?.packetsExpected ?? 0; return true; } /** * 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(); @@ -1370,10 +1425,13 @@ export class Player { * @returns True when stop was executed; false when no active track. */ stop() { + logger('debug', 'Player', `[Action: stop] Executing stop for guild ${this.guildId}`); this.isUpdatingTrack = true; try { - if (this.destroying || !this.track) + if (this.destroying || !this.track) { + logger('debug', 'Player', `[Action: stop] Aborted for guild ${this.guildId}: destroying=${this.destroying}, hasTrack=${!!this.track}`); return false; + } if (this.nextResource) { this.nextResource.destroy(); this.nextResource = null; @@ -1410,8 +1468,11 @@ export class Player { * @returns True when preload succeeded. */ async preload(payload) { - if (this.destroying) + logger('debug', 'Player', `[Action: preload] Method invoked for guild ${this.guildId} with track ${payload.info.identifier}`); + if (this.destroying) { + logger('debug', 'Player', `[Action: preload] Aborted for guild ${this.guildId}: player is destroying`); return false; + } const sameEncoded = !!payload.encoded && !!this.nextTrack?.encoded && this.nextTrack.encoded === payload.encoded; @@ -1465,6 +1526,7 @@ export class Player { * @returns True when state was cleared. */ clearNextTrack() { + logger('debug', 'Player', `[Action: clearNextTrack] Method invoked for guild ${this.guildId}`); if (this.destroying) return false; if (this.nextResource) { @@ -1482,8 +1544,11 @@ export class Player { * @returns True when state changed; false otherwise. */ pause(shouldPause) { - if (this.destroying || this.isPaused === shouldPause) + logger('debug', 'Player', `[Action: pause] Method invoked for guild ${this.guildId} with target: ${shouldPause}`); + if (this.destroying || this.isPaused === shouldPause) { + logger('debug', 'Player', `[Action: pause] Aborted for guild ${this.guildId}: destroying=${this.destroying}, alreadyPaused=${this.isPaused === shouldPause}`); return false; + } logger('debug', 'Player', `Setting pause to ${shouldPause} for guild ${this.guildId}`); if (shouldPause) { this._pausedAtPosition = this._realPosition(); @@ -1515,8 +1580,11 @@ export class Player { * @returns True when volume was updated. */ volume(level) { - if (this.destroying) + logger('debug', 'Player', `[Action: volume] Method invoked for guild ${this.guildId} with target: ${level}`); + if (this.destroying) { + logger('debug', 'Player', `[Action: volume] Aborted for guild ${this.guildId}: player is destroying`); return false; + } logger('debug', 'Player', `Setting volume to ${level} for guild ${this.guildId}`); this.volumePercent = Math.max(0, Math.min(1000, level)); this.connection?.audioStream?.setVolume(this.volumePercent / 100); @@ -1531,6 +1599,7 @@ export class Player { * @returns Always true. */ setFading(config) { + logger('debug', 'Player', `[Action: setFading] Method invoked for guild ${this.guildId}`); this.fading = config; return true; } @@ -1541,6 +1610,7 @@ export class Player { * @returns True when updated. */ setLoudnessNormalizer(enabled) { + logger('debug', 'Player', `[Action: setLoudnessNormalizer] Method invoked for guild ${this.guildId} to ${enabled}`); this.loudnessNormalizer = !!enabled; if (this.connection?.audioStream) { this.connection.audioStream.setLoudnessNormalizer?.(this.loudnessNormalizer); @@ -1554,8 +1624,11 @@ export class Player { * @returns True when filters applied; false if player inactive. */ setFilters(filters) { - if (this.destroying || !this.track) + logger('debug', 'Player', `[Action: setFilters] Method invoked for guild ${this.guildId}`); + if (this.destroying || !this.track) { + logger('debug', 'Player', `[Action: setFilters] Aborted for guild ${this.guildId}: destroying=${this.destroying}, hasTrack=${!!this.track}`); return false; + } logger('debug', 'Player', `Applying filters for guild ${this.guildId}:`, filters); const payload = filters.filters ?? filters; @@ -1688,6 +1761,7 @@ export class Player { * @param force - Forces reconnect even when unchanged. */ updateVoice(voicePayload = {}, force = false) { + logger('debug', 'Player', `[Action: updateVoice] Method invoked for guild ${this.guildId} with force=${force}`); if (this.destroying) return; const { sessionId, token, endpoint, channelId } = voicePayload; @@ -1720,6 +1794,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 @@ -1734,7 +1811,9 @@ export class Player { !this.connection?.audioStream && !this.isUpdatingTrack) { logger('debug', 'Player', `Voice state updated for guild ${this.guildId}, starting pending track.`); - await this._startPlayback(); + await this._startPlayback().catch((err) => { + logger('error', 'Player', `Failed to start pending track during voice update for guild ${this.guildId}:`, err); + }); } }); } @@ -1748,10 +1827,10 @@ export class Player { * @param emitClose - Whether to emit WEBSOCKET_CLOSED to the client. */ destroy(emitClose = true) { + logger('debug', 'Player', `[Action: destroy] Method invoked for guild ${this.guildId} with emitClose=${emitClose}`); if (this.destroying) return; this.destroying = true; - logger('debug', 'Player', `Destroying player for guild ${this.guildId}`); if (this.connection) { try { if (this.connection.audioStream) { @@ -1776,6 +1855,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; @@ -1788,13 +1872,14 @@ export class Player { * @throws Error when no active main stream or mixer limits exceeded. */ async addMix(trackPayload, volume = null) { + logger('debug', 'Player', `[Action: addMix] Method invoked for guild ${this.guildId}`); if (!this.track || this.isPaused) { throw new Error('Cannot add mix without an active stream'); } 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 @@ -1827,6 +1912,7 @@ export class Player { * @returns True when removed. */ removeMix(mixId) { + logger('debug', 'Player', `[Action: removeMix] Method invoked for guild ${this.guildId} mixId=${mixId}`); if (!this.audioMixer) { return false; } @@ -1840,6 +1926,7 @@ export class Player { * @returns True when updated; false if layer missing. */ updateMix(mixId, volume) { + logger('debug', 'Player', `[Action: updateMix] Method invoked for guild ${this.guildId} mixId=${mixId} volume=${volume}`); if (!this.audioMixer) { return false; } @@ -1851,6 +1938,7 @@ export class Player { * @returns Current mix layers with track and volume. */ getMixes() { + logger('debug', 'Player', `[Action: getMixes] Method invoked for guild ${this.guildId}`); if (!this.audioMixer) { return []; } @@ -1862,6 +1950,7 @@ export class Player { * @param skipTrackSource - When true, skips track source provider before fetching lyrics. */ async subscribeLyrics(skipTrackSource) { + logger('debug', 'Player', `[Action: subscribeLyrics] Method invoked for guild ${this.guildId}`); return new Promise((resolve) => { if (this.isLyricsSubscribed) { return resolve(); @@ -1882,6 +1971,7 @@ export class Player { * Unsubscribes from lyrics events. */ unsubscribeLyrics() { + logger('debug', 'Player', `[Action: unsubscribeLyrics] Method invoked for guild ${this.guildId}`); return new Promise((resolve) => { this.isLyricsSubscribed = false; this.skipTrackSource = false; @@ -1900,6 +1990,7 @@ export class Player { * @returns Current segments and configuration. */ getSponsorBlock() { + logger('debug', 'Player', `[Action: getSponsorBlock] Method invoked for guild ${this.guildId}`); return this.sponsorBlock; } /** @@ -1908,6 +1999,7 @@ export class Player { * @param updates - Configuration updates. */ updateSponsorBlock(updates) { + logger('debug', 'Player', `[Action: updateSponsorBlock] Method invoked for guild ${this.guildId}`); if (updates.enabled !== undefined) this.sponsorBlock.enabled = updates.enabled; if (updates.categories !== undefined) @@ -1921,6 +2013,7 @@ export class Player { * @param segments - Array of segments to apply. */ setSponsorBlockSegments(segments) { + logger('debug', 'Player', `[Action: setSponsorBlockSegments] Method invoked for guild ${this.guildId}`); this.sponsorBlock.segments = segments; this.sponsorBlock.lastSkippedUuid = null; } @@ -1928,6 +2021,7 @@ export class Player { * Clears SponsorBlock state for the player. */ clearSponsorBlock() { + logger('debug', 'Player', `[Action: clearSponsorBlock] Method invoked for guild ${this.guildId}`); this.sponsorBlock.segments = []; this.sponsorBlock.lastSkippedUuid = null; } @@ -2080,6 +2174,7 @@ export class Player { * Serializes player state to JSON-safe object. */ toJSON() { + logger('debug', 'Player', `[Action: toJSON] Method invoked for guild ${this.guildId}`); return { guildId: this.guildId, track: this.track, @@ -2092,15 +2187,18 @@ 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 } + voice: structuredClone(this.voice) }; } /** * Handles fading, tape, and scratch actions for start/stop/seek/pause events. */ _fading(action, payload = {}) { + logger('debug', 'Player', `[Fading] Executing fading action '${action}' for guild ${this.guildId}`); const timers = this._fadeTimers; if (!timers) return false; @@ -2217,7 +2315,7 @@ export class Player { }, delay); return true; } - if (!this.fading || this.fading.enabled !== true) + if (this.fading?.enabled !== true) return false; let section = null; if (action === 'trackStart' || action === 'trackStartArm') diff --git a/dist/src/playback/processing/AudioMixer.js b/dist/src/playback/processing/AudioMixer.js index cfb6f22b..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(); @@ -267,4 +286,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 9c2430fa..31c79bd0 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, @@ -110,6 +116,10 @@ export class FlowController extends Transform { // This exists to satisfy streamProcessor's type check and avoid 'as any' } _transform(chunk, _encoding, callback) { + if (!this.pendingBuffer) { + callback(); + return; + } let offset = 0; if (this.pendingLength > 0) { const needed = FRAME_SIZE - this.pendingLength; @@ -136,7 +146,7 @@ export class FlowController extends Transform { } _flush(callback) { let remaining = this.pendingLength > 0 - ? this.pendingBuffer.subarray(0, this.pendingLength) + ? (this.pendingBuffer?.subarray(0, this.pendingLength) ?? EMPTY_BUFFER) : EMPTY_BUFFER; this.pendingLength = 0; if (remaining.length > 0) { @@ -168,4 +178,8 @@ export class FlowController extends Transform { } callback(); } + _destroy(_err, cb) { + this.pendingBuffer = null; + cb(null); + } } diff --git a/dist/src/playback/processing/LoudnessNormalizer.js b/dist/src/playback/processing/LoudnessNormalizer.js index 7d3dd9a8..475d5b69 100644 --- a/dist/src/playback/processing/LoudnessNormalizer.js +++ b/dist/src/playback/processing/LoudnessNormalizer.js @@ -154,6 +154,8 @@ export class LoudnessNormalizer { process(inputView) { if (inputView.length === 0) return; + if (!this._channelBuffer) + return; const frameCount = inputView.length / this.channels; const channelBuffer = this._channelBuffer; let energyState = this._energyState; @@ -214,4 +216,8 @@ export class LoudnessNormalizer { this._releaseAlpha = fround(Math.exp(-1 / (releaseTime * sr))); this._energyAlpha = fround(Math.exp(-1 / (shortTermTime * sr))); } + destroy() { + 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 422e321f..de1eacc1 100644 --- a/dist/src/playback/processing/VolumeTransformer.js +++ b/dist/src/playback/processing/VolumeTransformer.js @@ -82,6 +82,18 @@ 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) { @@ -185,7 +197,7 @@ export class VolumeTransformer extends Transform { const { gainStart, gainEnd } = this._computeFadeGains(usableSamples); const gainStep = usableSamples > 1 ? (gainEnd - gainStart) / (usableSamples - 1) : 0; let gain = gainStart; - if (this.lookaheadSamples > 0) { + if (this.lookaheadSamples > 0 && this.lookaheadBuffer) { const outputBuffer = alignedBufferIfRequired(chunk.length); const outputView = new Int16Array(outputBuffer.buffer, outputBuffer.byteOffset, usableSamples); if (useBufferOps) { @@ -255,4 +267,12 @@ export class VolumeTransformer extends Transform { callback(error); } } + _destroy(_err, cb) { + this.lookaheadBuffer = null; + if (this.agc && typeof this.agc.destroy === 'function') { + this.agc.destroy(); + } + this.agc = null; + cb(null); + } } diff --git a/dist/src/playback/processing/filtersManager.js b/dist/src/playback/processing/filtersManager.js index 64c54913..929dfbcc 100644 --- a/dist/src/playback/processing/filtersManager.js +++ b/dist/src/playback/processing/filtersManager.js @@ -241,4 +241,13 @@ export class FiltersManager extends Transform { } return filters; } + _destroy(_err, cb) { + for (const name in this.filterInstances) { + const instance = this.filterInstances[name]; + 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 7cb0d37c..8665e977 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -26,7 +26,7 @@ const getMP4Box = async () => { }; const getLibSampleRate = async () => { if (!libSampleRatePromise) { - libSampleRatePromise = import('@alexanderolsen/libsamplerate-js').then((module) => module); + libSampleRatePromise = import('@alexanderolsen/libsamplerate-js').then((module) => (module.default || module)); } return libSampleRatePromise; }; @@ -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, @@ -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) { @@ -392,6 +406,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() { @@ -414,8 +432,8 @@ class BaseAudioResource { pipe.abort?.(); pipe.unpipe?.(); pipe.destroy?.(); - pipe.removeAllListeners?.(); } + this.pipes.length = 0; this.stream = null; this.pipes = null; } @@ -447,6 +465,19 @@ class BaseAudioResource { checkScratchEffectCompleted() { return false; } + tapeTo(_durationMs, _type, _curve) { } + setLoudnessNormalizer(_enabled) { } + isPipelineFinished() { + if (this._destroyed || !this.pipes) + return true; + for (const pipe of this.pipes) { + if (pipe.isFinished || + pipe.readableEnded) { + return true; + } + } + return false; + } setVolume(volume) { if (!this.pipes) return; @@ -534,34 +565,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; @@ -576,7 +603,6 @@ class SymphoniaDecoderStream extends Transform { clearImmediate(this._immediateId); this._immediateId = null; } - this._loopScheduled = false; } _isDecoderValid() { return this.decoder !== null && !this._aborted && !this.isFinished; @@ -586,117 +612,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); + } } - _scheduleDecode() { - if (this._loopScheduled || - this._isDecoding || - !this._isDecoderValid() || - this.readableFlowing === false) + _read(_size) { + super._read(_size); + if (this._isDecoderValid()) { + this._scheduleDecode(); + } + } + _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(); @@ -705,47 +723,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(); } @@ -832,7 +842,7 @@ class MPEGTSDemuxer extends Transform { offset += (packet[offset] || 0) + 1; if (offset + 11 < MPEGTS_CONFIG.packetSize) { this.patPmtId = - ((packet[offset + 10] || 0 & 0x1f) << 8) | (packet[offset + 11] || 0); + (((packet[offset + 10] || 0) & 0x1f) << 8) | (packet[offset + 11] || 0); } } _processPMT(packet, offset) { @@ -937,8 +947,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) { @@ -1094,9 +1106,7 @@ class AACDecoderStream extends Transform { if (!this.resamplerCreationPromise) { this.resamplerCreationPromise = getLibSampleRate() .then((libSampleRate) => libSampleRate.create(2, sampleRate, 48000, { - converterType: _getResamplerConverterType(this.resamplingQuality, libSampleRate - // biome-ignore lint/suspicious/noExplicitAny: library type mismatch - ) + converterType: _getResamplerConverterType(this.resamplingQuality, libSampleRate) })) .then((resampler) => { this.resampler = resampler; @@ -1150,8 +1160,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(); @@ -1184,7 +1196,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; @@ -1823,7 +1835,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); @@ -1852,7 +1864,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: @@ -1885,7 +1897,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) => { @@ -1919,8 +1933,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) { @@ -1957,8 +1973,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', @@ -1977,7 +1993,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({ @@ -2055,6 +2071,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({ @@ -2162,7 +2191,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) { @@ -2175,7 +2204,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; } } @@ -2197,7 +2228,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/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/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..f76faee0 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -1,14 +1,11 @@ -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_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 = /