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..59c7cfb2 --- /dev/null +++ b/config.default.ts @@ -0,0 +1,798 @@ +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_DOWN', + 'TV', + 'TVCast', + 'WebEmbedded', + 'WebParentTools', + 'Web', + 'IOS' + ], + resolve: [ + 'AndroidVR', + 'TV_DOWN', + '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 + }, + + newgrounds: { + 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', + preferredSources: [], + 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..c444fd94 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,379 @@ 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_DOWN', + 'TV', + 'TVCast', + 'WebEmbedded', + 'WebParentTools', + 'Web', + 'IOS' + ], + resolve: [ + 'AndroidVR', + 'TV_DOWN', + '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 +458,7 @@ export default { includeAnalysis: true, includeAnalysisSummary: true, eternalStream: true, - cacheMaxBytes: 20 * 1024 * 1024, + cacheMaxBytes: 20971520, maxBranches: 4, maxBranchThreshold: 75, branchThresholdStart: 10, @@ -235,29 +482,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 +526,120 @@ 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 + }, + newgrounds: { + 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 +653,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,32 +663,17 @@ 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 } }, lyrics: { fallbackSource: 'genius', + preferredSources: [], youtube: { enabled: true }, @@ -498,7 +682,6 @@ export default { }, musixmatch: { enabled: true - // signatureSecret: '' }, deezer: { enabled: true @@ -527,128 +710,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 +724,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..dbde68d7 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.20260801.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": "node -e \"const v=process.versions.node.split('.').map(Number); const t=(v[0]===22&&v[1]>=6)||v[0]>=23; require('child_process').spawnSync('node', ['--dns-result-order=ipv4first', ...(t?['--experimental-strip-types','src/index.ts']:['dist/src/index.js']), ...process.argv.slice(1)], {stdio:'inherit'})\" --", + "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,21 @@ "@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", - "typescript": "^6.0.3" + "typescript": "^7.0.2" } } diff --git a/dist/src/api/connection.js b/dist/src/api/connection.js index efdfa55c..f0c2ce99 100644 --- a/dist/src/api/connection.js +++ b/dist/src/api/connection.js @@ -1,4 +1,4 @@ -import { sendResponse } from "../utils.js"; +import { sendResponse } from '../utils.js'; /** * Creates a strongly typed runtime view for the connection endpoint. * diff --git a/dist/src/api/decodeTrack.js b/dist/src/api/decodeTrack.js index c0e932b4..929b1741 100644 --- a/dist/src/api/decodeTrack.js +++ b/dist/src/api/decodeTrack.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Default validation message used when the encoded track query parameter is * absent or empty. diff --git a/dist/src/api/decodeTracks.js b/dist/src/api/decodeTracks.js index 27fcfb89..65bfd883 100644 --- a/dist/src/api/decodeTracks.js +++ b/dist/src/api/decodeTracks.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Default validation message used when the request body is not a non-empty * array of encoded track strings. diff --git a/dist/src/api/encodeTrack.js b/dist/src/api/encodeTrack.js index 049380d5..d78da288 100644 --- a/dist/src/api/encodeTrack.js +++ b/dist/src/api/encodeTrack.js @@ -1,4 +1,4 @@ -import { encodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { encodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Validation message used when no usable track payload is provided. */ diff --git a/dist/src/api/encodedTracks.js b/dist/src/api/encodedTracks.js index 641f8667..1c11e2f2 100644 --- a/dist/src/api/encodedTracks.js +++ b/dist/src/api/encodedTracks.js @@ -1,4 +1,4 @@ -import { encodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { encodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Validation message used when the request body is not a non-empty track list. */ diff --git a/dist/src/api/index.js b/dist/src/api/index.js index ced90178..736d5da4 100644 --- a/dist/src/api/index.js +++ b/dist/src/api/index.js @@ -9,8 +9,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte import fs from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { PATH_VERSION } from "../constants.js"; -import { logger, sendErrorResponse, sendResponse, verifyMethod } from "../utils.js"; +import { PATH_VERSION } from '../constants.js'; +import { logger, sendErrorResponse, sendResponse, verifyMethod } from '../utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const defaultMethods = ['GET']; @@ -81,9 +81,9 @@ async function loadRoutes() { else if (routeName.includes('.')) { const parts = routeName.split('.'); const basePattern = parts - .map((part) => (part === 'id' ? '(?:id|[A-Za-z0-9]+)' : part)) + .map((part) => (part === 'id' ? '(?:id|[A-Za-z0-9_-]+)' : part)) .join('/'); - pathname = new RegExp(`^/${PATH_VERSION}/${basePattern}(?:/[A-Za-z0-9]+)?/?$`); + pathname = new RegExp(`^/${PATH_VERSION}/${basePattern}(?:/[A-Za-z0-9_-]+)?/?$`); } else { pathname = `/${PATH_VERSION}/${routeName}`; @@ -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..0e179c26 100644 --- a/dist/src/api/info.js +++ b/dist/src/api/info.js @@ -1,5 +1,5 @@ import process from 'node:process'; -import { getVersion } from "../utils.js"; +import { getVersion } from '../utils.js'; /** * Creates a strongly typed runtime view for the info endpoint. * @@ -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..49e57c0b 100644 --- a/dist/src/api/loadChapters.js +++ b/dist/src/api/loadChapters.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Default validation message used when the `encodedTrack` query parameter is * missing or empty. @@ -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/loadLyrics.js b/dist/src/api/loadLyrics.js index 90281789..c41b4701 100644 --- a/dist/src/api/loadLyrics.js +++ b/dist/src/api/loadLyrics.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Default validation message used when the `encodedTrack` query parameter is * missing or empty. diff --git a/dist/src/api/loadStream.js b/dist/src/api/loadStream.js index d373aacf..8c6d41da 100644 --- a/dist/src/api/loadStream.js +++ b/dist/src/api/loadStream.js @@ -1,5 +1,5 @@ import { pipeline } from 'node:stream'; -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Cached dynamic import for the stream processor module. */ @@ -19,7 +19,7 @@ const LOAD_STREAM_HEADERS = { */ function getStreamProcessorModule() { if (!streamProcessorModulePromise) { - streamProcessorModulePromise = import("../playback/processing/streamProcessor.js"); + streamProcessorModulePromise = import('../playback/processing/streamProcessor.js'); } return streamProcessorModulePromise; } @@ -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..1ebf0221 100644 --- a/dist/src/api/loadTracks.js +++ b/dist/src/api/loadTracks.js @@ -1,5 +1,5 @@ -import { logger, sendErrorResponse } from "../utils.js"; -import { validator } from "../validators.js"; +import { logger, sendErrorResponse } from '../utils.js'; +import { validator } from '../validators.js'; /** * Quick protocol matcher for absolute URLs. */ @@ -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/meaning.js b/dist/src/api/meaning.js index 6e5512c5..9d6ee529 100644 --- a/dist/src/api/meaning.js +++ b/dist/src/api/meaning.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Default validation message used when the `encodedTrack` query parameter is * missing or empty. diff --git a/dist/src/api/profiler.file.js b/dist/src/api/profiler.file.js index 65576d99..78e75504 100644 --- a/dist/src/api/profiler.file.js +++ b/dist/src/api/profiler.file.js @@ -1,7 +1,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { sendErrorResponse, sendResponse } from "../utils.js"; +import { sendErrorResponse, sendResponse } from '../utils.js'; /** * Loopback addresses allowed to access the profiler endpoints when external * access is disabled. diff --git a/dist/src/api/profiler.js b/dist/src/api/profiler.js index afe22bd1..58740b44 100644 --- a/dist/src/api/profiler.js +++ b/dist/src/api/profiler.js @@ -3,7 +3,7 @@ import fsPromises from 'node:fs/promises'; import inspector from 'node:inspector'; import os from 'node:os'; import v8 from 'node:v8'; -import { sendErrorResponse, sendResponse } from "../utils.js"; +import { sendErrorResponse, sendResponse } from '../utils.js'; const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); const { NODELINK_PROFILER_DIR: profilerDirectoryEnv } = process.env; const profilerBaseDir = profilerDirectoryEnv || '.profiles'; @@ -655,8 +655,8 @@ async function runMasterProfilerCommand(action, payload) { async function runWorkerProfilerCommand(manager, workers, action, payload) { const timeoutMs = getTimeoutForAction(action); const commandPayload = { - action, - ...(payload ?? {}) + ...(payload ?? {}), + action }; const settled = await Promise.allSettled(workers.map(async (worker) => { const response = await manager.execute(worker, 'profilerCommand', commandPayload, { timeoutMs }); @@ -741,7 +741,7 @@ async function collectActionSnapshot(nodelink, action, payload) { 'Specialized source workers are not enabled or do not support profiling.'; } else { - output.sourceWorkers = await sourceManager.executeAll('profilerCommand', { action, ...safePayload }, { timeoutMs: getTimeoutForAction(action), parseJson: true }); + output.sourceWorkers = await sourceManager.executeAll('profilerCommand', { ...safePayload, action }, { timeoutMs: getTimeoutForAction(action), parseJson: true }); } } return output; diff --git a/dist/src/api/profiler.ui.js b/dist/src/api/profiler.ui.js index fee87c3a..0e8abbe9 100644 --- a/dist/src/api/profiler.ui.js +++ b/dist/src/api/profiler.ui.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { sendErrorResponse } from "../utils.js"; +import { sendErrorResponse } from '../utils.js'; const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -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/routeplanner.js b/dist/src/api/routeplanner.js index 341fa888..daa27e87 100644 --- a/dist/src/api/routeplanner.js +++ b/dist/src/api/routeplanner.js @@ -1,4 +1,4 @@ -import { sendErrorResponse, sendResponse } from "../utils.js"; +import { sendErrorResponse, sendResponse } from '../utils.js'; /** * Builds a strongly typed runtime view for the route planner endpoint. * diff --git a/dist/src/api/sessions.id.groups.js b/dist/src/api/sessions.id.groups.js new file mode 100644 index 00000000..cc24b1c6 --- /dev/null +++ b/dist/src/api/sessions.id.groups.js @@ -0,0 +1,413 @@ +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; +function isObjectRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} +function getGroupsRouteRuntime(nodelink) { + const runtime = nodelink; + if (!runtime.sessions || + typeof runtime.sessions.get !== 'function') { + return null; + } + return runtime; +} +function getPathParams(parsedUrl) { + const parts = parsedUrl.pathname.split('/'); + const sessionId = parts[3]; + const groupId = parts[5]; + if (!sessionId) { + return null; + } + return groupId && groupId !== '' + ? { sessionId, groupId } + : { sessionId }; +} +function normalizeTrackInfo(decodedTrack) { + return { + ...decodedTrack.info, + uri: decodedTrack.info.uri ?? '', + artworkUrl: decodedTrack.info.artworkUrl ?? null, + isrc: decodedTrack.info.isrc ?? null + }; +} +function getAudioTrackId(trackPayload) { + return trackPayload.language ?? trackPayload.audioTrackId ?? undefined; +} +async function resolvePlayPayload(nodelink, trackPayload) { + if (!trackPayload) { + return undefined; + } + if (trackPayload.encoded !== undefined) { + if (trackPayload.encoded === null) { + return null; + } + const decodedTrack = decodeTrack(trackPayload.encoded.replace(/ /g, '+')); + return { + encoded: decodedTrack.encoded, + info: normalizeTrackInfo(decodedTrack), + audioTrackId: getAudioTrackId(trackPayload) + }; + } + if (trackPayload.identifier) { + if (!nodelink.loadTrack) { + throw new Error('Track identifier loading is not supported.'); + } + const loadResult = await nodelink.loadTrack(trackPayload.identifier); + if (loadResult.loadType !== 'track') { + if (loadResult.loadType === 'empty') { + throw new Error('Track identifier resolved to no tracks.'); + } + throw new Error(`Track identifier resolved to ${loadResult.loadType}, expected 'track'.`); + } + return { + encoded: loadResult.data.encoded, + info: loadResult.data.info, + audioTrackId: getAudioTrackId(trackPayload) + }; + } + return undefined; +} +function sanitizeFadingConfig(raw) { + const safe = { + 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: 'linear', type: 'volume' }, + resume: { duration: 0, curve: 'linear', type: 'volume' } + }; + if (!isObjectRecord(raw)) { + return safe; + } + const payload = raw; + safe.enabled = payload.enabled === true; + const updateSection = (key) => { + const section = payload[key]; + const target = safe[key]; + if (!isObjectRecord(section) || !target) { + return; + } + const typedSection = section; + const { duration, curve, type } = typedSection; + if (typeof duration === 'number' && Number.isFinite(duration)) { + target.duration = Math.max(0, duration); + } + if (typeof curve === 'string') { + target.curve = curve; + } + if (type === 'volume' || + type === 'tape' || + type === 'scratch' || + type === 'both') { + target.type = type; + } + }; + updateSection('trackStart'); + updateSection('trackEnd'); + updateSection('trackStop'); + updateSection('seek'); + updateSection('pause'); + updateSection('resume'); + if (isObjectRecord(payload.ducking)) { + const duckingInput = payload.ducking; + safe.ducking = { + enabled: duckingInput.enabled === true, + duration: typeof duckingInput.duration === 'number' && Number.isFinite(duckingInput.duration) + ? Math.max(0, duckingInput.duration) + : 500, + targetVolume: typeof duckingInput.targetVolume === 'number' && Number.isFinite(duckingInput.targetVolume) + ? Math.max(0, Math.min(1, duckingInput.targetVolume)) + : 0.3, + curve: typeof duckingInput.curve === 'string' ? duckingInput.curve : 'linear' + }; + } + return safe; +} +function getGroupCreatePayload(body) { + if (!isObjectRecord(body)) + return null; + const payload = body; + const id = payload.id; + if (typeof id !== 'string' || id.length === 0) + return null; + const guildIds = payload.guildIds; + if (guildIds !== undefined) { + if (!Array.isArray(guildIds)) + return null; + for (const gid of guildIds) { + if (typeof gid !== 'string' || !/^\d{17,20}$/.test(gid)) + return null; + } + } + return { + id: id, + guildIds: guildIds ?? [] + }; +} +function getGroupPatchPayload(body) { + if (!isObjectRecord(body)) + return null; + const payload = body; + const result = {}; + if (payload.players !== undefined) { + if (!isObjectRecord(payload.players)) + return null; + const p = payload.players; + const players = {}; + if (p.add !== undefined) { + if (!Array.isArray(p.add)) + return null; + for (const gid of p.add) { + if (typeof gid !== 'string' || !/^\d{17,20}$/.test(gid)) + return null; + } + players.add = p.add; + } + if (p.remove !== undefined) { + if (!Array.isArray(p.remove)) + return null; + for (const gid of p.remove) { + if (typeof gid !== 'string') + return null; + } + players.remove = p.remove; + } + result.players = players; + } + if (payload.track !== undefined) { + if (payload.track !== null && !isObjectRecord(payload.track)) + return null; + result.track = payload.track; + } + if (payload.position !== undefined) { + if (typeof payload.position !== 'number' || + !Number.isFinite(payload.position) || + payload.position < 0) + return null; + result.position = payload.position; + } + if (payload.endTime !== undefined) { + if (payload.endTime !== null && + (typeof payload.endTime !== 'number' || + !Number.isFinite(payload.endTime) || + payload.endTime < 0)) + return null; + result.endTime = payload.endTime; + } + if (payload.volume !== undefined) { + if (typeof payload.volume !== 'number' || + !Number.isFinite(payload.volume) || + payload.volume < 0 || + payload.volume > 1000) + return null; + result.volume = payload.volume; + } + if (payload.paused !== undefined) { + if (typeof payload.paused !== 'boolean') + return null; + result.paused = payload.paused; + } + if (payload.filters !== undefined) { + if (!payload.filters || + typeof payload.filters !== 'object' || + Array.isArray(payload.filters)) + return null; + result.filters = payload.filters; + } + if (payload.fading !== undefined) { + if (!payload.fading || + typeof payload.fading !== 'object' || + Array.isArray(payload.fading)) + return null; + result.fading = payload.fading; + } + if (payload.loudnessNormalizer !== undefined) { + if (typeof payload.loudnessNormalizer !== 'boolean') + return null; + result.loudnessNormalizer = payload.loudnessNormalizer; + } + if (payload.ducking !== undefined) { + if (typeof payload.ducking !== 'boolean') + return null; + result.ducking = payload.ducking; + } + return result; +} +async function applyGroupPatch(runtime, session, groupId, payload) { + const groups = session.groups; + if (!groups.has(groupId)) { + throw new Error(`Group '${groupId}' not found`); + } + if (payload.players?.add) { + for (const guildId of payload.players.add) { + groups.addPlayer(groupId, guildId); + } + } + if (payload.players?.remove) { + for (const guildId of payload.players.remove) { + groups.removePlayer(groupId, guildId); + } + } + const guildIds = groups.getGuildIds(groupId); + let trackToPlay = undefined; + if (payload.track !== undefined) { + trackToPlay = await resolvePlayPayload(runtime, payload.track); + } + const errors = []; + const playerStates = []; + for (const guildId of guildIds) { + try { + await session.players.create(guildId); + if (trackToPlay === null) { + await session.players.stop(guildId); + } + else if (trackToPlay) { + await session.players.play(guildId, { + ...trackToPlay, + userData: payload.track?.userData, + startTime: payload.position, + endTime: payload.endTime ?? undefined + }); + } + if (payload.volume !== undefined) { + await session.players.volume(guildId, payload.volume); + } + if (payload.paused !== undefined) { + await session.players.pause(guildId, payload.paused); + } + if (payload.position !== undefined && trackToPlay === undefined) { + await session.players.seek(guildId, payload.position); + } + if (payload.endTime !== undefined && trackToPlay === undefined) { + const playerState = await session.players.toJSON(guildId); + await session.players.seek(guildId, playerState.state.position, payload.endTime ?? undefined); + } + if (payload.filters !== undefined) { + await session.players.setFilters(guildId, payload.filters); + } + if (payload.fading !== undefined) { + await session.players.setFading(guildId, sanitizeFadingConfig(payload.fading)); + } + if (payload.loudnessNormalizer !== undefined) { + await session.players.setLoudnessNormalizer(guildId, payload.loudnessNormalizer); + } + if (payload.ducking !== undefined) { + await session.players.setDucking(guildId, payload.ducking); + } + const state = await session.players.toJSON(guildId); + playerStates.push(state); + await new Promise(resolve => setTimeout(resolve, 35)); + } + catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger('error', 'GroupUpdate', `Failed to apply patch to guild ${guildId} in group '${groupId}': ${message}`); + errors.push({ guildId, error: message }); + } + } + const groupJson = groups.toJSON(groupId); + return { + ...groupJson, + players: playerStates, + ...(errors.length > 0 ? { errors } : {}) + }; +} +/** + * Handles requests for the groups route. + * + * Supports: + * - `GET /sessions/:id/groups` — list all groups + * - `GET /sessions/:id/groups/:groupId` — get specific group + * - `POST /sessions/:id/groups` — create a new group + * - `PATCH /sessions/:id/groups/:groupId` — update group (membership + orchestration) + * - `DELETE /sessions/:id/groups/:groupId` — delete group (does not destroy players) + */ +async function handler(nodelink, req, res, sendResponse, parsedUrl) { + const runtime = getGroupsRouteRuntime(nodelink); + if (!runtime) { + sendErrorResponse(req, res, 500, 'Internal Server Error', 'Groups runtime contract is incomplete.', parsedUrl.pathname, true); + return; + } + const pathParams = getPathParams(parsedUrl); + if (!pathParams) { + sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid path parameters', parsedUrl.pathname); + return; + } + const session = runtime.sessions.get(pathParams.sessionId); + if (!session) { + sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", parsedUrl.pathname); + return; + } + try { + if (!pathParams.groupId) { + if (req.method === 'GET') { + sendResponse(req, res, session.groups.list(), 200); + return; + } + if (req.method === 'POST') { + const payload = getGroupCreatePayload(req.body); + if (!payload) { + sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid group creation payload. Required: { id: string, guildIds?: string[] }', parsedUrl.pathname); + return; + } + session.groups.create(payload.id, payload.guildIds); + sendResponse(req, res, session.groups.toJSON(payload.id), 201); + return; + } + sendErrorResponse(req, res, 405, 'Method Not Allowed', 'Method Not Allowed', parsedUrl.pathname); + return; + } + if (req.method === 'GET') { + const group = session.groups.toJSON(pathParams.groupId); + if (!group) { + sendErrorResponse(req, res, 404, 'Not Found', `Group '${pathParams.groupId}' not found.`, parsedUrl.pathname); + return; + } + sendResponse(req, res, group, 200); + return; + } + if (req.method === 'DELETE') { + if (!session.groups.has(pathParams.groupId)) { + sendErrorResponse(req, res, 404, 'Not Found', `Group '${pathParams.groupId}' not found.`, parsedUrl.pathname); + return; + } + session.groups.delete(pathParams.groupId); + sendResponse(req, res, null, 204); + return; + } + if (req.method === 'PATCH') { + if (!session.groups.has(pathParams.groupId)) { + sendErrorResponse(req, res, 404, 'Not Found', `Group '${pathParams.groupId}' not found.`, parsedUrl.pathname); + return; + } + const payload = getGroupPatchPayload(req.body); + if (!payload) { + sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid group patch payload.', parsedUrl.pathname); + return; + } + const result = await applyGroupPatch(runtime, session, pathParams.groupId, payload); + sendResponse(req, res, result, 200); + return; + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unhandled group error'; + if (errorMessage.includes('not found') || + errorMessage.includes('already exists')) { + const status = errorMessage.includes('already exists') ? 409 : 404; + sendErrorResponse(req, res, status, status === 409 ? 'Conflict' : 'Not Found', errorMessage, parsedUrl.pathname); + return; + } + logger('error', 'GroupUpdate', `Unhandled error: ${errorMessage}`, error); + sendErrorResponse(req, res, 500, 'Internal Server Error', errorMessage, parsedUrl.pathname, true); + return; + } + sendErrorResponse(req, res, 405, 'Method Not Allowed', 'Method Not Allowed', parsedUrl.pathname); +} +/** + * Route module definition for the groups route. + */ +const groupsRoute = { + handler, + methods: ['GET', 'POST', 'PATCH', 'DELETE'] +}; +export default groupsRoute; diff --git a/dist/src/api/sessions.id.js b/dist/src/api/sessions.id.js index abe5f65c..a42c9a63 100644 --- a/dist/src/api/sessions.id.js +++ b/dist/src/api/sessions.id.js @@ -1,4 +1,4 @@ -import { logger, sendErrorResponse } from "../utils.js"; +import { logger, sendErrorResponse } from '../utils.js'; /** * Builds a strongly typed runtime view for the session patch endpoint. * diff --git a/dist/src/api/sessions.id.players.id.lyrics.subscribe.js b/dist/src/api/sessions.id.players.id.lyrics.subscribe.js index af1b4fd7..834973e1 100644 --- a/dist/src/api/sessions.id.players.id.lyrics.subscribe.js +++ b/dist/src/api/sessions.id.players.id.lyrics.subscribe.js @@ -1,4 +1,4 @@ -import { logger, sendErrorResponse } from "../utils.js"; +import { logger, sendErrorResponse } from '../utils.js'; /** * Builds a strongly typed runtime view for the lyrics subscription route. * diff --git a/dist/src/api/sessions.id.players.id.mix.id.js b/dist/src/api/sessions.id.players.id.mix.id.js index 6706cc5e..81602c1e 100644 --- a/dist/src/api/sessions.id.players.id.mix.id.js +++ b/dist/src/api/sessions.id.players.id.mix.id.js @@ -1,4 +1,4 @@ -import { logger, sendErrorResponse } from "../utils.js"; +import { logger, sendErrorResponse } from '../utils.js'; /** * Builds a strongly typed runtime view for the mix item route. * diff --git a/dist/src/api/sessions.id.players.id.mix.js b/dist/src/api/sessions.id.players.id.mix.js index ba679a77..727f5ecd 100644 --- a/dist/src/api/sessions.id.players.id.mix.js +++ b/dist/src/api/sessions.id.players.id.mix.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Builds a strongly typed runtime view for the mix collection route. * @@ -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..daf18591 100644 --- a/dist/src/api/sessions.id.players.id.sponsorblock.js +++ b/dist/src/api/sessions.id.players.id.sponsorblock.js @@ -1,4 +1,4 @@ -import { sendErrorResponse } from "../utils.js"; +import { sendErrorResponse } from '../utils.js'; /** * Builds a strongly typed runtime view for the SponsorBlock route. * @@ -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/sessions.id.players.js b/dist/src/api/sessions.id.players.js index c65c575c..50eb09af 100644 --- a/dist/src/api/sessions.id.players.js +++ b/dist/src/api/sessions.id.players.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Validates that a value is a non-array object. * @@ -206,6 +206,10 @@ function getPlayerPatchPayload(body) { typeof loudnessNormalizer !== 'boolean') { return null; } + const ducking = payload.ducking; + if (ducking !== undefined && typeof ducking !== 'boolean') { + return null; + } const filtersValue = payload.filters; if (filtersValue !== undefined && (!filtersValue || @@ -243,6 +247,7 @@ function getPlayerPatchPayload(body) { volume, paused, loudnessNormalizer, + ducking, filters: filtersValue, fading, voice: voice ?? undefined @@ -296,6 +301,19 @@ function sanitizeFadingConfig(raw) { updateSection('seek'); updateSection('pause'); updateSection('resume'); + if (isObjectRecord(payload.ducking)) { + const duckingInput = payload.ducking; + safe.ducking = { + enabled: duckingInput.enabled === true, + duration: typeof duckingInput.duration === 'number' && Number.isFinite(duckingInput.duration) + ? Math.max(0, duckingInput.duration) + : 500, + targetVolume: typeof duckingInput.targetVolume === 'number' && Number.isFinite(duckingInput.targetVolume) + ? Math.max(0, Math.min(1, duckingInput.targetVolume)) + : 0.3, + curve: typeof duckingInput.curve === 'string' ? duckingInput.curve : 'linear' + }; + } return safe; } /** @@ -500,6 +518,9 @@ async function applyPlayerPatch(runtime, session, guildId, payload, query) { if (payload.loudnessNormalizer !== undefined) { await session.players.setLoudnessNormalizer(guildId, payload.loudnessNormalizer); } + if (payload.ducking !== undefined) { + await session.players.setDucking(guildId, payload.ducking); + } return await session.players.toJSON(guildId); } /** diff --git a/dist/src/api/stats.js b/dist/src/api/stats.js index abb9de29..b6331f29 100644 --- a/dist/src/api/stats.js +++ b/dist/src/api/stats.js @@ -1,4 +1,4 @@ -import { getStats } from "../utils.js"; +import { getStats } from '../utils.js'; /** * Builds a strongly typed runtime view for the stats endpoint. * diff --git a/dist/src/api/trackstream.js b/dist/src/api/trackstream.js index b408bc08..169aa8da 100644 --- a/dist/src/api/trackstream.js +++ b/dist/src/api/trackstream.js @@ -1,4 +1,4 @@ -import { decodeTrack, logger, sendErrorResponse } from "../utils.js"; +import { decodeTrack, logger, sendErrorResponse } from '../utils.js'; /** * Default validation message used when the `encodedTrack` query parameter is * missing or empty. @@ -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/api/workers.js b/dist/src/api/workers.js index d8d86c00..1c42917e 100644 --- a/dist/src/api/workers.js +++ b/dist/src/api/workers.js @@ -1,4 +1,4 @@ -import { sendErrorResponse, sendResponse } from "../utils.js"; +import { sendErrorResponse, sendResponse } from '../utils.js'; /** * Loopback addresses allowed to access the workers patch endpoint when * external patching is disabled. diff --git a/dist/src/api/youtube.config.js b/dist/src/api/youtube.config.js index 4548a1cd..a82d68fb 100644 --- a/dist/src/api/youtube.config.js +++ b/dist/src/api/youtube.config.js @@ -1,5 +1,5 @@ -import OAuth from "../sources/youtube/OAuth.js"; -import { logger, sendErrorResponse } from "../utils.js"; +import OAuth from '../sources/youtube/OAuth.js'; +import { logger, sendErrorResponse } from '../utils.js'; /** * Returns whether the provided body value is a plain object record. * diff --git a/dist/src/api/youtube.oauth.js b/dist/src/api/youtube.oauth.js index cee57a6b..c7aab864 100644 --- a/dist/src/api/youtube.oauth.js +++ b/dist/src/api/youtube.oauth.js @@ -1,4 +1,4 @@ -import { logger, makeRequest, sendErrorResponse } from "../utils.js"; +import { logger, makeRequest, sendErrorResponse } from '../utils.js'; /** * Google OAuth client identifier used by the YouTube refresh flow. * diff --git a/dist/src/constants.js b/dist/src/constants.js index c113dc20..0d18285a 100644 --- a/dist/src/constants.js +++ b/dist/src/constants.js @@ -305,6 +305,8 @@ export const SupportedFormats = { WAV: 'wav', /** Flash Video container format */ FLV: 'flv', + /** Apple Lossless Audio Codec */ + ALAC: 'alac', /** Unknown or unsupported audio format */ UNKNOWN: 'unknown' }; @@ -369,6 +371,8 @@ export function normalizeFormat(type) { if (!type) return SupportedFormats.UNKNOWN; const lowerType = type.toLowerCase(); + if (lowerType.includes('alac')) + return SupportedFormats.ALAC; if (lowerType.includes('opus') || lowerType.includes('webm') || lowerType.includes('weba')) diff --git a/dist/src/index.js b/dist/src/index.js index 720b2751..0558f543 100644 --- a/dist/src/index.js +++ b/dist/src/index.js @@ -12,21 +12,25 @@ import http from 'node:http'; import { resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; 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 RoutePlannerManager from './managers/routePlannerManager.js'; +import SessionManager from './managers/sessionManager.js'; +import StatsManager from './managers/statsManager.js'; +import { migrateConfig, persistConfig } from './modules/config/configMigration.js'; +import { 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"; -import DosProtectionManager from "./managers/dosProtectionManager.js"; -import PluginManager from "./managers/pluginManager.js"; -import RateLimitManager from "./managers/rateLimitManager.js"; -import { parseVoiceFrameHeader } from "./voice/voiceFrames.js"; -import { createVoiceRelay } from "./voice/voiceRelay.js"; +import { GatewayEvents, MINIMUM_NODE_VERSION } from './constants.js'; +import ConfigValidationManager from './managers/configValidationManager.js'; +import DosProtectionManager from './managers/dosProtectionManager.js'; +import PluginManager from './managers/pluginManager.js'; +import RateLimitManager from './managers/rateLimitManager.js'; +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'; @@ -85,13 +89,13 @@ const getLatestNodeLtsVersion = async (credentialManager) => { }; const getRequestHandler = async () => { if (!requestHandlerPromise) { - requestHandlerPromise = import("./api/index.js").then((module) => module.default); + requestHandlerPromise = import('./api/index.js').then((module) => module.default); } return requestHandlerPromise; }; const getProfilerApi = async () => { if (!profilerApiPromise) { - profilerApiPromise = import("./api/profiler.js"); + profilerApiPromise = import('./api/profiler.js'); } return profilerApiPromise; }; @@ -119,61 +123,126 @@ const memoryTrace = (stage) => { let playerManagerClassPromise = null; const getPlayerManagerClass = async () => { if (!playerManagerClassPromise) { - playerManagerClassPromise = import("./managers/playerManager.js").then((module) => module.default); + playerManagerClassPromise = import('./managers/playerManager.js').then((module) => module.default); } return playerManagerClassPromise; }; let workerManagerClassPromise = null; const getWorkerManagerClass = async () => { if (!workerManagerClassPromise) { - workerManagerClassPromise = import("./managers/workerManager.js").then((module) => module.default); + workerManagerClassPromise = import('./managers/workerManager.js').then((module) => module.default); } return workerManagerClassPromise; }; let sourceWorkerManagerClassPromise = null; const getSourceWorkerManagerClass = async () => { if (!sourceWorkerManagerClassPromise) { - sourceWorkerManagerClassPromise = import("./managers/sourceWorkerManager.js").then((module) => module.default); + sourceWorkerManagerClassPromise = import('./managers/sourceWorkerManager.js').then((module) => module.default); } return sourceWorkerManagerClassPromise; }; let credentialManagerClassPromise = null; const getCredentialManagerClass = async () => { if (!credentialManagerClassPromise) { - credentialManagerClassPromise = import("./managers/credentialManager.js").then((module) => module.default); + credentialManagerClassPromise = import('./managers/credentialManager.js').then((module) => module.default); } return credentialManagerClassPromise; }; let trackCacheManagerClassPromise = null; const getTrackCacheManagerClass = async () => { if (!trackCacheManagerClassPromise) { - trackCacheManagerClassPromise = import("./managers/trackCacheManager.js").then((module) => module.default); + trackCacheManagerClassPromise = import('./managers/trackCacheManager.js').then((module) => module.default); } 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 = @@ -191,6 +260,21 @@ else if (typeof config.cluster?.workers === 'number') initLogger(config); const isBun = typeof Bun !== 'undefined'; if (!cluster.isWorker) { + const supportGuidelines = `\x1b[35m +╭──────────────────────────────────────────────────────────────────────────────╮ +│ SUPPORT GUIDELINES │ +│ │ +│ 1. Turn on debugging in config.ts before asking for help. │ +│ 2. Test the 'dev' branch (git checkout dev) to ensure it's not fixed yet. │ +│ 3. If providing logs, start copying EXACTLY from this box onwards. │ +│ 4. Cut logs or missing Node.js version will result in an ignored ticket. │ +│ 5. FAQ and Rules: https://discord.gg/bVz6ppZ3SP │ +│ https://discord.gg/z4ayqfeBdB │ +╰──────────────────────────────────────────────────────────────────────────────╯\x1b[0m + + * +`; + process.stdout.write(supportGuidelines); const ascii = ` ▄ ████▄ ██▄ ▄███▄ █ ▄█ ▄ █ █▀ █ █ █ █ █ █▀ ▀ █ ██ █ █▄█ @@ -198,107 +282,23 @@ 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(); -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); + try { + await checkForUpdates(); } - /** - * Terminates the connection immediately - * @public - */ - terminate() { - this.ws.close(1000, 'Terminated'); + catch (e) { + logger('error', 'Git', `Update check failed: ${e.message}`); } - /** - * Internal handler for received messages - * @param message - Message data - * @internal - */ - _handleMessage(message) { - this.emit('message', message); + try { + await checkDependencyUpdates(); } - /** - * Internal handler for connection close events - * @param code - Close code - * @param reason - Close reason - * @internal - */ - _handleClose(code, reason) { - this.emit('close', code, reason); + catch (e) { + logger('error', 'Git', `Dependency check failed: ${e.message}`); } } +memoryTrace('bootstrap:after-check-for-updates'); /** * Main NodeLink server class * Handles WebSocket connections, audio sources, and player management @@ -314,6 +314,7 @@ class NodelinkServer extends EventEmitter { lyrics; meanings; _sourceInitPromise; + proxyManager; routePlanner; credentialManager; trackCacheManager; @@ -360,6 +361,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 +397,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 }); @@ -426,9 +428,9 @@ class NodelinkServer extends EventEmitter { async _initSources(isClusterPrimary, _options) { if (!isClusterPrimary) { const [{ default: sourceMan }, { default: lyricsMan }, { default: meaningMan }] = await Promise.all([ - import("./managers/sourceManager.js"), - import("./managers/lyricsManager.js"), - import("./managers/meaningManager.js") + import('./managers/sourceManager.js'), + import('./managers/lyricsManager.js'), + import('./managers/meaningManager.js') ]); this.sources = new sourceMan(this); this.lyrics = new lyricsMan(this); @@ -442,7 +444,7 @@ class NodelinkServer extends EventEmitter { await this._connectionManagerInitPromise; return; } - this._connectionManagerInitPromise = import("./managers/connectionManager.js") + this._connectionManagerInitPromise = import('./managers/connectionManager.js') .then(({ default: ConnectionManagerClass }) => { if (!this.connectionManager) { this.connectionManager = new ConnectionManagerClass(this); @@ -478,12 +480,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 +645,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 +877,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 +1119,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 +1155,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 +1191,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 +1288,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 +1465,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 +1512,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,8 +1636,8 @@ process.on('unhandledRejection', (reason, promise) => { }); if (clusterEnabled && cluster.isPrimary) { if (config.sources?.youtube?.getOAuthToken) { - // dynamicly import OAuth (if enabled) - const OAuth = (await import("./sources/youtube/OAuth.js").catch((e) => { + // 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); })).default; @@ -1944,6 +1683,8 @@ if (clusterEnabled && cluster.isPrimary) { process.stdout.write('\n \x1b[32m💚 Thank you for using NodeLink!\x1b[0m\n'); process.stdout.write(' \x1b[37mIf you have ideas, suggestions or want to report bugs, join us on Discord:\x1b[0m\n'); process.stdout.write(' \x1b[1m\x1b[34m➜\x1b[0m \x1b[36mhttps://discord.gg/fzjksWS65v\x1b[0m\n\n'); + process.stdout.write(' \x1b[1m\x1b[35m➜\x1b[0m \x1b[35mHaving issues? Use "git checkout dev" to see if it\'s fixed.\x1b[0m\n'); + process.stdout.write(' \x1b[1m\x1b[35m➜\x1b[0m \x1b[35mRead the FAQ & follow the support template in our Discord.\x1b[0m\n\n'); logger('info', 'Server', 'Shutdown signal received. Cleaning up resources...'); nserver._stopHeartbeat(); await nserver.credentialManager?.forceSave(); @@ -1971,7 +1712,7 @@ if (clusterEnabled && cluster.isPrimary) { }); } else if (clusterEnabled && cluster.isWorker) { - await import("./workers/main.js"); + await import('./workers/main.js'); } else { const serverInstancePromise = (async () => { @@ -1991,6 +1732,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)); @@ -2003,6 +1750,8 @@ else { process.stdout.write('\n \x1b[32m💚 Thank you for using NodeLink!\x1b[0m\n'); process.stdout.write(' \x1b[37mIf you have ideas, suggestions or want to report bugs, join us on Discord:\x1b[0m\n'); process.stdout.write(' \x1b[1m\x1b[34m➜\x1b[0m \x1b[36mhttps://discord.gg/fzjksWS65v\x1b[0m\n\n'); + process.stdout.write(' \x1b[1m\x1b[35m➜\x1b[0m \x1b[35mHaving issues? Use "git checkout dev" to see if it\'s fixed.\x1b[0m\n'); + process.stdout.write(' \x1b[1m\x1b[35m➜\x1b[0m \x1b[35mRead the FAQ & follow the support template in our Discord.\x1b[0m\n\n'); process.exit(0); }; process.once('SIGINT', shutdown); diff --git a/dist/src/lyrics/bilibili.js b/dist/src/lyrics/bilibili.js index 9acce379..0e314632 100644 --- a/dist/src/lyrics/bilibili.js +++ b/dist/src/lyrics/bilibili.js @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { logger, makeRequest } from "../utils.js"; +import { logger, makeRequest } from '../utils.js'; /** * WBI mixin index table used for signing. * @internal diff --git a/dist/src/lyrics/deezer.js b/dist/src/lyrics/deezer.js index 53b47ea8..d5f25066 100644 --- a/dist/src/lyrics/deezer.js +++ b/dist/src/lyrics/deezer.js @@ -1,4 +1,4 @@ -import { getBestMatch, logger, makeRequest } from "../utils.js"; +import { getBestMatch, logger, makeRequest } from '../utils.js'; /** * Deezer lyrics provider utilizing the Deezer GraphQL internal API. * Supports word-by-word and line-level synchronization. diff --git a/dist/src/lyrics/genius.js b/dist/src/lyrics/genius.js index 168beb04..9aa39eab 100644 --- a/dist/src/lyrics/genius.js +++ b/dist/src/lyrics/genius.js @@ -1,4 +1,4 @@ -import { logger, makeRequest } from "../utils.js"; +import { logger, makeRequest } from '../utils.js'; const CLEAN_PATTERNS = [ /\s*\([^)]*(?:official|lyrics?|video|audio|mv|visualizer|color\s*coded|hd|4k|prod\.)[^)]*\)/gi, /\s*\[[^\]]*(?:official|lyrics?|video|audio|mv|visualizer|color\s*coded|hd|4k|prod\.)[^\]]*\]/gi, diff --git a/dist/src/lyrics/letrasmus.js b/dist/src/lyrics/letrasmus.js index 104e8368..f5da9734 100644 --- a/dist/src/lyrics/letrasmus.js +++ b/dist/src/lyrics/letrasmus.js @@ -1,4 +1,4 @@ -import { http1makeRequest, logger } from "../utils.js"; +import { http1makeRequest, logger } from '../utils.js'; /** * Letras suggest endpoint for track discovery. * @internal diff --git a/dist/src/lyrics/lrclib.js b/dist/src/lyrics/lrclib.js index f2d06c64..5262fd0b 100644 --- a/dist/src/lyrics/lrclib.js +++ b/dist/src/lyrics/lrclib.js @@ -1,4 +1,4 @@ -import { logger, makeRequest } from "../utils.js"; +import { logger, makeRequest } from '../utils.js'; const CLEAN_PATTERNS = [ /\s*\([^)]*(?:official|lyrics?|video|audio|mv|visualizer|color\s*coded|hd|4k|prod\.)[^)]*\)/gi, /\s*\[[^\]]*(?:official|lyrics?|video|audio|mv|visualizer|color\s*coded|hd|4k|prod\.)[^\]]*\]/gi, diff --git a/dist/src/lyrics/monochrome.js b/dist/src/lyrics/monochrome.js index 34c5de90..6ed0c29a 100644 --- a/dist/src/lyrics/monochrome.js +++ b/dist/src/lyrics/monochrome.js @@ -1,4 +1,4 @@ -import { logger, makeRequest } from "../utils.js"; +import { logger, makeRequest } from '../utils.js'; /** * Monochrome lyrics provider. * @@ -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/lyrics/musixmatch.js b/dist/src/lyrics/musixmatch.js index 0bf4aeec..72edd46e 100644 --- a/dist/src/lyrics/musixmatch.js +++ b/dist/src/lyrics/musixmatch.js @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import { readFile, unlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { http1makeRequest, logger } from "../utils.js"; +import { http1makeRequest, logger } from '../utils.js'; class HttpError extends Error { status; constructor(status, message) { diff --git a/dist/src/lyrics/yandexmusic.js b/dist/src/lyrics/yandexmusic.js index f1383eac..02903be5 100644 --- a/dist/src/lyrics/yandexmusic.js +++ b/dist/src/lyrics/yandexmusic.js @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { http1makeRequest, logger } from "../utils.js"; +import { http1makeRequest, logger } from '../utils.js'; /** * Base URL for Yandex Music API. * @internal diff --git a/dist/src/lyrics/youtube.js b/dist/src/lyrics/youtube.js index c8ec4ff7..c5343b62 100644 --- a/dist/src/lyrics/youtube.js +++ b/dist/src/lyrics/youtube.js @@ -1,4 +1,4 @@ -import { logger, makeRequest } from "../utils.js"; +import { logger, makeRequest } from '../utils.js'; /** * Decodes common HTML entities found in YouTube caption segments. * @param text - Raw caption text. diff --git a/dist/src/managers/baseCacheManager.js b/dist/src/managers/baseCacheManager.js new file mode 100644 index 00000000..1b6bc21c --- /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..7639c692 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..f7e894f6 100644 --- a/dist/src/managers/connectionManager.js +++ b/dist/src/managers/connectionManager.js @@ -3,8 +3,8 @@ import dns from 'node:dns'; import os from 'node:os'; import { performance } from 'node:perf_hooks'; import { promisify } from 'node:util'; -import { GatewayEvents } from "../constants.js"; -import { http1makeRequest, logger } from "../utils.js"; +import { GatewayEvents } from '../constants.js'; +import { http1makeRequest, logger } from '../utils.js'; const execAsync = promisify(exec); const dnsLookup = promisify(dns.lookup); const DEFAULT_TEST_ENDPOINTS = [ @@ -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() }; @@ -63,15 +64,15 @@ export default class ConnectionManager { this._lastSpeedTestTime = 0; this._lastPingMs = undefined; } - /** - * Starts the periodic connection monitor. - */ start() { const checkInterval = Math.max(1, this.config.interval || 300000); if (checkInterval > 0) { logger('info', 'ConnectionManager', `Starting connection checks every ${checkInterval}ms.`); - this.checkConnection(); - this.interval = setInterval(() => this.checkConnection(), checkInterval); + // Delay the first check so it doesn't feel like part of the startup latency + setTimeout(() => { + this.checkConnection().catch(() => { }); + }, 5000); + this.interval = setInterval(() => this.checkConnection().catch(() => { }), checkInterval); } } /** @@ -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..4e97be77 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..e7ca32e2 100644 --- a/dist/src/managers/dosProtectionManager.js +++ b/dist/src/managers/dosProtectionManager.js @@ -1,4 +1,4 @@ -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; const DEFAULT_CONFIG = { enabled: true, thresholds: { @@ -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/groupManager.js b/dist/src/managers/groupManager.js new file mode 100644 index 00000000..bad288de --- /dev/null +++ b/dist/src/managers/groupManager.js @@ -0,0 +1,152 @@ +import { logger } from '../utils.js'; +/** + * Manages groups of players within a session for multi-guild synchronization. + * + * @remarks + * A group is a pure orchestration layer — it does not share streams, decoders, + * or buffers between players. Each player in a group keeps its own independent + * audio pipeline (decoder, FlowController, OpusEncoder, voice connection). + * + * The group provides a single API surface to apply the same operations + * (play, pause, seek, volume, filters, etc.) to all member players at once, + * while still allowing individual player control. + * + * This is designed for radio bots that need to keep N guilds synchronized + * on the same track and position. + */ +export default class GroupManager { + /** + * Map of group ID to PlayerGroup. + */ + groups = new Map(); + /** + * Creates a new player group. + * + * @param id - Unique group identifier. + * @param guildIds - Initial set of guild IDs to include. + * @returns The created group. + * @throws Error if a group with the same ID already exists. + */ + create(id, guildIds = []) { + if (this.groups.has(id)) { + throw new Error(`Group '${id}' already exists`); + } + const group = { + id, + guildIds: new Set(guildIds), + createdAt: Date.now() + }; + this.groups.set(id, group); + logger('debug', 'GroupManager', `Group '${id}' created with ${guildIds.length} player(s)`); + return group; + } + /** + * Retrieves a group by ID. + * + * @param id - Group identifier. + * @returns The group, or `undefined` if not found. + */ + get(id) { + return this.groups.get(id); + } + /** + * Checks whether a group exists. + * + * @param id - Group identifier. + * @returns `true` when the group exists. + */ + has(id) { + return this.groups.has(id); + } + /** + * Deletes a group. Does not destroy the member players. + * + * @param id - Group identifier. + * @returns `true` if the group existed and was deleted. + */ + delete(id) { + const existed = this.groups.delete(id); + if (existed) { + logger('debug', 'GroupManager', `Group '${id}' deleted`); + } + return existed; + } + /** + * Adds a guild to a group. + * + * @param groupId - Target group identifier. + * @param guildId - Guild to add. + * @throws Error if the group does not exist. + */ + addPlayer(groupId, guildId) { + const group = this.groups.get(groupId); + if (!group) + throw new Error(`Group '${groupId}' not found`); + group.guildIds.add(guildId); + logger('debug', 'GroupManager', `Added guild ${guildId} to group '${groupId}' (${group.guildIds.size} member(s))`); + } + /** + * Removes a guild from a group. + * + * @param groupId - Target group identifier. + * @param guildId - Guild to remove. + * @throws Error if the group does not exist. + */ + removePlayer(groupId, guildId) { + const group = this.groups.get(groupId); + if (!group) + throw new Error(`Group '${groupId}' not found`); + group.guildIds.delete(guildId); + logger('debug', 'GroupManager', `Removed guild ${guildId} from group '${groupId}' (${group.guildIds.size} member(s))`); + } + /** + * Returns the guild IDs for a group. + * + * @param groupId - Target group identifier. + * @returns Array of guild IDs, or empty array if group not found. + */ + getGuildIds(groupId) { + const group = this.groups.get(groupId); + return group ? Array.from(group.guildIds) : []; + } + /** + * Lists all groups as JSON. + * + * @returns Array of serialized group states. + */ + list() { + return Array.from(this.groups.values()).map((g) => this._toJSON(g)); + } + /** + * Serializes a specific group to JSON. + * + * @param groupId - Target group identifier. + * @returns Serialized group state, or `null` if not found. + */ + toJSON(groupId) { + const group = this.groups.get(groupId); + return group ? this._toJSON(group) : null; + } + /** + * Destroys all groups. Called during session cleanup. + */ + destroy() { + this.groups.clear(); + } + /** + * Returns the number of groups. + */ + get size() { + return this.groups.size; + } + /** + * Internal JSON serialization helper. + */ + _toJSON(group) { + return { + id: group.id, + guildIds: Array.from(group.guildIds), + createdAt: group.createdAt + }; + } +} diff --git a/dist/src/managers/lyricsManager.js b/dist/src/managers/lyricsManager.js index 7ec0e435..bb0a1648 100644 --- a/dist/src/managers/lyricsManager.js +++ b/dist/src/managers/lyricsManager.js @@ -9,8 +9,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { alignLyrics } from "../modules/lyricsAligner.js"; -import { logger } from "../utils.js"; +import { alignLyrics } from '../modules/lyricsAligner.js'; +import { logger } from '../utils.js'; const getErrorMessage = (error) => error instanceof Error ? error.message : String(error); const isTrackInfoLike = (value) => typeof value === 'object' && value !== null; const getTrackInfoFromResolve = (result, fallback) => { @@ -135,8 +135,13 @@ export default class LyricsManager { } } } - for (const [name, source] of this.lyricsSources) { - if (name === sourceName) + const sources = new Set([ + ...(this.nodelink.options.lyrics?.preferredSources ?? []), + ...this.lyricsSources.keys() + ]); + for (const name of sources) { + const source = this.lyricsSources.get(name); + if (!source || name === sourceName) continue; logger('debug', 'Lyrics', `Trying lyrics source ${name} for ${trackInfo?.title || 'Unknown Title'}.`); const lyrics = await source.getLyrics(trackInfo, language); diff --git a/dist/src/managers/meaningManager.js b/dist/src/managers/meaningManager.js index 5dd4897c..95bb6aab 100644 --- a/dist/src/managers/meaningManager.js +++ b/dist/src/managers/meaningManager.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 { logger } from '../utils.js'; /** * Checks whether a meaning source is enabled in config. * @param meaningsConfig - Meanings configuration bucket. diff --git a/dist/src/managers/playerManager.js b/dist/src/managers/playerManager.js index 164aa113..9b5c83bc 100644 --- a/dist/src/managers/playerManager.js +++ b/dist/src/managers/playerManager.js @@ -1,4 +1,4 @@ -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; /** * Session-scoped manager that controls player lifecycle and player commands. * @@ -224,7 +224,7 @@ export default class PlayerManager { eventQueue: session.eventQueue, isPaused: session.isPaused }; - const { Player } = await import("../playback/player.js"); + const { Player } = await import('../playback/player.js'); logger('debug', 'PlayerManager', `Creating new player for guild ${guildId} (session: ${this.sessionId})`); const player = new Player({ nodelink: this.nodelink, @@ -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..3e2e979c 100644 --- a/dist/src/managers/pluginManager.js +++ b/dist/src/managers/pluginManager.js @@ -10,7 +10,7 @@ import fs from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; /** * CommonJS resolver used to resolve npm package entry points. * @internal @@ -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..f5218b8a --- /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..366af899 100644 --- a/dist/src/managers/rateLimitManager.js +++ b/dist/src/managers/rateLimitManager.js @@ -1,4 +1,4 @@ -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; const DEFAULT_PER_USER = { maxRequests: 50, timeWindowMs: 5000 @@ -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..315ce195 100644 --- a/dist/src/managers/routePlannerManager.js +++ b/dist/src/managers/routePlannerManager.js @@ -1,4 +1,27 @@ -import { logger } from "../utils.js"; +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/sessionManager.js b/dist/src/managers/sessionManager.js index 70105b51..ec287aec 100644 --- a/dist/src/managers/sessionManager.js +++ b/dist/src/managers/sessionManager.js @@ -1,4 +1,5 @@ -import { generateRandomLetters, logger } from "../utils.js"; +import { generateRandomLetters, logger } from '../utils.js'; +import GroupManager from './groupManager.js'; /** * Manages active and resumable WebSocket sessions for NodeLink. * Handles the full lifecycle of a session, including creation, pausing for resumption, @@ -57,6 +58,7 @@ export default class SessionManager { userId: request.headers['user-id'], socket, players, + groups: new GroupManager(), resuming: false, timeout: 60, isPaused: false, @@ -160,9 +162,17 @@ export default class SessionManager { } else { for (const player of players.players.values()) { - player.destroy?.(); + try { + player.destroy?.(); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger('error', 'SessionManager', `Failed to destroy player for guild ${player.guildId} during session destruction: ${message}`); + } } + players.players.clear(); } + session.groups.destroy(); session.socket?.destroy?.(); } /** diff --git a/dist/src/managers/sourceManager.js b/dist/src/managers/sourceManager.js index ac97e2f9..72f3d17e 100644 --- a/dist/src/managers/sourceManager.js +++ b/dist/src/managers/sourceManager.js @@ -6,16 +6,19 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte } return path; }; +import { AsyncLocalStorage } from 'node:async_hooks'; 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. * @public */ export default class SourcesManager { + /** Source instances active in the current track URL resolution chain. */ + resolvingSources = new AsyncLocalStorage(); /** The parent NodeLink instance context. */ nodelink; /** Map of primary source instances keyed by their unique identifier. */ @@ -54,12 +57,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 +102,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,10 +224,16 @@ 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]; + const activeSources = this.resolvingSources.getStore(); for (const source of defaultSources) { + const instance = activeSources && + (this.searchAliasMap.get(source) ?? this.sourceMap.get(source)); + if (instance && activeSources.has(instance)) + continue; try { const result = await this.search(source, query); if (result.loadType === 'search' && @@ -246,9 +255,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,15 +333,56 @@ 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) { const instance = this.sourceMap.get(track.sourceName); if (!instance?.getTrackUrl) { throw new Error(`Source ${track.sourceName} not found or does not support getTrackUrl`); } - return (await instance.getTrackUrl(track, itag, isRecovering)); + const sourceGetTrackUrl = instance.getTrackUrl; + const activeSources = this.resolvingSources.getStore(); + if (activeSources?.has(instance)) { + return { + exception: { + message: `Circular track URL resolution detected for source ${track.sourceName}.`, + severity: 'fault' + } + }; + } + const nextActiveSources = new Set(activeSources).add(instance); + return this.resolvingSources.run(nextActiveSources, async () => { + // 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) { + const hqSource = this.sources.get(source); + if (!hqSource || nextActiveSources.has(hqSource)) + 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}`); + } + } + } + return (await sourceGetTrackUrl.call(instance, track, itag, isRecovering)); + }); } /** * Retrieves an audio stream for a resolved track URL. diff --git a/dist/src/managers/sourceWorkerManager.js b/dist/src/managers/sourceWorkerManager.js index 08ecf1af..57712796 100644 --- a/dist/src/managers/sourceWorkerManager.js +++ b/dist/src/managers/sourceWorkerManager.js @@ -5,7 +5,7 @@ import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const hasSocketSend = (res) => typeof res.send === 'function'; @@ -50,7 +50,7 @@ const buildSourceWorkerExecArgv = (options = null) => { const runtime = options?.cluster?.runtime || {}; const env = process.env; for (const arg of Array.from(args)) { - if (arg.startsWith('--max-old-space-size=')) + if (arg.startsWith('--max-old-space-size=') || arg === '--expose-gc') args.delete(arg); } const maxOldSpaceMb = parsePositiveInt(env.NODELINK_SOURCE_WORKER_MAX_OLD_SPACE_MB ?? diff --git a/dist/src/managers/statsManager.js b/dist/src/managers/statsManager.js index 3efa7586..9f29f281 100644 --- a/dist/src/managers/statsManager.js +++ b/dist/src/managers/statsManager.js @@ -1,4 +1,4 @@ -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; const MAX_ENDPOINT_ENTRIES = 500; const MAX_ENDPOINT_LENGTH = 200; const safeNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0; @@ -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..0f5b847d 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..6afba18f 100644 --- a/dist/src/managers/workerManager.js +++ b/dist/src/managers/workerManager.js @@ -6,7 +6,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import v8 from 'node:v8'; -import { logger } from "../utils.js"; +import { logger } from '../utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const getGlobalNodelink = () => globalThis.nodelink; @@ -124,12 +124,9 @@ export default class WorkerManager { this.nextStatelessWorkerIndex = 0; this.pendingRequests = new Map(); this.streamRequests = new Map(); - const availableParallelism = typeof os.availableParallelism === 'function' - ? os.availableParallelism() - : os.cpus().length; this.maxWorkers = config.cluster.workers === 0 - ? availableParallelism + ? os.cpus().length : Math.max(1, config.cluster.workers || 0); this.minWorkers = Math.max(1, config.cluster?.minWorkers || 1); this.workerLoad = new Map(); @@ -167,7 +164,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`); @@ -339,22 +336,12 @@ export default class WorkerManager { let cost = playingCount * playingWeight + pausedCount * pausedWeight; if (stats.isHibernating) return cost; - const cpuLoad = stats.cpu?.nodelinkLoad ?? 0; - const lagP95 = stats.eventLoopLagP95 ?? stats.eventLoopLag ?? 0; - const frameDeficit = stats.frameStats?.deficit ?? 0; - const stuckRecoveries = stats.stuckRecoveries ?? 0; - if (cpuLoad > this.scalingConfig.cpuPenaltyLimit) { + if ((stats.cpu?.nodelinkLoad ?? 0) > this.scalingConfig.cpuPenaltyLimit) { cost += this.scalingConfig.maxPlayersPerWorker + 5; } - if (lagP95 > this.scalingConfig.lagPenaltyLimit) { + if ((stats.eventLoopLag ?? 0) > this.scalingConfig.lagPenaltyLimit) { cost += this.scalingConfig.maxPlayersPerWorker / 2; } - if (frameDeficit > playingCount * 10) { - cost += this.scalingConfig.maxPlayersPerWorker / 4; - } - if (stuckRecoveries > playingCount * 0.1 && playingCount > 0) { - cost += this.scalingConfig.maxPlayersPerWorker / 3; - } return cost; } _updateWorkerFailureHistory(workerId, code, signal) { @@ -375,16 +362,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 +468,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 +483,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 +635,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 +880,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); @@ -1053,28 +1069,6 @@ export default class WorkerManager { } } } - if (bestWorker) { - const ws = this.workerStats.get(bestWorker.id); - const localLoad = this.workerToGuilds.get(bestWorker.id)?.size ?? 0; - const lagP99 = ws?.eventLoopLagP99 ?? ws?.eventLoopLag ?? 0; - const cpuLoad = ws?.cpu?.nodelinkLoad ?? 0; - const stuckRecoveries = ws?.stuckRecoveries ?? 0; - const playingCount = localLoad; - const admissionDenied = lagP99 > this.scalingConfig.lagPenaltyLimit * 3 || - cpuLoad > 0.95 || - (stuckRecoveries > playingCount * 0.5 && playingCount > 5); - if (admissionDenied && this.workers.length < this.maxWorkers) { - logger('warn', 'Cluster', `Worker #${bestWorker.id} admission denied (lagP99=${lagP99.toFixed(1)}ms, cpu=${cpuLoad.toFixed(2)}, stuckRecoveries=${stuckRecoveries}). Forking new worker.`); - const newWorker = this.forkWorker(); - if (newWorker) { - this.assignGuildToWorker(playerKey, newWorker); - return newWorker; - } - } - else if (admissionDenied) { - logger('warn', 'Cluster', `Worker #${bestWorker.id} admission denied but at max workers. Assigning anyway.`); - } - } const threshold = this.scalingConfig.maxPlayersPerWorker; const hasConnectedWorker = !!bestWorker; if (hasConnectedWorker && @@ -1251,7 +1245,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/meanings/letrasmus.js b/dist/src/meanings/letrasmus.js index 52aaadd9..c4ce7663 100644 --- a/dist/src/meanings/letrasmus.js +++ b/dist/src/meanings/letrasmus.js @@ -1,5 +1,5 @@ -import { translateMany, translateText } from "../modules/googleTranslate.js"; -import { getBestMatch, http1makeRequest, logger } from "../utils.js"; +import { translateMany, translateText } from '../modules/googleTranslate.js'; +import { getBestMatch, http1makeRequest, logger } from '../utils.js'; /** * Letras suggest endpoint used to discover candidate tracks. * @internal diff --git a/dist/src/meanings/wikipedia.js b/dist/src/meanings/wikipedia.js index 6e6c767e..93563b1e 100644 --- a/dist/src/meanings/wikipedia.js +++ b/dist/src/meanings/wikipedia.js @@ -1,4 +1,4 @@ -import { logger, makeRequest } from "../utils.js"; +import { logger, makeRequest } from '../utils.js'; /** * Meaning provider backed by Wikipedia. * 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..539752fa --- /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/googleTranslate.js b/dist/src/modules/googleTranslate.js index e3067c05..9e24ef5c 100644 --- a/dist/src/modules/googleTranslate.js +++ b/dist/src/modules/googleTranslate.js @@ -1,4 +1,4 @@ -import { http1makeRequest } from "../utils.js"; +import { http1makeRequest } from '../utils.js'; /** * Fallback key used when no custom Google Translate key is configured. * @internal diff --git a/dist/src/modules/spotifyAuth.js b/dist/src/modules/spotifyAuth.js index bbe9fd20..a8242e8c 100644 --- a/dist/src/modules/spotifyAuth.js +++ b/dist/src/modules/spotifyAuth.js @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { http1makeRequest, logger } from "../utils.js"; +import { http1makeRequest, logger } from '../utils.js'; /** * Built-in encoded TOTP secrets used as primary/fallback bootstrap. * @internal @@ -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..aa22ce5e 100644 --- a/dist/src/playback/dash/DASHHandler.js +++ b/dist/src/playback/dash/DASHHandler.js @@ -1,5 +1,5 @@ import { Transform } from 'node:stream'; -import { logger, makeRequest } from "../../utils.js"; +import { logger, makeRequest } from '../../utils.js'; const PREFETCH_COUNT = 4; const MAX_BUFFERED = 16 * 1024; /** @@ -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/Flv.js b/dist/src/playback/demuxers/Flv.js index c81c7394..8e81a600 100644 --- a/dist/src/playback/demuxers/Flv.js +++ b/dist/src/playback/demuxers/Flv.js @@ -1,7 +1,7 @@ import { Transform } from 'node:stream'; -import { logger } from "../../utils.js"; -import { bufferPool } from "../structs/BufferPool.js"; -import { RingBuffer } from "../structs/RingBuffer.js"; +import { logger } from '../../utils.js'; +import { bufferPool } from '../structs/BufferPool.js'; +import { RingBuffer } from '../structs/RingBuffer.js'; const STATE_HEADER = 0; const STATE_TAG_HEADER = 1; const STATE_TAG_BODY = 2; diff --git a/dist/src/playback/demuxers/WebmOpus.js b/dist/src/playback/demuxers/WebmOpus.js index 92be8484..4eefb832 100644 --- a/dist/src/playback/demuxers/WebmOpus.js +++ b/dist/src/playback/demuxers/WebmOpus.js @@ -1,6 +1,6 @@ import { Transform } from 'node:stream'; -import { logger } from "../../utils.js"; -import { RingBuffer } from "../structs/RingBuffer.js"; +import { logger } from '../../utils.js'; +import { RingBuffer } from '../structs/RingBuffer.js'; const TOO_SHORT = Symbol('TOO_SHORT'); const INVALID_VINT = Symbol('INVALID_VINT'); const parsePositiveIntEnv = (key, fallback) => { @@ -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/AnimatableFilter.js b/dist/src/playback/filters/AnimatableFilter.js index 45736b8a..58b7e5c6 100644 --- a/dist/src/playback/filters/AnimatableFilter.js +++ b/dist/src/playback/filters/AnimatableFilter.js @@ -1,4 +1,4 @@ -import { BaseFilter } from "./BaseFilter.js"; +import { BaseFilter } from './BaseFilter.js'; const SUPPORTED_CURVES = new Set([ 'linear', 'exponential', diff --git a/dist/src/playback/filters/channelMix.js b/dist/src/playback/filters/channelMix.js index 36a6a6aa..464d9f49 100644 --- a/dist/src/playback/filters/channelMix.js +++ b/dist/src/playback/filters/channelMix.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; /** * Mixes audio channels based on configurable weights. diff --git a/dist/src/playback/filters/chorus.js b/dist/src/playback/filters/chorus.js index 029e43f0..57b74d88 100644 --- a/dist/src/playback/filters/chorus.js +++ b/dist/src/playback/filters/chorus.js @@ -1,8 +1,8 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import DelayLine from "./dsp/delay.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import DelayLine from './dsp/delay.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; const MAX_DELAY_MS = 50; const bufferSize = Math.ceil((SAMPLE_RATE * MAX_DELAY_MS) / 1000); @@ -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/compressor.js b/dist/src/playback/filters/compressor.js index 9bdc3082..361673a2 100644 --- a/dist/src/playback/filters/compressor.js +++ b/dist/src/playback/filters/compressor.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; // biome-ignore lint/style/useExponentiationOperator: const dbToGain = (db) => Math.pow(10, db / 20); diff --git a/dist/src/playback/filters/distortion.js b/dist/src/playback/filters/distortion.js index bd52bf92..332a2b77 100644 --- a/dist/src/playback/filters/distortion.js +++ b/dist/src/playback/filters/distortion.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; /** * Applies various distortion effects (sin, cos, tan, etc.). 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/dsp/lfo.js b/dist/src/playback/filters/dsp/lfo.js index 72772db4..f8b03a07 100644 --- a/dist/src/playback/filters/dsp/lfo.js +++ b/dist/src/playback/filters/dsp/lfo.js @@ -1,6 +1,6 @@ /** biome-ignore-all lint/complexity/useLiteralKeys: */ -import { SAMPLE_RATE } from "../../../constants.js"; -import { Waveforms } from "./waves.js"; +import { SAMPLE_RATE } from '../../../constants.js'; +import { Waveforms } from './waves.js'; /** * Low Frequency Oscillator for modulating audio parameters. * @public diff --git a/dist/src/playback/filters/echo.js b/dist/src/playback/filters/echo.js index f3544be2..7e952386 100644 --- a/dist/src/playback/filters/echo.js +++ b/dist/src/playback/filters/echo.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { Float64DelayLine } from "./dsp/float64Delay.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { Float64DelayLine } from './dsp/float64Delay.js'; const CHANNELS = 2; const MAX_DELAY_MS = 2000; const bufferSize = Math.ceil((SAMPLE_RATE * MAX_DELAY_MS) / 1000); @@ -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/equalizer.js b/dist/src/playback/filters/equalizer.js index e07e47fe..a76c8141 100644 --- a/dist/src/playback/filters/equalizer.js +++ b/dist/src/playback/filters/equalizer.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; const BANDS = [ 25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, diff --git a/dist/src/playback/filters/flanger.js b/dist/src/playback/filters/flanger.js index 68dab947..9d0310a6 100644 --- a/dist/src/playback/filters/flanger.js +++ b/dist/src/playback/filters/flanger.js @@ -1,8 +1,8 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import DelayLine from "./dsp/delay.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import DelayLine from './dsp/delay.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; const MAX_DELAY_MS = 10; const bufferSize = Math.ceil((SAMPLE_RATE * MAX_DELAY_MS) / 1000); @@ -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/highpass.js b/dist/src/playback/filters/highpass.js index 2a8027ae..93f4913b 100644 --- a/dist/src/playback/filters/highpass.js +++ b/dist/src/playback/filters/highpass.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; const BUTTERWORTH_Q = Math.SQRT1_2; const SUB_BLOCK_FRAMES = 64; diff --git a/dist/src/playback/filters/karaoke.js b/dist/src/playback/filters/karaoke.js index 07bd6ff2..c746a8d7 100644 --- a/dist/src/playback/filters/karaoke.js +++ b/dist/src/playback/filters/karaoke.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const MAX_OUTPUT_GAIN = 0.98; const SCALE_16 = 32768; const INV_16 = 1 / SCALE_16; @@ -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/lowpass.js b/dist/src/playback/filters/lowpass.js index 6a4e08cf..3429a258 100644 --- a/dist/src/playback/filters/lowpass.js +++ b/dist/src/playback/filters/lowpass.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; const BUTTERWORTH_Q = Math.SQRT1_2; const SUB_BLOCK_FRAMES = 64; diff --git a/dist/src/playback/filters/phaser.js b/dist/src/playback/filters/phaser.js index 25512b99..0462763f 100644 --- a/dist/src/playback/filters/phaser.js +++ b/dist/src/playback/filters/phaser.js @@ -1,8 +1,8 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import Allpass from "./dsp/allpass.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import Allpass from './dsp/allpass.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; const MAX_STAGES = 12; /** diff --git a/dist/src/playback/filters/phonograph.js b/dist/src/playback/filters/phonograph.js index 818acd4a..70c95200 100644 --- a/dist/src/playback/filters/phonograph.js +++ b/dist/src/playback/filters/phonograph.js @@ -1,7 +1,7 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import LFO from './dsp/lfo.js'; const MAX_DELAY_MS = 60; const MAX_DELAY_SAMPLES = Math.ceil((SAMPLE_RATE * MAX_DELAY_MS) / 1000); class XorShift32 { @@ -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..9124f03d 100644 --- a/dist/src/playback/filters/reverb.js +++ b/dist/src/playback/filters/reverb.js @@ -1,6 +1,6 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; const CHANNELS = 2; const COMB_DELAYS = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617]; const ALLPASS_DELAYS = [556, 441, 341, 225]; @@ -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/rotation.js b/dist/src/playback/filters/rotation.js index de975e2b..5a130e15 100644 --- a/dist/src/playback/filters/rotation.js +++ b/dist/src/playback/filters/rotation.js @@ -1,7 +1,7 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; /** * Rotates audio between left and right channels at a specific frequency. diff --git a/dist/src/playback/filters/spatial.js b/dist/src/playback/filters/spatial.js index 9f69ef60..04a0a64f 100644 --- a/dist/src/playback/filters/spatial.js +++ b/dist/src/playback/filters/spatial.js @@ -1,8 +1,8 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import DelayLine from "./dsp/delay.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import DelayLine from './dsp/delay.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; const MAX_DELAY_MS = 30; const bufferSize = Math.ceil((SAMPLE_RATE * MAX_DELAY_MS) / 1000); @@ -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/tesseract.js b/dist/src/playback/filters/tesseract.js new file mode 100644 index 00000000..0f4d013e --- /dev/null +++ b/dist/src/playback/filters/tesseract.js @@ -0,0 +1,85 @@ +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import LFO from './dsp/lfo.js'; +const CHANNELS = 2; +export default class Tesseract extends AnimatableFilter { + priority = 11; + lfoTheta; + lfoPhi; + lfoGamma; + rotationHz = 0; + alpha = 0; + delaySamples = Math.floor(SAMPLE_RATE * 0.015); + delayBufferL = new Int16Array(this.delaySamples); + delayBufferR = new Int16Array(this.delaySamples); + delayIndex = 0; + constructor() { + super(); + this.lfoTheta = new LFO('SINE'); + this.lfoPhi = new LFO('SINE'); + this.lfoGamma = new LFO('SINE'); + } + update(settings) { + const r = settings?.tesseract || {}; + const isDisabled = r._disabled === true; + this.rotationHz = r.rotationHz ?? 0; + if (this.rotationHz > 0.001) { + this.lfoTheta.update(this.rotationHz, 1); + this.lfoPhi.update(this.rotationHz * 1.33, 1); + this.lfoGamma.update(this.rotationHz * 0.77, 1); + } + const targetAlpha = isDisabled ? 0.0 : this.rotationHz > 0.001 ? 1.0 : 0.0; + super.applyAnimatedUpdate({ tesseract: { alpha: targetAlpha } }, 'tesseract', { alpha: 0.0 }); + } + onConfigChanged(config) { + this.alpha = config.alpha ?? 0; + } + isConfigActive(config) { + const a = config ? config.alpha : this.alpha; + return (a ?? 0) > 0.001; + } + process(chunk) { + super.processAnimation(SAMPLE_RATE, chunk.length, CHANNELS); + if (this.alpha <= 0.001) { + return chunk; + } + const alpha = this.alpha; + for (let i = 0; i < chunk.length; i += 4) { + const thetaVal = this.lfoTheta.getValue() * Math.PI; + const phiVal = this.lfoPhi.getValue() * Math.PI; + const gammaVal = this.lfoGamma.getValue() * Math.PI; + const sinTheta = Math.sin(thetaVal); + const cosTheta = Math.cos(thetaVal); + const sinPhi = Math.sin(phiVal); + const cosPhi = Math.cos(phiVal); + const sinGamma = Math.sin(gammaVal); + const cosGamma = Math.cos(gammaVal); + const L = chunk.readInt16LE(i); + const R = chunk.readInt16LE(i + 2); + const Y = this.delayBufferL[this.delayIndex] ?? 0; + const Z = this.delayBufferR[this.delayIndex] ?? 0; + this.delayBufferL[this.delayIndex] = L; + this.delayBufferR[this.delayIndex] = R; + this.delayIndex = (this.delayIndex + 1) % this.delaySamples; + const L_rot = L * cosTheta - Y * sinTheta; + const R_rot = R * cosPhi - Z * sinPhi; + const newLeft = L_rot * cosGamma - R_rot * sinGamma; + const newRight = L_rot * sinGamma + R_rot * cosGamma; + const finalLeft = L * (1 - alpha) + newLeft * alpha; + const finalRight = R * (1 - alpha) + newRight * alpha; + chunk.writeInt16LE(clamp16Bit(finalLeft), i); + chunk.writeInt16LE(clamp16Bit(finalRight), i + 2); + } + return chunk; + } + flush() { + this.lfoTheta.phase = 0; + this.lfoPhi.phase = 0; + this.lfoGamma.phase = 0; + this.delayBufferL.fill(0); + this.delayBufferR.fill(0); + this.delayIndex = 0; + return Buffer.alloc(0); + } +} diff --git a/dist/src/playback/filters/timescale.js b/dist/src/playback/filters/timescale.js index 091bffe8..42f5f6b1 100644 --- a/dist/src/playback/filters/timescale.js +++ b/dist/src/playback/filters/timescale.js @@ -1,8 +1,8 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import { resample } from "./timescale/resampler.js"; -import TimeStretch from "./timescale/timeStretch.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import { resample } from './timescale/resampler.js'; +import TimeStretch from './timescale/timeStretch.js'; const CHANNELS = 2; const BYTES_PER_SAMPLE = 2; const FRAME_SIZE = CHANNELS * BYTES_PER_SAMPLE; @@ -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..492965b7 100644 --- a/dist/src/playback/filters/timescale/timeStretch.js +++ b/dist/src/playback/filters/timescale/timeStretch.js @@ -1,4 +1,4 @@ -import FloatFifoBuffer from "./floatFifoBuffer.js"; +import FloatFifoBuffer from './floatFifoBuffer.js'; const DEFAULT_FRAME_SIZE = 1024; const DEFAULT_OVERLAP = 256; const DEFAULT_SEARCH = 128; @@ -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/tremolo.js b/dist/src/playback/filters/tremolo.js index 19485a6a..7faf0cab 100644 --- a/dist/src/playback/filters/tremolo.js +++ b/dist/src/playback/filters/tremolo.js @@ -1,7 +1,7 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; /** * Applies a tremolo effect (amplitude modulation) using an LFO. diff --git a/dist/src/playback/filters/vibrato.js b/dist/src/playback/filters/vibrato.js index 1a7c0712..4fab01a5 100644 --- a/dist/src/playback/filters/vibrato.js +++ b/dist/src/playback/filters/vibrato.js @@ -1,8 +1,8 @@ -import { SAMPLE_RATE } from "../../constants.js"; -import { AnimatableFilter } from "./AnimatableFilter.js"; -import { clamp16Bit } from "./dsp/clamp16Bit.js"; -import DelayLine from "./dsp/delay.js"; -import LFO from "./dsp/lfo.js"; +import { SAMPLE_RATE } from '../../constants.js'; +import { AnimatableFilter } from './AnimatableFilter.js'; +import { clamp16Bit } from './dsp/clamp16Bit.js'; +import DelayLine from './dsp/delay.js'; +import LFO from './dsp/lfo.js'; const CHANNELS = 2; const MAX_DELAY_MS = 20; const bufferSize = Math.ceil((SAMPLE_RATE * MAX_DELAY_MS) / 1000); @@ -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/AESDecryptor.js b/dist/src/playback/hls/AESDecryptor.js index 34dccbe3..c5876789 100644 --- a/dist/src/playback/hls/AESDecryptor.js +++ b/dist/src/playback/hls/AESDecryptor.js @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { logger } from "../../utils.js"; +import { logger } from '../../utils.js'; /** * Decrypts AES-encrypted HLS segment data. * Supports AES-128-CBC and AES-256-CBC. diff --git a/dist/src/playback/hls/HLSHandler.js b/dist/src/playback/hls/HLSHandler.js index a2ffbab9..eb51441b 100644 --- a/dist/src/playback/hls/HLSHandler.js +++ b/dist/src/playback/hls/HLSHandler.js @@ -1,7 +1,7 @@ import { PassThrough } from 'node:stream'; -import { http1makeRequest, logger } from "../../utils.js"; -import { parse as parsePlaylist } from "./PlaylistParser.js"; -import SegmentFetcher from "./SegmentFetcher.js"; +import { http1makeRequest, logger } from '../../utils.js'; +import { parse as parsePlaylist } from './PlaylistParser.js'; +import SegmentFetcher from './SegmentFetcher.js'; const MAX_HISTORY = 200; const MAX_GAP = 30; const MASTER_REFRESH_INTERVAL = 3; @@ -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/PlaylistParser.js b/dist/src/playback/hls/PlaylistParser.js index 9eb610c4..f67fbcf4 100644 --- a/dist/src/playback/hls/PlaylistParser.js +++ b/dist/src/playback/hls/PlaylistParser.js @@ -56,7 +56,13 @@ export function parse(content, baseUrl) { currentKey = parseAttributes(line, baseUrl); } else if (line.startsWith('#EXT-X-MAP:')) { - currentMap = parseAttributes(line, baseUrl); + const attrs = parseAttributes(line, baseUrl); + currentMap = { + uri: typeof attrs.uri === 'string' ? attrs.uri : undefined, + byteRange: typeof attrs.byterange === 'string' + ? parseByteRange(attrs.byterange, null) + : null + }; } else if (line.startsWith('#EXTINF:')) { const parts = line.split(':'); diff --git a/dist/src/playback/hls/SegmentFetcher.js b/dist/src/playback/hls/SegmentFetcher.js index eb20de65..0689d842 100644 --- a/dist/src/playback/hls/SegmentFetcher.js +++ b/dist/src/playback/hls/SegmentFetcher.js @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import { Transform } from 'node:stream'; -import { http1makeRequest, logger } from "../../utils.js"; +import { http1makeRequest, logger } from '../../utils.js'; /** * A Transform stream that decrypts HLS segments on the fly. * @@ -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(); } @@ -104,13 +104,20 @@ export default class SegmentFetcher { async fetchMap(mapInfo, keyInfo = null) { if (!mapInfo?.uri) return null; + const headers = { + ...this.headers + }; + if (mapInfo.byteRange) { + const end = mapInfo.byteRange.offset + mapInfo.byteRange.length - 1; + headers.Range = `bytes=${mapInfo.byteRange.offset}-${end}`; + } const { body, error, statusCode } = await http1makeRequest(mapInfo.uri, { - headers: this.headers, + headers, responseType: 'buffer', localAddress: this.localAddress ?? undefined, proxy: this.proxy ?? undefined }); - if (error || statusCode !== 200) { + if (error || (statusCode !== 200 && statusCode !== 206)) { throw new Error(`Map fetch failed: ${statusCode}`); } const buffer = body; diff --git a/dist/src/playback/opus/Opus.js b/dist/src/playback/opus/Opus.js index 5969403c..08cd1168 100644 --- a/dist/src/playback/opus/Opus.js +++ b/dist/src/playback/opus/Opus.js @@ -1,21 +1,18 @@ +import { Buffer } from 'node:buffer'; import { createRequire } from 'node:module'; import { Transform } from 'node:stream'; -import { bufferPool } from "../structs/BufferPool.js"; +import { bufferPool } from '../structs/BufferPool.js'; const require = createRequire(import.meta.url); +const OPUS_MAX_PACKET_SIZE = 4000; +const OPUS_MAX_PACKET_DURATION_MS = 120; +const PCM_SAMPLE_BYTES = 2; +// ^^ @toddynnn/voice-opus related, the encodeInto() functions does not check for MAX_PACKET_SIZE, unlike the encode(). const OPUS_CTL = { BITRATE: 4002, FEC: 4012, 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,86 +73,91 @@ export class Encoder extends Transform { lib; frameSize; frameBytes; - ring; - swap; - writePos; - readPos; + leftover; + leftoverStorage; + encodedScratch; constructor({ rate = 48000, channels = 2, frameSize = 960, application = 'audio' } = {}) { super({ readableObjectMode: true }); const { instance, lib } = _createInstance(rate, channels, application); this.enc = instance; 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.frameBytes = frameSize * channels * PCM_SAMPLE_BYTES; + this.leftover = null; + this.leftoverStorage = null; + this.encodedScratch = Buffer.allocUnsafe(OPUS_MAX_PACKET_SIZE); } _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); + if (this.leftoverStorage) + bufferPool.release(this.leftoverStorage); + this.leftover = null; + this.leftoverStorage = null; + buf = pooledBuf.subarray(0, totalLen); } - 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)); + let encoded; + if (this.enc.encodeInto && this.encodedScratch) { + const written = this.enc.encodeInto(frame, this.encodedScratch); + encoded = Buffer.from(this.encodedScratch.subarray(0, written)); } else { - this.push(this.enc.encode(frame)); + 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; + this.leftoverStorage = 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.leftoverStorage = bufferPool.acquire(remaining.length); + remaining.copy(this.leftoverStorage, 0, 0, remaining.length); + this.leftover = this.leftoverStorage.subarray(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) { + if (this.leftoverStorage) + bufferPool.release(this.leftoverStorage); + this.leftover = null; + this.leftoverStorage = null; + } cb(); } _destroy(err, cb) { @@ -163,13 +165,12 @@ 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; + this.encodedScratch = null; + if (this.leftover) { + if (this.leftoverStorage) + bufferPool.release(this.leftoverStorage); + this.leftover = null; + this.leftoverStorage = null; } cb(err); } @@ -196,17 +197,30 @@ export class Encoder extends Transform { export class Decoder extends Transform { dec; lib; + pcmScratch; constructor({ rate = 48000, channels = 2 } = {}) { super({ readableObjectMode: false }); const { instance, lib } = _createInstance(rate, channels, 'voip'); this.dec = instance; this.lib = lib; + const maxSamplesPerChannel = Math.ceil((rate * OPUS_MAX_PACKET_DURATION_MS) / 1000); + this.pcmScratch = Buffer.allocUnsafe(maxSamplesPerChannel * channels * PCM_SAMPLE_BYTES); } _transform(chunk, _encoding, cb) { try { if (!this.dec) throw new Error('Decoder not ready.'); - this.push(this.dec.decode(chunk)); + if (this.dec.decodeInto && this.pcmScratch) { + const written = this.dec.decodeInto(chunk, this.pcmScratch); + // Copy because stream consumers may retain the chunk after this call. + // pushing it to scratch would result: + // the scratch subarray would allow the next frame to overwrite queued audio, causing corruption. + // and its also required by ownership boundary btw. + this.push(Buffer.from(this.pcmScratch.subarray(0, written))); + } + else { + this.push(this.dec.decode(chunk)); + } cb(); } catch (e) { @@ -218,6 +232,7 @@ export class Decoder extends Transform { this.dec.delete(); } this.dec = null; + this.pcmScratch = null; cb(err); } } diff --git a/dist/src/playback/player.js b/dist/src/playback/player.js index 813c3860..df97e227 100644 --- a/dist/src/playback/player.js +++ b/dist/src/playback/player.js @@ -1,17 +1,17 @@ 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"; +import { EndReasons, GatewayEvents } from '../constants.js'; +import { logger } from '../utils.js'; +import { DuckingController } from './processing/DuckingController.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; - const processor = await import("./processing/streamProcessor.js"); + const processor = await import('./processing/streamProcessor.js'); createAudioResource = processor.createAudioResource; createSeekeableAudioResource = processor.createSeekeableAudioResource; @@ -38,6 +38,7 @@ export class Player { holoTrack = null; nextTrack = null; nextResource = null; + _currentResource = null; nextStreamInfo = null; isPaused = false; volumePercent; @@ -62,6 +63,7 @@ export class Player { audioMixer = null; fading; loudnessNormalizer; + duckingController = null; _fadeTimers = { trackEnd: null, pause: null, stop: null }; _isResuming = false; _pendingTrackStartFade = false; @@ -87,7 +89,13 @@ export class Player { _isStopping = false; _pausedAtPosition = undefined; stuckRecoveryCount = 0; + _positionAtRecoveryStart = 0; static MAX_STUCK_RECOVERY_ATTEMPTS = 3; + _connStateHandler = () => { }; + _connPlayHandler = () => { }; + _connErrorHandler = () => { }; + _connStuckHandler = () => { }; + _connSpeakStartHandler = () => { }; constructor(options) { if (!options.nodelink || !options.session?.socket || @@ -99,12 +107,17 @@ 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; + // Initialize ducking controller from config + const duckingCfg = this._resolveDuckingConfig(); + if (duckingCfg.enabled) { + this.duckingController = new DuckingController(this.guildId, duckingCfg); + } 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 +127,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 +157,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; @@ -173,8 +196,8 @@ export class Player { async _initAudioMixer() { if (this.audioMixer) return; - const { AudioMixer: Mixer } = await import("./processing/AudioMixer.js"); - this.audioMixer = new Mixer(this.nodelink.options?.mix ?? { + const { AudioMixer: Mixer } = await import('./processing/AudioMixer.js'); + this.audioMixer = new Mixer(this.nodelink.options?.playback.mix ?? { enabled: true, defaultVolume: 0.8, maxLayersMix: 5, @@ -216,45 +239,72 @@ export class Player { } await this._audioMixerInitPromise; } + _destroyAudioMixer() { + if (this.audioMixer) { + this.audioMixer.destroy(); + this.audioMixer = null; + } + this._audioMixerInitPromise = null; + } /** * Establishes the voice connection and attaches event listeners. */ _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.on('stateChange', (_, s) => { + this.connection.stuckTimeout = + Math.max(this.nodelink.options.playback.trackStuckThresholdMs ?? 10000, 30000) + 5000; + this._connStateHandler = (_, s) => { logger('debug', 'Player', `Voice connection state change for guild ${this.guildId} in session ${this.session.id}: ${s.status}`); this._onConn(s); - }); - this.connection.on('playerStateChange', (_, s) => this._onPlay(s)); - this.connection.on('error', (err) => { + }; + this.connection.on('stateChange', this._connStateHandler); + this._connPlayHandler = (_, s) => this._onPlay(s); + this.connection.on('playerStateChange', this._connPlayHandler); + this._connErrorHandler = (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('error', this._connErrorHandler); + this._connStuckHandler = () => { + if (this.destroying) + return; + logger('warn', 'Player', `Voice library detected stuck stream for guild ${this.guildId}`); + }; + this.connection.on('stuck', this._connStuckHandler); + // Automatically drain incoming voice streams to prevent memory leaks + this._connSpeakStartHandler = (_userId, ssrc) => { + if (this.destroying || !this.connection) + return; + const stream = this.connection.getSpeakStream?.(ssrc); + if (stream && !stream.destroyed) { + stream.resume(); + } + }; + this.connection.on('speakStart', this._connSpeakStartHandler); if (this.nodelink.voiceRelay?.attach) { this.nodelink.voiceRelay.attach(this.connection, this.guildId); } + // Attach ducking controller to the voice connection + if (this.duckingController && this.connection) { + this.duckingController.attach(this.connection); + } } /** * Handles connection state transitions. @@ -262,12 +312,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 +326,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 +381,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; } @@ -349,7 +415,22 @@ export class Player { this._lyricsBasePosition = 0; this._lyricsBasePackets = this.connection?.statistics?.packetsExpected ?? 0; - this.connection?.play(resource); + const oldStream = this.connection?.play(resource); + if (oldStream) + oldStream.destroy(); + if (this._currentResource && this._currentResource !== resource) { + try { + this._currentResource.destroy(); + } + catch { } + } + this._currentResource = resource; + if (this.duckingController && this.connection && resource.fadeTo) { + this.duckingController.attach(this.connection); + this.duckingController.setStreamControl({ + fadeTo: (volume, durationMs, curve) => resource.fadeTo?.(volume, durationMs, curve) + }); + } return; } if ((this.isUpdatingTrack || this._isSeeking) && @@ -363,17 +444,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 +467,17 @@ export class Player { this._emitTrackStart().catch((err) => this._onError(err)); } } - else if (state.status === 'paused') { + else if (state.status === 'idle' && + (state.reason === 'paused' || state.reason === 'requested')) { 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. @@ -468,9 +557,12 @@ export class Player { this.position = 0; this._pausedAtPosition = undefined; this._lastStreamDataTime = 0; + this.streamInfo = null; + this.sponsorBlock.segments = []; this.currentLyrics = null; this.lyricsLineIndex = -1; this._fading('reset'); + this._destroyAudioMixer(); this._lyricsBasePosition = 0; this._lyricsBasePackets = this.connection?.statistics?.packetsExpected ?? 0; if (this._lyricsMarkerTimer) { @@ -492,43 +584,66 @@ 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 mls = this.connection?.mlsSession; + if (mls?._pendingKeyPackage) + mls._pendingKeyPackage = null; const audioStream = conn?.audioStream; - if (!audioStream) + if (this.duckingController) { + this.duckingController.setStreamControl(null); + this.duckingController.detach(); + } + if (this._currentResource) { + try { + this._currentResource.destroy(); + } + catch (err) { + logger('debug', 'Player', `Resource destroy failed during ${context} for guild ${this.guildId}: ${err?.message ?? String(err)}`); + } + this._currentResource = null; + } + if (!audioStream) { return; + } try { audioStream._cleanupListeners?.(); } 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; + finally { + if (conn) + conn.audioStream = null; + } } - /** - * Optionally runs forced GC after finish for leak diagnostics. - */ - _scheduleTrackFinishGcProbe() { - if (!trackFinishForceGcEnabled) + _cleanupSSRCStreams(conn) { + if (!conn?.ssrcs) return; - const gcFn = global.gc; - if (typeof gcFn !== 'function') - return; - const timer = setTimeout(() => { - try { - gcFn(); - gcFn(); + for (const entry of conn.ssrcs.values()) { + const s = entry?.stream; + if (s && !s.destroyed) { + s.resume(); + s.destroy?.(); } - catch { } - this._traceTrackFinishMemory('after-gc'); - }, 0); - timer.unref?.(); + } } /** * Emits TRACK_START and related events after resolving Holo tracks. @@ -592,7 +707,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 +715,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 +764,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(); @@ -658,28 +773,36 @@ export class Player { return { exception: { message: 'Stream processor not initialized' } }; } const additionalData = { - ...urlData.additionalData + ...urlData.additionalData, + ...(startTime !== undefined ? { startTime, position: startTime } : {}), + guildId: this.guildId, + positionCallback: (positionMs) => { + if (!Number.isFinite(positionMs) || positionMs < 0) + return; + this.position = positionMs; + }, + // specific to sabr protocol for now + playbackPaused: () => this.isPaused }; - if (startTime !== undefined) { - additionalData.startTime = startTime; - // Keep both keys for source compatibility while seek handling is unified. - additionalData.position = startTime; - } - additionalData.guildId = this.guildId; - additionalData.positionCallback = (positionMs) => { - if (!Number.isFinite(positionMs) || positionMs < 0) - return; - this.position = positionMs; + const resolvedUrlData = { + ...urlData, + additionalData }; - urlData.additionalData = additionalData; - const track = urlData?.newTrack - ? urlData?.newTrack?.info + const track = resolvedUrlData?.newTrack + ? resolvedUrlData?.newTrack?.info : info; - const fetched = await this.nodelink.sources.getTrackStream(track, urlData.url, urlData.protocol, additionalData); - if (fetched.exception) + logger('debug', 'Player', `Fetching stream resource from source for guild ${this.guildId}`, { + source: track.sourceName, + url: resolvedUrlData.url + }); + const fetched = await this.nodelink.sources.getTrackStream(track, resolvedUrlData.url, resolvedUrlData.protocol, additionalData); + 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 totalBytesRaw = resolvedUrlData.additionalData?.contentLength ?? null; const totalBytesNum = Number(totalBytesRaw); this.profilerStreamStats = { downloadedBytes: 0, @@ -704,6 +827,17 @@ export class Player { streamForResource = fetchedStream.pipe(profilerTap); profilerTap._sourceStream = fetchedStream; + const seekControl = fetchedStream; + const beginSeekHandoff = seekControl.beginSeekHandoff; + if (beginSeekHandoff) { + ; + profilerTap.beginSeekHandoff = () => beginSeekHandoff(); + } + const cancelSeekHandoff = seekControl.cancelSeekHandoff; + if (cancelSeekHandoff) { + ; + profilerTap.cancelSeekHandoff = () => cancelSeekHandoff(); + } const eternalboxHandler = (data) => { this.emitEvent(GatewayEvents.ETERNALBOX_JUMP, { track: this.holoTrack || this.track, @@ -718,17 +852,39 @@ export class Player { }; eventStream.on?.('eternalboxJump', eternalboxHandler); eventStream.on?.('icyMetadata', icyHandler); + let listenersCleaned = false; const cleanupListeners = () => { + if (listenersCleaned) + return; + listenersCleaned = true; eventStream.off?.('eternalboxJump', eternalboxHandler); eventStream.off?.('icyMetadata', icyHandler); profilerTap.off('data', profilerHandler); - profilerTap.destroy(); + streamForResource.off('close', cleanupListeners); + streamForResource.off('error', cleanupListeners); + streamForResource.off('end', cleanupListeners); + const src = profilerTap + ._sourceStream; + if (src && !src.destroyed) { + try { + src.destroy(); + } + catch { } + } + delete profilerTap._sourceStream; + if (!profilerTap.destroyed) { + try { + profilerTap.destroy(); + } + catch { } + } }; 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); + const resource = audioResourceFactory(this.guildId, streamForResource, fetched.type || resolvedUrlData.format, this.nodelink, this.filters, this.volumePercent / 100, this.audioMixer, false, this.loudnessNormalizer); return { stream: resource }; } /** @@ -743,11 +899,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 +912,19 @@ 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.connection.stop(EndReasons.FINISHED); + 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, { @@ -799,12 +963,10 @@ export class Player { const endThreshold = playbackSpeed < 1.0 ? 5000 : 2000; if (trackLength > 0 && position >= trackLength - endThreshold) { logger('debug', 'Player', `Player for guild ${this.guildId} is near track end (${position}/${trackLength}ms). Treating as natural finish instead of stuck.`); - this._emitTrackEnd(EndReasons.FINISHED); - this._resetTrack(); + this.connection.stop(EndReasons.FINISHED); 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 +990,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 +1033,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 +1062,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 +1088,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 +1098,93 @@ export class Player { /** * Starts playback for the current track. */ - async _startPlayback(startTime = 0) { + async _connectAndPlayStream(urlData, position, cleanupReason, fadingAction, playLogMessage) { 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; - 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._currentResource = resource; this.connection.play(resource); + // Connect ducking controller to the new audio stream + if (this.duckingController && resource.fadeTo) { + this.duckingController.attach(this.connection); + this.duckingController.setStreamControl({ + fadeTo: (volume, durationMs, curve) => resource.fadeTo?.(volume, durationMs, curve) + }); + } 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"); + 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 +1218,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 +1283,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 +1316,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.'); @@ -1130,7 +1335,7 @@ export class Player { logger('debug', 'Player', 'Stream info URL became available during wait.'); } } - const source = this.nodelink.sources.getSource(sourceName); + const source = this.nodelink.sources.getSource(resolvedSourceName); const hasSourceLoader = source && typeof source.loadStream === 'function'; const canNativeSeek = !!hasSourceLoader && (this.streamInfo?.protocol === 'sabr' || @@ -1168,6 +1373,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 +1391,27 @@ 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; + let seekHandoff = 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 = await inputStream?.beginSeekHandoff?.(); + if (previousSession) { + seekHandoff = inputStream?.cancelSeekHandoff + ? { cancelSeekHandoff: inputStream.cancelSeekHandoff } + : null; + logger('debug', 'Player', `Extracted SABR session state: rn=${previousSession.requestNumber}, hasCookie=${!!previousSession.nextRequestPolicy?.playbackCookie}`); + reuseUrlData = { + newTrack: this.streamInfo.newTrack, + 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 = { @@ -1210,47 +1421,27 @@ export class Player { const urlData = reuseUrlData || (await this.nodelink.sources.getTrackUrl(trackInfo)); this.streamInfo = { ...urlData, trackInfo: this.track.info }; if (urlData.exception) { + seekHandoff?.cancelSeekHandoff(); const err = new Error(urlData.exception.message); 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)); - 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; + try { + const result = await this._connectAndPlayStream(urlData, position, 'source-seek', 'seekPrepare', `Playing resource for guild ${this.guildId} after source seek`); + if (!result) + seekHandoff?.cancelSeekHandoff(); + return result; } - this._cleanupCurrentAudioStream('source-seek'); - const resource = fetched.stream; - if (this.volumePercent !== 100) { - resource.setVolume(this.volumePercent / 100); + catch (error) { + seekHandoff?.cancelSeekHandoff(); + throw error; } - 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(); @@ -1286,6 +1477,13 @@ export class Player { if (oldStream) { oldStream.destroy(); } + if (this._currentResource && this._currentResource !== resource) { + try { + this._currentResource.destroy(); + } + catch { } + } + this._currentResource = resource; this._lyricsBasePosition = position; this._lyricsBasePackets = this.connection?.statistics?.packetsExpected ?? 0; @@ -1358,7 +1556,15 @@ export class Player { this._fading('seekPrepare', { resource }); this.setFilters(this.filters); logger('debug', 'Player', `Playing resource for guild ${this.guildId} after legacy seek`); + this._currentResource = resource; this.connection.play(resource); + // Connect ducking controller to the new audio stream + if (this.duckingController && resource.fadeTo) { + this.duckingController.attach(this.connection); + this.duckingController.setStreamControl({ + fadeTo: (volume, durationMs, curve) => resource.fadeTo?.(volume, durationMs, curve) + }); + } await this.waitEvent('playerStateChange', (s) => s.status === 'playing'); this._lyricsBasePosition = position; this._lyricsBasePackets = this.connection?.statistics?.packetsExpected ?? 0; @@ -1370,10 +1576,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 +1619,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 +1677,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 +1695,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 +1731,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 +1750,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,12 +1761,67 @@ 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); } return true; } + /** + * Resolves the ducking configuration from the fading config or global config. + * + * @returns Resolved ducking configuration. + */ + _resolveDuckingConfig() { + const fadingDucking = this.fading?.ducking; + const configDucking = this.nodelink.options?.playback?.audio?.fading?.ducking; + const source = fadingDucking ?? configDucking; + return { + enabled: source?.enabled ?? false, + duration: source?.duration ?? 500, + targetVolume: source?.targetVolume ?? 0.15, + curve: source?.curve ?? 'linear' + }; + } + /** + * Toggles auto-ducking (lowers music volume when users speak). + * + * @param enabled - Whether to enable auto-ducking. + * @returns True when updated. + */ + setDucking(enabled) { + logger('debug', 'Player', `[Action: setDucking] Method invoked for guild ${this.guildId} to ${enabled}`); + if (enabled) { + const duckingCfg = this._resolveDuckingConfig(); + duckingCfg.enabled = true; + if (!this.duckingController) { + this.duckingController = new DuckingController(this.guildId, duckingCfg); + } + else { + this.duckingController.updateConfig(duckingCfg); + } + // Attach to current connection if available + if (this.connection) { + this.duckingController.attach(this.connection); + } + // Connect to current audio stream if available + if (this.connection?.audioStream?.fadeTo) { + const stream = this.connection.audioStream; + this.duckingController.setStreamControl({ + fadeTo: (volume, durationMs, curve) => stream.fadeTo?.(volume, durationMs, curve) + }); + } + } + else { + if (this.duckingController) { + this.duckingController.detach(); + this.duckingController.destroy(); + this.duckingController = null; + } + } + return true; + } /** * Applies audio filters to the active stream. * @@ -1554,8 +1829,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 +1966,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; @@ -1716,13 +1995,17 @@ export class Player { logger('debug', 'Player', `Updating voice state for guild ${this.guildId}`); if (!this.connection) this._initConnection(); - if (this.voice.channelId && this.connection) { - this.connection.channelId = this.voice.channelId; + this.connection?.voiceStateUpdate({ + session_id: this.voice.sessionId, + channel_id: this.voice.channelId ?? undefined + }); + if (force && this.connection?.voiceServer) { + this.connection.voiceServer = null; } - this.connection?.voiceStateUpdate({ session_id: this.voice.sessionId }); this.connection?.voiceServerUpdate({ token: this.voice.token, - endpoint: this.voice.endpoint + endpoint: this.voice.endpoint, + channel_id: this.voice.channelId ?? undefined }); this.connection?.connect(async () => { if (this.destroying) @@ -1734,7 +2017,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,17 +2033,30 @@ 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 { + this.connection.removeListener('stateChange', this._connStateHandler); + this.connection.removeListener('playerStateChange', this._connPlayHandler); + this.connection.removeListener('error', this._connErrorHandler); + this.connection.removeListener('stuck', this._connStuckHandler); + this.connection.removeListener('speakStart', this._connSpeakStartHandler); + if (this.nodelink.voiceRelay?.detach) { + this.nodelink.voiceRelay.detach(this.connection); + } if (this.connection.audioStream) { this.connection.stop(EndReasons.CLEANUP); this._cleanupCurrentAudioStream('destroy'); } + this._cleanupSSRCStreams(this.connection); this.connection.destroy(); + if (this.duckingController) { + this.duckingController.destroy(); + this.duckingController = null; + } this.connection = null; } catch (err) { @@ -1776,6 +2074,14 @@ export class Player { this.emitEvent(GatewayEvents.PLAYER_DESTROYED, { guildId: this.guildId }); + this._destroyAudioMixer(); + if (this._currentResource) { + try { + this._currentResource.destroy(); + } + catch { } + this._currentResource = null; + } this._resetTrack(); this.connStatus = 'destroyed'; this.volumePercent = this.nodelink.options?.defaultVolume ?? 100; @@ -1788,13 +2094,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 @@ -1803,7 +2110,7 @@ export class Player { throw new Error(`Maximum number of mix layers(${mixConfig.maxLayersMix}) reached`); } const mixVolume = volume ?? mixConfig.defaultVolume ?? 0.8; - const { createAudioResource: createResource } = await import("./processing/streamProcessor.js"); + const { createAudioResource: createResource } = await import('./processing/streamProcessor.js'); const urlData = await this.nodelink.sources.getTrackUrl(trackPayload.info); if (!urlData?.url) { throw new Error('Failed to get stream URL for mix track'); @@ -1827,6 +2134,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 +2148,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 +2160,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 +2172,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 +2193,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 +2212,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 +2221,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 +2235,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 +2243,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 +2396,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 +2409,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 +2537,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') @@ -2268,8 +2588,9 @@ export class Player { return false; this._pendingTrackStartFade = false; if (fadeType === 'volume' || fadeType === 'both') { + const targetVol = this.duckingController?.getTargetVolume(1) ?? 1; if (stream.fadeTo) - stream.fadeTo?.(1, section.duration, section.curve); + stream.fadeTo?.(targetVol, section.duration, section.curve); } if (fadeType === 'tape' || fadeType === 'both') { if (stream.tapeTo) @@ -2304,9 +2625,10 @@ export class Player { if (!stream) return false; if (fadeType === 'volume' || fadeType === 'both') { + const targetVol = this.duckingController?.getTargetVolume(1) ?? 1; if (stream.setFadeVolume) stream.setFadeVolume(0); - stream.fadeTo?.(1, section.duration, section.curve); + stream.fadeTo?.(targetVol, section.duration, section.curve); } if (fadeType === 'tape' || fadeType === 'both') { stream.tapeTo?.(section.duration, 'start', section.curve); diff --git a/dist/src/playback/processing/AudioMixer.js b/dist/src/playback/processing/AudioMixer.js index cfb6f22b..67be018d 100644 --- a/dist/src/playback/processing/AudioMixer.js +++ b/dist/src/playback/processing/AudioMixer.js @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; import { Readable } from 'node:stream'; -import { RingBuffer } from "../structs/RingBuffer.js"; +import { RingBuffer } from '../structs/RingBuffer.js'; const LAYER_BUFFER_SIZE = 1024 * 1024; const EMPTY_BUFFER = Buffer.alloc(0); const FRAME_SIZE = 3840; @@ -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/DuckingController.js b/dist/src/playback/processing/DuckingController.js new file mode 100644 index 00000000..75cfc012 --- /dev/null +++ b/dist/src/playback/processing/DuckingController.js @@ -0,0 +1,210 @@ +import { Decoder as OpusDecoder } from '../../playback/opus/Opus.js'; +import { logger } from '../../utils.js'; +/** + * Controls auto-ducking behavior: lowers music volume when users speak + * and restores it when they stop. + */ +export class DuckingController { + config; + streamControl = null; + activeSpeakers = new Map(); + isDucked = false; + destroyed = false; + hookedStreams = new Set(); + activeStreams = new Map(); + static HOLD_MS = 600; + static RMS_THRESHOLD = 3500; + _onSpeakStart = (_userId, ssrc) => { + if (this.destroyed || !this.config.enabled || !this.streamControl) + return; + if (this.hookedStreams.has(ssrc)) + return; + const stream = this.connection?.getSpeakStream?.(ssrc); + if (!stream) + return; + this.hookedStreams.add(ssrc); + let decoder = null; + try { + decoder = new OpusDecoder({ rate: 48000, channels: 2 }); + } + catch (err) { + logger('warn', 'Ducking', `Failed to create Opus decoder for RMS ducking: ${err.message}`); + const onData = (chunk) => { + if (this.destroyed || !this.config.enabled) + return; + if (chunk.length < 40) + return; + this._registerSpeech(ssrc); + }; + const onEnd = () => this._cleanupStream(ssrc); + this.activeStreams.set(ssrc, { + stream, + decoder: null, + onData, + onEnd + }); + stream.on('data', onData); + stream.once('end', onEnd); + stream.once('close', onEnd); + return; + } + let lastLogTime = 0; + const onData = (pcm) => { + if (this.destroyed || !this.config.enabled) + return; + let sumSquare = 0; + for (let i = 0; i < pcm.length; i += 2) { + const sample = pcm.readInt16LE(i); + sumSquare += sample * sample; + } + const rms = Math.sqrt(sumSquare / (pcm.length / 2)); + const now = Date.now(); + if (now - lastLogTime > 1000) { + logger('debug', 'Ducking', `[RMS Debug] SSRC ${ssrc} - Current RMS: ${Math.round(rms)} (Threshold: ${DuckingController.RMS_THRESHOLD})`); + lastLogTime = now; + } + if (rms >= DuckingController.RMS_THRESHOLD) { + this._registerSpeech(ssrc, rms); + } + }; + const onEnd = () => this._cleanupStream(ssrc); + this.activeStreams.set(ssrc, { stream, decoder, onData, onEnd }); + stream.pipe(decoder); + decoder.on('data', onData); + stream.once('end', onEnd); + stream.once('close', onEnd); + }; + connection = null; + guildId; + constructor(guildId, config) { + this.guildId = guildId; + this.config = { ...config }; + } + updateConfig(config) { + this.config = { ...config }; + if (!config.enabled && this.isDucked) + this._restore(0); + } + getTargetVolume(defaultVolume = 1.0) { + return this.isDucked ? this.currentTargetVolume : defaultVolume; + } + setStreamControl(control) { + this.streamControl = control; + if (!control) { + for (const ssrc of this.activeStreams.keys()) + this._cleanupStream(ssrc); + this._restore(0); + return; + } + if (this.isDucked && this.streamControl) { + try { + this.streamControl.fadeTo(this.config.targetVolume, this.config.duration, this.config.curve); + } + catch (err) { + logger('error', 'Ducking', `Failed to reapply ducked volume for guild ${this.guildId}: ${err.message}`); + } + } + } + attach(connection) { + if (this.connection) + return; + this.connection = connection; + this.connection.on('speakStart', this._onSpeakStart); + logger('debug', 'Ducking', `Attached ducking listeners for guild ${this.guildId}`); + } + detach() { + if (this.connection) { + this.connection.removeListener('speakStart', this._onSpeakStart); + this.connection = null; + logger('debug', 'Ducking', `Detached ducking listeners for guild ${this.guildId}`); + } + for (const ssrc of this.activeStreams.keys()) + this._cleanupStream(ssrc); + this.hookedStreams.clear(); + this._restore(0); + } + destroy() { + this.destroyed = true; + this.detach(); + this.activeSpeakers.forEach(clearTimeout); + this.activeSpeakers.clear(); + this.streamControl = null; + } + currentTargetVolume = 1.0; + lastFadeTime = 0; + _cleanupStream(ssrc) { + const entry = this.activeStreams.get(ssrc); + if (!entry) + return; + this.activeStreams.delete(ssrc); + this.hookedStreams.delete(ssrc); + entry.stream.off('end', entry.onEnd); + entry.stream.off('close', entry.onEnd); + if (entry.decoder) { + entry.decoder.off('data', entry.onData); + entry.stream.unpipe(entry.decoder); + entry.decoder.destroy(); + } + else { + entry.stream.off('data', entry.onData); + } + const existing = this.activeSpeakers.get(ssrc); + if (existing) { + clearTimeout(existing); + this.activeSpeakers.delete(ssrc); + if (this.activeSpeakers.size === 0) + this._restore(); + } + } + _registerSpeech(ssrc, rms = 0) { + const existing = this.activeSpeakers.get(ssrc); + if (existing) + clearTimeout(existing); + this.activeSpeakers.set(ssrc, setTimeout(() => { + this.activeSpeakers.delete(ssrc); + logger('debug', 'Ducking', `Speaker ${ssrc} timeout expired. Active speakers: ${this.activeSpeakers.size}`); + if (this.activeSpeakers.size === 0) + this._restore(); + }, DuckingController.HOLD_MS)); + const intensity = Math.min(1.0, Math.max(0, (rms - DuckingController.RMS_THRESHOLD) / + (15000 - DuckingController.RMS_THRESHOLD))); + const minVol = 0.03; + let dynamicVol = this.config.targetVolume - intensity * (this.config.targetVolume - minVol); + dynamicVol = Number(Math.max(minVol, dynamicVol).toFixed(2)); + const now = Date.now(); + const wasDucked = this.isDucked; + const isAttacking = dynamicVol < this.currentTargetVolume; + const threshold = isAttacking ? 0.02 : 0.06; + const isSignificantChange = Math.abs(this.currentTargetVolume - dynamicVol) >= threshold; + if (!wasDucked || (isSignificantChange && now - this.lastFadeTime > 100)) { + this.isDucked = true; + let fadeDur = 50; + if (!isAttacking && wasDucked) { + fadeDur = 400; + } + this.currentTargetVolume = dynamicVol; + this.lastFadeTime = now; + try { + this.streamControl?.fadeTo(dynamicVol, fadeDur, this.config.curve); + logger('debug', 'Ducking', `Proportional Ducking -> Vol: ${dynamicVol} | Dur: ${fadeDur}ms | RMS: ${Math.round(rms)} | Guild: ${this.guildId}`); + } + catch (err) { + logger('error', 'Ducking', `Failed to dynamically duck volume for guild ${this.guildId}: ${err.message}`); + } + } + } + _restore(overrideDuration) { + if (!this.isDucked) + return; + this.isDucked = false; + this.currentTargetVolume = 1.0; + const duration = overrideDuration ?? Math.max(this.config.duration, 600); + try { + this.streamControl?.fadeTo(1.0, duration, this.config.curve); + logger('debug', 'Ducking', `Volume restored to 1.0 over ${duration}ms for guild ${this.guildId}`); + } + catch (err) { + logger('error', 'Ducking', `Failed to restore volume for guild ${this.guildId}: ${err.message}`); + } + } +} 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..90591c58 100644 --- a/dist/src/playback/processing/VolumeTransformer.js +++ b/dist/src/playback/processing/VolumeTransformer.js @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; import { Transform } from 'node:stream'; -import { LoudnessNormalizer } from "./LoudnessNormalizer.js"; +import { LoudnessNormalizer } from './LoudnessNormalizer.js'; const INT16_MAX = 32767; const INT16_MIN = -32768; const DEFAULT_CURVE = 'sinusoidal'; @@ -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..fc53efd9 100644 --- a/dist/src/playback/processing/filtersManager.js +++ b/dist/src/playback/processing/filtersManager.js @@ -1,22 +1,23 @@ import { Transform } from 'node:stream'; -import ChannelMix from "../filters/channelMix.js"; -import Chorus from "../filters/chorus.js"; -import Compressor from "../filters/compressor.js"; -import Distortion from "../filters/distortion.js"; -import Echo from "../filters/echo.js"; -import Equalizer from "../filters/equalizer.js"; -import Flanger from "../filters/flanger.js"; -import Highpass from "../filters/highpass.js"; -import Karaoke from "../filters/karaoke.js"; -import Lowpass from "../filters/lowpass.js"; -import Phaser from "../filters/phaser.js"; -import Phonograph from "../filters/phonograph.js"; -import Reverb from "../filters/reverb.js"; -import Rotation from "../filters/rotation.js"; -import Spatial from "../filters/spatial.js"; -import Timescale from "../filters/timescale.js"; -import Tremolo from "../filters/tremolo.js"; -import Vibrato from "../filters/vibrato.js"; +import ChannelMix from '../filters/channelMix.js'; +import Chorus from '../filters/chorus.js'; +import Compressor from '../filters/compressor.js'; +import Distortion from '../filters/distortion.js'; +import Echo from '../filters/echo.js'; +import Equalizer from '../filters/equalizer.js'; +import Flanger from '../filters/flanger.js'; +import Highpass from '../filters/highpass.js'; +import Karaoke from '../filters/karaoke.js'; +import Lowpass from '../filters/lowpass.js'; +import Phaser from '../filters/phaser.js'; +import Phonograph from '../filters/phonograph.js'; +import Reverb from '../filters/reverb.js'; +import Rotation from '../filters/rotation.js'; +import Spatial from '../filters/spatial.js'; +import Timescale from '../filters/timescale.js'; +import Tremolo from '../filters/tremolo.js'; +import Vibrato from '../filters/vibrato.js'; +import Tesseract from '../filters/tesseract.js'; const FILTER_CLASSES = { tremolo: Tremolo, vibrato: Vibrato, @@ -35,7 +36,8 @@ const FILTER_CLASSES = { spatial: Spatial, reverb: Reverb, flanger: Flanger, - phonograph: Phonograph + phonograph: Phonograph, + tesseract: Tesseract }; const CANONICAL_KEY_MAP = {}; for (const key in FILTER_CLASSES) { @@ -234,11 +236,24 @@ export class FiltersManager extends Transform { * @param filters - Filter payload in any supported shape. */ _normalizeFilters(filters) { - if (!filters || typeof filters !== 'object') - return {}; - if ('filters' in filters) { - return filters.filters ?? {}; + let normalized = {}; + if (filters && typeof filters === 'object') { + if ('filters' in filters) { + normalized = filters.filters ?? {}; + } + else { + normalized = filters; + } } - return filters; + return normalized; + } + _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..fe64ca47 100644 --- a/dist/src/playback/processing/streamProcessor.js +++ b/dist/src/playback/processing/streamProcessor.js @@ -3,19 +3,19 @@ import { PassThrough, Readable, Transform, pipeline } from 'node:stream'; import FAAD2NodeDecoder from '@ecliptia/faad2-wasm/faad2_node_decoder.js'; import { SeekError, seekableStream } from '@ecliptia/seekable-stream'; import { SymphoniaDecoder } from '@toddynnn/symphonia-decoder'; -import { normalizeFormat, SupportedFormats } from "../../constants.js"; -import { http1makeRequest, logger } from "../../utils.js"; -import FlvDemuxer from "../demuxers/Flv.js"; -import WebmOpusDemuxer from "../demuxers/WebmOpus.js"; -import { Decoder as OpusDecoder, Encoder as OpusEncoder } from "../opus/Opus.js"; -import { RingBuffer } from "../structs/RingBuffer.js"; -import { FadeTransformer } from "./FadeTransformer.js"; -import { TapeTransformer } from "./TapeTransformer.js"; -import { ScratchTransformer } from "./ScratchTransformer.js"; -import { FlowController } from "./FlowController.js"; -import { FiltersManager } from "./filtersManager.js"; -import { VolumeTransformer } from "./VolumeTransformer.js"; -import { SilenceDetector } from "./SilenceDetector.js"; +import { normalizeFormat, SupportedFormats } from '../../constants.js'; +import { http1makeRequest, logger } from '../../utils.js'; +import FlvDemuxer from '../demuxers/Flv.js'; +import WebmOpusDemuxer from '../demuxers/WebmOpus.js'; +import { Decoder as OpusDecoder, Encoder as OpusEncoder } from '../opus/Opus.js'; +import { RingBuffer } from '../structs/RingBuffer.js'; +import { FadeTransformer } from './FadeTransformer.js'; +import { TapeTransformer } from './TapeTransformer.js'; +import { ScratchTransformer } from './ScratchTransformer.js'; +import { FlowController } from './FlowController.js'; +import { FiltersManager } from './filtersManager.js'; +import { VolumeTransformer } from './VolumeTransformer.js'; +import { SilenceDetector } from './SilenceDetector.js'; let libSampleRatePromise = null; let mp4BoxPromise = null; const getMP4Box = async () => { @@ -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, @@ -69,6 +69,37 @@ const SAMPLE_RATES = Object.freeze([ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 ]); +const _parseAacSampleRate = (data) => { + let bitOffset = 0; + const readBits = (count) => { + if (bitOffset + count > data.byteLength * 8) + return null; + let value = 0; + for (let i = 0; i < count; i++) { + const byte = data[bitOffset >> 3]; + if (byte === undefined) + return null; + value = (value << 1) | ((byte >> (7 - (bitOffset & 7))) & 1); + bitOffset++; + } + return value; + }; + const objectType = readBits(5); + if (objectType === null) + return null; + if (objectType === 31 && readBits(6) === null) + return null; + const samplingIndex = readBits(4); + if (samplingIndex === null) + return null; + if (samplingIndex === 15) { + const explicitSampleRate = readBits(24); + return explicitSampleRate && explicitSampleRate > 0 + ? explicitSampleRate + : null; + } + return SAMPLE_RATES[samplingIndex] ?? null; +}; const EMPTY_BUFFER = Buffer.alloc(0); const _getResamplerConverterType = (quality, libSampleRate) => { const types = libSampleRate.ConverterType; @@ -160,6 +191,22 @@ 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'; + if (lowerType.includes('alac') || + lowerType.includes('mp4') || + lowerType.includes('m4a')) + return 'm4a'; + return null; +}; const _tightBuffer = (buf) => buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength ? buf : Buffer.from(buf); @@ -374,13 +421,18 @@ class PCMFrameCounter extends Transform { class BaseAudioResource { pipes; stream; + canStop; _destroyed; guildId; + _forwardFinishBuffering = null; + _finishBufferingEmitted; constructor(guildId) { this.guildId = guildId || 'api-stream'; this.pipes = []; this.stream = null; + this.canStop = false; this._destroyed = false; + this._finishBufferingEmitted = false; } _assignStream(stream) { const voiceStream = stream; @@ -392,6 +444,27 @@ 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); + voiceStream.isPipelineFinished = () => this.isPipelineFinished(); + /* + * This will prevent a race condition where fast CDN/network streams finish buffering and emit + * 'finishBuffering' before the voice connection finishes starting and subscribes to the event. + * Using 'newListener' event, if the voice player subscribes after the stream has + * already buffered, we will re-emit 'finishBuffering' immediately, allowing the track to be stoppable + * and transition to trackEnd naturally. + * + * ... i hate nodejs sometimes... + */ + voiceStream.on('newListener', (event) => { + if (event === 'finishBuffering' && this._finishBufferingEmitted) { + setImmediate(() => { + voiceStream.emit('finishBuffering'); + }); + } + }); this.stream = voiceStream; } _end() { @@ -399,23 +472,41 @@ class BaseAudioResource { return; this._destroyed = true; const firstPipe = this.pipes[0]; + if (typeof firstPipe?._cleanupListeners === 'function') { + try { + firstPipe._cleanupListeners(); + } + catch { } + } + if (this._forwardFinishBuffering && firstPipe?._sourceStream) { + try { + firstPipe._sourceStream.off?.('finishBuffering', this._forwardFinishBuffering); + } + catch { } + } if (firstPipe?.stopHls) { firstPipe.stopHls(); } if (firstPipe?.responseStream?.destroyed === false) { firstPipe.responseStream.destroy(); } - const src = firstPipe; - if (src._sourceStream && !src._sourceStream.destroyed) { - src._sourceStream.destroy(); + if (firstPipe?._sourceStream && !firstPipe._sourceStream.destroyed) { + try { + firstPipe._sourceStream.destroy(); + } + catch { } + try { + delete firstPipe._sourceStream; + } + catch { } } for (let i = this.pipes.length - 1; i >= 0; i--) { const pipe = this.pipes[i]; pipe.abort?.(); pipe.unpipe?.(); pipe.destroy?.(); - pipe.removeAllListeners?.(); } + this.pipes.length = 0; this.stream = null; this.pipes = null; } @@ -447,6 +538,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 +638,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 +676,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 +685,109 @@ class SymphoniaDecoderStream extends Transform { callback(); return; } - this.decoder.push(chunk); - this._scheduleDecode(); - const bufferedBytes = this.decoder?.bufferedBytes ?? 0; - if (bufferedBytes > BUFFER_THRESHOLDS.maxCompressed) { - this.resumeInput = callback; - } - else { + try { + this.decoder.push(chunk); + if (!this.decoder.isProbed) { + this.decoder.initialize(this.codecRegistryHint); + } + this._scheduleDecode(); callback(); } + catch (err) { + callback(err); + } + } + _read(_size) { + super._read(_size); + if (this._isDecoderValid()) { + this._scheduleDecode(); + } } - _scheduleDecode() { - if (this._loopScheduled || - this._isDecoding || - !this._isDecoderValid() || - this.readableFlowing === false) + _scheduleDecode(delayMs = 0) { + if (this._immediateId || this._timeoutId || !this._isDecoderValid()) return; - if (this.readableLength >= this.readableHighWaterMark) { - this._loopScheduled = true; + if (delayMs > 0) { this._timeoutId = setTimeout(() => { this._timeoutId = null; - this._loopScheduled = false; - if (this._isDecoderValid()) - this._scheduleDecode(); - }, AUDIO_CONSTANTS.decodeIntervalMs); + this._decodeLoop(); + }, delayMs); return; } - this._loopScheduled = true; - this._timeoutId = setTimeout(() => { - this._timeoutId = null; - this._loopScheduled = false; - if (this._isDecoderValid()) - this._decodeLoop(); - }, AUDIO_CONSTANTS.decodeIntervalMs); + this._immediateId = setImmediate(() => { + this._immediateId = null; + this._decodeLoop(); + }); } - async _decodeLoop() { - if (!this._isDecoderValid() || this.readableFlowing === false) + _decodeLoop() { + if (this._isDecoding || !this._isDecoderValid()) + return; + if (!this.decoder?.isProbed) { + try { + if (!this.decoder?.initialize(this.codecRegistryHint)) { + if (this.inputClosed) + this._finishDecode(); + return; + } + } + catch (err) { + this._failDecode(err); + return; + } + } + if (this.readableLength >= this.readableHighWaterMark) { + this._scheduleDecode(AUDIO_CONSTANTS.decodeIntervalMs); return; + } this._isDecoding = true; try { - let hasMoreData = true; - while (hasMoreData && + let decodeCount = 0; + while (decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && this._isDecoderValid() && - this.readableFlowing !== false && this.readableLength < this.readableHighWaterMark) { - hasMoreData = this._processAudio(); - if (hasMoreData && this._isDecoderValid()) { - await new Promise((resolve) => { - this._immediateId = setImmediate(() => { - this._immediateId = null; - resolve(); - }); - }); + const result = this.decoder?.decode(); + if (!result) { + if (this.inputClosed) + this._finishDecode(); + return; + } + decodeCount++; + if (result.samples.length === 0) + continue; + if (!this.push(result.samples)) { + this._scheduleDecode(AUDIO_CONSTANTS.decodeIntervalMs); + return; } } + this._scheduleDecode(); } catch (err) { - if (!this._aborted) - this.emit('error', err); + this._failDecode(err); } finally { this._isDecoding = false; } - const bufferedBytes = this.decoder?.bufferedBytes ?? 0; - if (bufferedBytes > 0 && - this._isDecoderValid() && - this.readableFlowing !== false && - this.readableLength < this.readableHighWaterMark) { - this._scheduleDecode(); - } } - _processAudio() { - if (!this._isDecoderValid()) - return false; - if (this.readableLength >= this.readableHighWaterMark) - return true; - if (!this.decoder?.isProbed) { - try { - if (!this.decoder?.initialize()) - return false; - } - catch (err) { - throw new Error(`Symphonia init failed: ${err.message}`); - } + _finishDecode() { + const callback = this.flushCallback; + this.flushCallback = null; + this.isFinished = true; + this._cleanup(); + callback?.(); + } + _failDecode(err) { + const callback = this.flushCallback; + this.flushCallback = null; + this.isFinished = true; + this._cleanup(); + const error = err instanceof Error ? err : new Error(`Symphonia decode failed: ${err}`); + if (callback) { + callback(error); } - let decodeCount = 0; - let hasOutput = false; - while (decodeCount < AUDIO_CONSTANTS.maxDecodesPerTick && - this._isDecoderValid() && - this.readableLength < this.readableHighWaterMark) { - const result = this.decoder?.decode(); - if (!result) - break; - const canPush = this.push(result.samples); - hasOutput = true; - decodeCount++; - if (this.resumeInput) { - const afterBytes = this.decoder?.bufferedBytes ?? 0; - if (afterBytes < BUFFER_THRESHOLDS.minCompressed) { - const cb = this.resumeInput; - this.resumeInput = null; - cb(); - } - } - if (!canPush) - break; + else { + this.emit('error', error); } - const remainingBytes = this.decoder?.bufferedBytes ?? 0; - return hasOutput || remainingBytes > 0; } _flush(callback) { - this.isFinished = true; this._cancelTimers(); if (this._aborted || !this.decoder) { this._cleanup(); @@ -705,47 +796,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 +915,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) { @@ -912,11 +995,13 @@ class AACDecoderStream extends Transform { resamplingQuality; resamplerCreationPromise; static MAX_PENDING_CHUNKS = 200; + state; constructor(options) { super({ ...options, highWaterMark: AUDIO_CONFIG.highWaterMark }); + this.state = options.state ?? null; this.decoder = new FAAD2NodeDecoder(); this.resampler = null; this.isDecoderReady = false; @@ -937,8 +1022,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) { @@ -1048,6 +1135,11 @@ class AACDecoderStream extends Transform { return null; } _transform(chunk, encoding, callback) { + if (this.state?.isAlac) { + this.push(chunk); + callback(); + return; + } if (!this.isDecoderReady || this.pendingChunks.length > 0) { if (this.pendingChunks.length >= AACDecoderStream.MAX_PENDING_CHUNKS) { this.pendingChunks.shift(); @@ -1094,9 +1186,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; @@ -1133,6 +1223,10 @@ class AACDecoderStream extends Transform { } } _flush(callback) { + if (this.state?.isAlac) { + callback(); + return; + } if (this.ringBuffer.length > 0 && this.isConfigured) { try { const frameInfo = this._findADTSFrame(); @@ -1150,13 +1244,80 @@ 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(); } } +function patchMoovOffsets(moovBuffer, shift) { + let idx = 0; + while (true) { + idx = moovBuffer.indexOf('stco', idx); + if (idx === -1) + break; + const boxStart = idx - 4; + if (boxStart >= 0) { + const boxSize = moovBuffer.readUInt32BE(boxStart); + const entryCount = moovBuffer.readUInt32BE(idx + 8); + if (boxSize === 16 + entryCount * 4) { + for (let i = 0; i < entryCount; i++) { + const offsetPos = idx + 12 + i * 4; + const originalOffset = moovBuffer.readUInt32BE(offsetPos); + moovBuffer.writeUInt32BE(originalOffset + shift, offsetPos); + } + } + } + idx += 4; + } + idx = 0; + while (true) { + idx = moovBuffer.indexOf('co64', idx); + if (idx === -1) + break; + const boxStart = idx - 4; + if (boxStart >= 0) { + const boxSize = moovBuffer.readUInt32BE(boxStart); + const entryCount = moovBuffer.readUInt32BE(idx + 8); + if (boxSize === 16 + entryCount * 8) { + for (let i = 0; i < entryCount; i++) { + const offsetPos = idx + 12 + i * 8; + const high = moovBuffer.readUInt32BE(offsetPos); + const low = moovBuffer.readUInt32BE(offsetPos + 4); + const originalOffset = high * 0x100000000 + low; + const newOffset = originalOffset + shift; + const newHigh = Math.floor(newOffset / 0x100000000); + const newLow = newOffset % 0x100000000; + moovBuffer.writeUInt32BE(newHigh, offsetPos); + moovBuffer.writeUInt32BE(newLow, offsetPos + 4); + } + } + } + idx += 4; + } +} +/* INFO: for context of why i did this: https://github.com/pdeljanov/Symphonia/issues/289, still seems to be broken. */ +function reorderMp4Boxes(originalBuffer) { + const topBoxes = _parseBoxes(originalBuffer); + const ftyp = topBoxes.find((b) => b.type === 'ftyp'); + const moov = topBoxes.find((b) => b.type === 'moov'); + const mdat = topBoxes.find((b) => b.type === 'mdat'); + if (!ftyp || !moov || !mdat) { + return originalBuffer; + } + if (moov.offset < mdat.offset) { + return originalBuffer; + } + const ftypBuffer = originalBuffer.subarray(ftyp.offset, ftyp.offset + ftyp.size); + const moovBuffer = Buffer.from(originalBuffer.subarray(moov.offset, moov.offset + moov.size)); + patchMoovOffsets(moovBuffer, moov.size); + const beforeMoov = originalBuffer.subarray(ftyp.offset + ftyp.size, moov.offset); + const afterMoov = originalBuffer.subarray(moov.offset + moov.size); + return Buffer.concat([ftypBuffer, moovBuffer, beforeMoov, afterMoov]); +} class MP4ToAACStream extends Transform { mp4boxFile; audioConfig; @@ -1165,6 +1326,9 @@ class MP4ToAACStream extends Transform { _prefetchDone; _opts; _initPromise; + state; + symphoniaDecoder; + headerChunks; constructor(options = {}) { super({ ...options, highWaterMark: AUDIO_CONFIG.highWaterMark }); this._opts = options; @@ -1174,6 +1338,9 @@ class MP4ToAACStream extends Transform { this._aborted = false; this._prefetchDone = false; this._initPromise = null; + this.state = options.state ?? null; + this.symphoniaDecoder = null; + this.headerChunks = []; } async _initMp4Box() { if (this.mp4boxFile) @@ -1184,7 +1351,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; @@ -1192,12 +1359,16 @@ class MP4ToAACStream extends Transform { abort() { this._aborted = true; this._cleanupMp4Box(); + if (this.symphoniaDecoder) { + this.symphoniaDecoder.abort?.(); + } } _appendPrefetchIfNeeded() { if (this._prefetchDone || !this.mp4boxFile) return; this._prefetchDone = true; const prefetch = this._opts.prefetch ?? []; + this._opts.prefetch = undefined; for (const chunk of prefetch) { const ab = chunk.data; ab.fileStart = chunk.fileStart; @@ -1213,26 +1384,48 @@ class MP4ToAACStream extends Transform { this.mp4boxFile.onReady = (info) => { if (this._aborted || !this.mp4boxFile) return; - const audioTrack = info.tracks.find((t) => t.codec?.startsWith('mp4a')); - if (!audioTrack) - throw new Error('No AAC track found in MP4'); - this.audioConfig = this._getAudioConfig(audioTrack); - this.mp4boxFile.setExtractionOptions(audioTrack.id, null, { - nbSamples: 50 - }); - if (typeof this._opts.seekTimeSec === 'number') { - const mp4boxFile = this.mp4boxFile; - const seekRes = mp4boxFile.seek(this._opts.seekTimeSec, true); - const expectedOffset = _seekOffset(seekRes); - if (typeof this._opts.baseFileStart === 'number' && - this._opts.baseFileStart !== expectedOffset) { - logger('warn', 'MP4ToAACStream', `MP4 seek mismatch: stream starts at ${this._opts.baseFileStart} but MP4Box requested ${expectedOffset}`); - } - if (typeof this._opts.baseFileStart !== 'number') { - this.offset = expectedOffset; + const audioTrack = info.tracks.find((t) => t.codec?.startsWith('mp4a') || t.codec?.startsWith('alac')); + if (!audioTrack) { + throw new Error('No supported track found in MP4'); + } + if (audioTrack.codec?.startsWith('alac')) { + if (this.state) + this.state.isAlac = true; + this._cleanupMp4Box(); + this.symphoniaDecoder = new SymphoniaDecoderStream({ + codecRegistryHint: 'm4a' + }); + this.symphoniaDecoder.on('data', (pcm) => { + this.push(pcm); + }); + this.symphoniaDecoder.on('error', (err) => { + this.emit('error', err); + }); + const fullBuffer = Buffer.concat(this.headerChunks); + const reordered = reorderMp4Boxes(fullBuffer); + this.headerChunks = []; + this.symphoniaDecoder.write(reordered); + } + else { + this.audioConfig = this._getAudioConfig(audioTrack); + this.headerChunks = []; + this.mp4boxFile.setExtractionOptions(audioTrack.id, null, { + nbSamples: 50 + }); + if (typeof this._opts.seekTimeSec === 'number') { + const mp4boxFile = this.mp4boxFile; + const seekRes = mp4boxFile.seek(this._opts.seekTimeSec, true); + const expectedOffset = _seekOffset(seekRes); + if (typeof this._opts.baseFileStart === 'number' && + this._opts.baseFileStart !== expectedOffset) { + logger('warn', 'MP4ToAACStream', `MP4 seek mismatch: stream starts at ${this._opts.baseFileStart} but MP4Box requested ${expectedOffset}`); + } + if (typeof this._opts.baseFileStart !== 'number') { + this.offset = expectedOffset; + } } + this.mp4boxFile.start(); } - this.mp4boxFile.start(); }; this.mp4boxFile.onSamples = (id, _user, samples) => { if (this._aborted || !this.mp4boxFile) @@ -1258,16 +1451,21 @@ class MP4ToAACStream extends Transform { } _getAudioConfig(track) { let profile = 2; - let adtsSampleRate = track.audio.sample_rate; + const file = this.mp4boxFile; + const entry = file?.getTrackById?.(track.id)?.mdia?.minf?.stbl?.stsd + ?.entries?.[0]; + const decoderConfig = entry?.esds?.esd?.descs?.find((descriptor) => descriptor.tag === 4); + const decoderSpecificInfo = decoderConfig?.descs?.find((descriptor) => descriptor.tag === 5)?.data; + const adtsSampleRate = (decoderSpecificInfo && _parseAacSampleRate(decoderSpecificInfo)) || + track.audio.sample_rate; if (track.codec) { const codecParts = (String(track.codec) || '').split('.'); if (codecParts.length >= 3) { const objectType = Number.parseInt(codecParts[2] || '0', 10); if (objectType === 5 || objectType === 29) { - // HE-AAC/HE-AACv2 stores the output rate on the track, but ADTS must - // advertise the core AAC-LC rate (typically half of the output rate). + // ADTS carries the AAC-LC core profile. FAAD detects the SBR/PS + // extension from the payload and exposes the higher output rate. profile = 2; - adtsSampleRate = Math.max(SAMPLE_RATES[SAMPLE_RATES.length - 1] ?? 7350, Math.floor(track.audio.sample_rate / 2)); } else { profile = objectType; @@ -1290,6 +1488,13 @@ class MP4ToAACStream extends Transform { callback(); return; } + if (this.symphoniaDecoder) { + this.symphoniaDecoder.write(chunk); + callback(); + return; + } + if (!this.audioConfig) + this.headerChunks.push(chunk); try { await this._initMp4Box(); if (!this.mp4boxFile) { @@ -1311,24 +1516,42 @@ class MP4ToAACStream extends Transform { } } _flush(callback) { + const decoderInstance = this + .symphoniaDecoder; + if (decoderInstance) { + decoderInstance.end(callback); + return; + } if (!this._aborted && this.mp4boxFile) { try { this.mp4boxFile.flush(); } catch { } } + const decoderInstanceAfter = this + .symphoniaDecoder; + if (decoderInstanceAfter) { + decoderInstanceAfter.end(callback); + return; + } this._cleanupMp4Box(); callback(); } _destroy(err, callback) { this._aborted = true; this._cleanupMp4Box(); + this.headerChunks = []; + if (this.symphoniaDecoder) { + this.symphoniaDecoder.destroy(); + this.symphoniaDecoder = null; + } super._destroy(err, callback); } _cleanupMp4Box() { if (this.mp4boxFile) { try { this.mp4boxFile.stop(); + this.mp4boxFile.flush(); } catch { } this.mp4boxFile.onReady = null; @@ -1823,7 +2046,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 +2075,8 @@ class StreamAudioResource extends BaseAudioResource { case SupportedFormats.FLAC: case SupportedFormats.OGG_VORBIS: case SupportedFormats.WAV: - return this._createSymphoniaPipeline(stream); + case SupportedFormats.ALAC: + return this._createSymphoniaPipeline(stream, type); case SupportedFormats.OPUS: return this._createOpusPipeline(stream, type); default: @@ -1876,6 +2100,7 @@ class StreamAudioResource extends BaseAudioResource { const lowerType = type.toLowerCase(); const _aacStream = stream; const streams = [stream]; + const state = { isAlac: false }; if (_isFmp4Format(lowerType)) { const bufferMode = lowerType.includes('fmp4-buffered'); const demuxer = new FMP4ToAACStream({ bufferMode }); @@ -1885,7 +2110,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) => { @@ -1902,13 +2129,15 @@ class StreamAudioResource extends BaseAudioResource { ? { prefetch: seekOpts.prefetch, baseFileStart: seekOpts.baseFileStart, - seekTimeSec: seekOpts.seekTimeSec + seekTimeSec: seekOpts.seekTimeSec, + state } - : {}); + : { state }); streams.push(demuxer); } const decoder = new AACDecoderStream({ - resamplingQuality: resamplingQuality + resamplingQuality: resamplingQuality, + state }); streams.push(decoder); this.pipes?.push(...streams.slice(1)); @@ -1919,8 +2148,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 +2188,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 +2208,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 +2286,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({ @@ -2078,9 +2322,11 @@ class StreamAudioResource extends BaseAudioResource { _setupEventHandlers(inputStream) { const forwardFinishBuffering = () => { if (!this._destroyed) { + this._finishBufferingEmitted = true; this.stream?.emit('finishBuffering'); } }; + this._forwardFinishBuffering = forwardFinishBuffering; inputStream.on('finishBuffering', forwardFinishBuffering); const wrappedSource = inputStream._sourceStream; wrappedSource?.on?.('finishBuffering', forwardFinishBuffering); @@ -2162,12 +2408,13 @@ 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) { case SupportedFormats.AAC: { const lowerType = type.toLowerCase(); + const state = { isAlac: false }; if (_isFmp4Format(lowerType)) { const bufferMode = lowerType.includes('fmp4-buffered'); streams.push(new FMP4ToAACStream({ bufferMode })); @@ -2175,14 +2422,17 @@ 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; } } else if (_isMp4Format(lowerType)) - streams.push(new MP4ToAACStream()); + streams.push(new MP4ToAACStream({ state })); streams.push(new AACDecoderStream({ - resamplingQuality: resamplingQuality + resamplingQuality: resamplingQuality, + state })); break; } @@ -2196,8 +2446,11 @@ export const createPCMStream = (_guildId, stream, type, nodelink, volume = 1.0, case SupportedFormats.MPEG: case SupportedFormats.FLAC: case SupportedFormats.OGG_VORBIS: - case SupportedFormats.WAV: { - streams.push(new SymphoniaDecoderStream()); + case SupportedFormats.WAV: + case SupportedFormats.ALAC: { + streams.push(new SymphoniaDecoderStream({ + codecRegistryHint: _getSymphoniaCodecHint(type) + })); break; } case SupportedFormats.OPUS: { diff --git a/dist/src/playback/structs/BufferPool.js b/dist/src/playback/structs/BufferPool.js index 0256b65d..fa8450a2 100644 --- a/dist/src/playback/structs/BufferPool.js +++ b/dist/src/playback/structs/BufferPool.js @@ -1,4 +1,4 @@ -import { logger } from "../../utils.js"; +import { logger } from '../../utils.js'; const parsePositiveIntEnv = (key, fallback) => { const raw = process.env[key]; if (!raw) diff --git a/dist/src/playback/structs/RingBuffer.js b/dist/src/playback/structs/RingBuffer.js index 59a565f0..0d095a6d 100644 --- a/dist/src/playback/structs/RingBuffer.js +++ b/dist/src/playback/structs/RingBuffer.js @@ -1,4 +1,4 @@ -import { bufferPool } from "./BufferPool.js"; +import { bufferPool } from './BufferPool.js'; /** * A fast, fixed-size circular buffer for audio chunks. * Uses BufferPool for memory management to reduce GC pressure. diff --git a/dist/src/server/bunServer.js b/dist/src/server/bunServer.js new file mode 100644 index 00000000..52d00f38 --- /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/amazonmusic.js b/dist/src/sources/amazonmusic.js index 7f916046..0246800c 100644 --- a/dist/src/sources/amazonmusic.js +++ b/dist/src/sources/amazonmusic.js @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { encodeTrack, getBestMatch, http1makeRequest, logger } from "../utils.js"; +import { encodeTrack, getBestMatch, http1makeRequest, logger } from '../utils.js'; /** * User agent string used for general browsing and scraping operations. * @internal @@ -191,13 +191,15 @@ export default class AmazonMusicSource { 'x-amzn-device-language': 'en_US', 'x-amzn-currency-of-preference': 'USD', 'x-amzn-os-version': '1.0', - 'x-amzn-application-version': '1.0.9172.0', + 'x-amzn-application-version': '1.0.10905.0', 'x-amzn-device-time-zone': 'America/New_York', 'x-amzn-timestamp': String(Date.now()), 'x-amzn-csrf': this.buildCsrfHeader(cfg.csrf), 'x-amzn-music-domain': 'music.amazon.com', 'x-amzn-page-url': pageUrl, - 'x-amzn-feature-flags': 'hd-supported,uhd-supported' + 'x-amzn-feature-flags': 'hd-supported,uhd-supported', + 'x-amnz-age-band': 'ADULT', + 'x-amzn-has-profile-id': 'true', }; } /** diff --git a/dist/src/sources/anghami.js b/dist/src/sources/anghami.js index df86951f..bfbcef9e 100644 --- a/dist/src/sources/anghami.js +++ b/dist/src/sources/anghami.js @@ -1,4 +1,4 @@ -import { encodeTrack, getBestMatch, logger, makeRequest } from "../utils.js"; +import { encodeTrack, getBestMatch, logger, makeRequest } from '../utils.js'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; const AnghamiSongBatchDecoder = { decode(buffer) { diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js index 2d47a772..72de3cac 100644 --- a/dist/src/sources/applemusic.js +++ b/dist/src/sources/applemusic.js @@ -1,4 +1,4 @@ -import { encodeTrack, getBestMatch, http1makeRequest } from "../utils.js"; +import { encodeTrack, getBestMatch, http1makeRequest } from '../utils.js'; /** * Apple Music source implementation. * Provides integration with the Apple Music Catalog API for track resolution and searching. @@ -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/audiomack.js b/dist/src/sources/audiomack.js index 32059489..dd1e84ca 100644 --- a/dist/src/sources/audiomack.js +++ b/dist/src/sources/audiomack.js @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import { PassThrough } from 'node:stream'; -import { encodeTrack, getBestMatch, http1makeRequest, logger } from "../utils.js"; +import { encodeTrack, getBestMatch, http1makeRequest, logger } from '../utils.js'; const AUDIOMACK_PATTERNS = [ /https?:\/\/(?:www\.)?audiomack\.com\/[^/]+\/song\/[^/]+(?:\?.*)?$/i, /https?:\/\/(?:www\.)?audiomack\.com\/[^/]+\/album\/[^/]+(?:\?.*)?$/i, diff --git a/dist/src/sources/audius.js b/dist/src/sources/audius.js index d510687c..49f108c3 100644 --- a/dist/src/sources/audius.js +++ b/dist/src/sources/audius.js @@ -1,4 +1,4 @@ -import { encodeTrack, http1makeRequest, logger } from "../utils.js"; +import { encodeTrack, http1makeRequest, logger } from '../utils.js'; /** * Base URL for Audius discovery nodes. * @internal @@ -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..e1f21527 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -1,14 +1,11 @@ -import { PassThrough } from 'node:stream'; -import { encodeTrack, logger, makeRequest } from "../utils.js"; +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 = /