From da3844c7987219a8d25bdf9f99dc970c02823901 Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 03:15:41 +0700 Subject: [PATCH 01/18] feat: Implement plugin system for NodeLink - Added PluginManager to manage plugins, including loading local and package plugins. - Introduced API for plugins to register routes, audio sources, and lyrics sources. - Implemented stream interceptors and before-play hooks for enhanced audio processing. - Created example and audio cache plugins to demonstrate plugin capabilities. - Updated source and lyrics managers to support dynamic plugin registration. - Enhanced documentation for plugin system and usage examples. --- README.md | 23 ++ docs/API.md | 7 + docs/plugins.md | 108 ++++++++ src/api/index.js | 22 ++ src/api/info.js | 23 +- src/api/plugins.js | 29 +++ src/api/routes.js | 65 +++++ src/index.js | 6 +- src/managers/lyricsManager.js | 9 +- src/managers/sourceManager.js | 86 +++---- src/playback/player.js | 7 + src/plugins/pluginManager.js | 203 +++++++++++++++ src/plugins/src/audio-cache-plugin.js | 340 ++++++++++++++++++++++++++ src/plugins/src/example-plugin.js | 14 ++ src/plugins/src/prebuffer-plugin.js | 120 +++++++++ src/worker.js | 5 +- 16 files changed, 1017 insertions(+), 50 deletions(-) create mode 100644 docs/plugins.md create mode 100644 src/api/plugins.js create mode 100644 src/api/routes.js create mode 100644 src/plugins/pluginManager.js create mode 100644 src/plugins/src/audio-cache-plugin.js create mode 100644 src/plugins/src/example-plugin.js create mode 100644 src/plugins/src/prebuffer-plugin.js diff --git a/README.md b/README.md index 7053183e..60ca0605 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,29 @@ Once started, NodeLink runs a Lavalink-compatible WebSocket server, ready for im --- +## Plugins + +NodeLink now supports a simple plugin system so you can add custom HTTP routes and register new sources or lyrics providers without modifying core files. + +- Local development: add files under `src/plugins/*.js`. +- Package plugins: install NPM packages named `nodelink-plugin-*`. + +See `docs/plugins.md` for the full API and examples. + +### Audio Cache Plugin Settings +- Configure in `config.js` under `plugins.audioCache`: +```js +plugins: { + audioCache: { + dir: 'cache/audio', // cache directory + ttlDays: 7, // days to keep inactive files + cleanupIntervalHours: 12 // periodic cleanup interval + } +} +``` + +--- + ## Usage NodeLink is compatible with most Lavalink clients, as it implements nearly the entire original API. diff --git a/docs/API.md b/docs/API.md index 30839fa4..be34426a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -370,3 +370,10 @@ A dynamic range compressor that reduces the volume of loud sounds or amplifies q | attack? | float | The attack time in milliseconds. | | release? | float | The release time in milliseconds. | | gain? | float | The makeup gain in dB. | + + +### Cache Plugin Endpoints + +- GET /v4/cache/stats: Returns cache file count, total bytes, optional maxBytes, and usagePercent if a cap is configured. +- POST /v4/cache/cleanup: Triggers TTL cleanup immediately. + diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..d10c0fa5 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,108 @@ +Plugin System + +Overview +- NodeLink exposes a lightweight plugin system so others can extend the server without changing core files. +- Plugins can: + - Register HTTP routes (static path or RegExp). + - Register custom audio sources. + - Register custom lyrics sources. + +Where Plugins Live +- Local (recommended for development): place files in `src/plugins/*.js`. + - Example: `src/plugins/example-plugin.js` is included and registers a `/v4/example-plugin/ping` route. +- Packages (publishable): install NPM packages whose name starts with `nodelink-plugin-` and they will be auto-loaded. + +Exports +- A plugin may export one of the following as its default export: + - `export default function (nodelink, api) { /* ... */ }` + - `export default { register(nodelink, api) { /* ... */ } }` + - `export default { init(nodelink, api) { /* ... */ } }` + +Plugin API +- `api.addRoute(pathOrRegex, handler, methods = ['GET'])` + - `pathOrRegex`: string like `/v4/your/route` or a `RegExp` to match dynamic paths. + - `handler(nodelink, req, res, sendResponse, parsedUrl)`: + - Use `sendResponse(req, res, data, status)` to reply JSON. +- `api.registerSource(name, instance)` + - Registers an audio source instance. The instance should follow the same interface as built-in sources: + - `setup(): Promise`, `search(query)`, `resolve(url)`, `loadStream(track, url, protocol, additionalData)`, etc. + - Optionally provide `searchTerms: string[]`, `patterns: RegExp[]`, and `priority: number`. +- `api.registerLyricsSource(name, instance)` + - Registers a lyrics provider with methods like `setup()` and `getLyrics(trackInfo)`. +- `api.registerStreamInterceptor(fn)` + - Intercepts audio stream loading. Useful for caching, metering, or transformation. + - Signature: `(nodelink, track, url, protocol, additionalData, next) => Readable|Promise` + - Call `await next()` to get the original stream, then return your wrapped stream. +- `api.registerBeforePlay(fn)` + - Runs right before playback starts for a track, after the server resolves the stream URL but before the audio pipeline is created. + - Signature: `(nodelink, player, context) => void|Promise` + - `player`: the Player instance; you can call `player.setFilters(...)` or read `player.volumePercent`. + - `context.info`: the decoded track info object. + - `context.urlData`: `{ url, protocol, format?, additionalData?, newTrack? }` as returned by the source. + - Use cases: enable filters (e.g., compressor), tag telemetry, or adjust internal state. Best practice: do not override user volume unless your plugin explicitly requires it. +- `api.logger(level, category?, message)` +- `api.config` – resolved server config. +- `api.version` – server version string. + +Route Matching Order +1) Built-in routes +2) Plugin static routes +3) Built-in dynamic routes +4) Plugin dynamic routes + +Example Plugin (Local) +```js +// src/plugins/my-plugin.js +export default async function (nodelink, api) { + api.addRoute('/v4/my-plugin/health', (server, req, res, sendResponse) => { + sendResponse(req, res, { status: 'ok' }, 200) + }) + + api.logger('info', 'Plugin', 'my-plugin loaded') +} +``` + +Audio Cache Example +```js +// src/plugins/audio-cache-plugin.js +// Intercepts streams and caches them on disk; see repository version for a full implementation. +export default async function (nodelink, api) { + api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { + const original = await next() + // Return original or a PassThrough tee’d into a file + return original + }) +} +``` + +Before-Play Hook Example (Normalization) +```js +// src/plugins/audio-normalizer-plugin.js +export default async function (nodelink, api) { + // Gentle compressor to even out loudness; do not change user volume + const compressor = { threshold: -18, ratio: 3, attack: 10, release: 120, gain: 6 } + + api.registerBeforePlay(async (_server, player, { info, urlData }) => { + // Enable compressor prior to pipeline creation + player.setFilters({ filters: { compressor } }) + // Respect the user's chosen volume: avoid calling player.volume(...) + }) +} +``` + +Package Plugin Skeleton +```js +// index.js in an npm package named: nodelink-plugin-hello +export default { + async register(nodelink, api) { + api.addRoute(/\/v4\/hello\/?.*/, (server, req, res, sendResponse, url) => { + sendResponse(req, res, { route: url.pathname }, 200) + }) + } +} +``` + +Notes +- Plugins load after built-in sources and lyrics in single-process mode and in worker processes. +- If a plugin registers sources/lyrics, it can be used immediately for requests. +- If you need more hooks, open an issue or contribute a PR to extend the plugin API. diff --git a/src/api/index.js b/src/api/index.js index ff3d55ca..2ec005d2 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -183,6 +183,17 @@ async function requestHandler(nodelink, req, res) { return } + // Check plugin-provided routes (static) + const pluginRoutes = nodelink?.pluginManager?.getRoutes?.() + if (pluginRoutes) { + const pStatic = pluginRoutes.static.get(parsedUrl.pathname) + if (pStatic) { + if (!verifyMethod(parsedUrl, req, res, pStatic.methods, clientAddress, trace)) return + pStatic.handler(nodelink, req, res, sendResponse, parsedUrl) + return + } + } + for (const [regex, route] of dynamicRoutes) { if (regex.test(parsedUrl.pathname)) { if ( @@ -194,6 +205,17 @@ async function requestHandler(nodelink, req, res) { } } + // Check plugin-provided dynamic routes + if (pluginRoutes) { + for (const [regex, route] of pluginRoutes.dynamic) { + if (regex.test(parsedUrl.pathname)) { + if (!verifyMethod(parsedUrl, req, res, route.methods, clientAddress, trace)) return + route.handler(nodelink, req, res, sendResponse, parsedUrl) + return + } + } + } + logger( 'warn', 'Request', diff --git a/src/api/info.js b/src/api/info.js index badead26..dcf18a5d 100644 --- a/src/api/info.js +++ b/src/api/info.js @@ -22,11 +22,26 @@ async function handler(nodelink, req, res, sendResponse) { sourceManagers: nodelink.workerManager ? nodelink.supportedSourcesCache || (nodelink.supportedSourcesCache = await nodelink.getSourcesFromWorker()) - : nodelink.sources?.sources - ? Array.from(nodelink.sources.sources.keys()) - : [], + : (nodelink.sources?.sources && Array.from(nodelink.sources.sources.keys())) || [], filters, - plugins: [] + plugins: (() => { + const pm = nodelink.pluginManager + // Prefer detailed list if available + if (pm && Array.isArray(pm.plugins)) { + return pm.plugins.map((p) => { + if (typeof p === 'string') return { name: p, version: 'unknown', description: 'unknown' } + return { + name: p?.name || 'unknown', + version: p?.version || 'unknown', + description: p?.description || 'unknown' + } + }) + } + if (pm && typeof pm.getPluginList === 'function') { + return pm.getPluginList().map((name) => ({ name, version: 'unknown', description: 'unknown' })) + } + return [] + })() } sendResponse(req, res, response, 200) } diff --git a/src/api/plugins.js b/src/api/plugins.js new file mode 100644 index 00000000..edac440f --- /dev/null +++ b/src/api/plugins.js @@ -0,0 +1,29 @@ +import { sendResponse } from '../utils.js' + +function handler(nodelink, req, res) { + const pm = nodelink.pluginManager + const names = pm?.getPluginList?.() || [] + const routes = pm?.getRoutes?.() + const pluginStatic = routes ? Array.from(routes.static.keys()) : [] + const pluginDynamic = routes ? routes.dynamic.map(([regex]) => String(regex)) : [] + + return sendResponse( + req, + res, + { + count: names.length, + plugins: names, + http: { + static: pluginStatic, + dynamic: pluginDynamic + } + }, + 200 + ) +} + +export default { + handler, + methods: ['GET'] +} + diff --git a/src/api/routes.js b/src/api/routes.js new file mode 100644 index 00000000..a0e7f918 --- /dev/null +++ b/src/api/routes.js @@ -0,0 +1,65 @@ +import fs from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { PATH_VERSION } from '../constants.js' +import { sendResponse } from '../utils.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +async function listBuiltinRoutes() { + const files = await fs.readdir(__dirname) + const builtin = { static: [], dynamic: [] } + + for (const file of files) { + if (file === 'index.js' || !file.endsWith('.js')) continue + const routeName = file.replace('.js', '').toLowerCase() + let pathnameOrRegex + + if (routeName === 'version') { + pathnameOrRegex = '/version' + } else if (routeName.includes('.')) { + const parts = routeName + .split('.') + .map((part) => (part === 'id' ? '(?:id|[A-Za-z0-9]+)' : part)) + .join('/') + pathnameOrRegex = new RegExp(`^/${PATH_VERSION}/${parts}(?:/[A-Za-z0-9]+)?/?$`) + } else { + pathnameOrRegex = `/${PATH_VERSION}/${routeName}` + } + + // Try to get methods from module, default to ['GET'] + let methods = ['GET'] + try { + const filePath = join(__dirname, file) + const mod = await import(pathToFileURL(filePath)) + const exported = mod?.default + if (exported?.methods && Array.isArray(exported.methods)) methods = exported.methods + } catch {} + + if (pathnameOrRegex instanceof RegExp) { + builtin.dynamic.push({ pattern: String(pathnameOrRegex), methods }) + } else { + builtin.static.push({ path: pathnameOrRegex, methods }) + } + } + + return builtin +} + +async function handler(nodelink, req, res) { + const builtin = await listBuiltinRoutes() + const pr = nodelink?.pluginManager?.getRoutes?.() + const plugins = { + static: pr ? Array.from(pr.static.entries()).map(([path, data]) => ({ path, methods: data.methods || ['GET'] })) : [], + dynamic: pr ? pr.dynamic.map(([regex, data]) => ({ pattern: String(regex), methods: data.methods || ['GET'] })) : [] + } + + return sendResponse(req, res, { builtin, plugins }, 200) +} + +export default { + handler, + methods: ['GET'] +} + diff --git a/src/index.js b/src/index.js index a3b2d156..5d42da04 100644 --- a/src/index.js +++ b/src/index.js @@ -27,6 +27,7 @@ import 'dotenv/config' import PlayerManager from './managers/playerManager.js' import RateLimitManager from './managers/rateLimitManager.js' import DosProtectionManager from './managers/dosProtectionManager.js' +import PluginManager from './plugins/pluginManager.js' let config @@ -97,6 +98,7 @@ class NodelinkServer { this.statsManager = new statsManager(this) this.rateLimitManager = new RateLimitManager(this) this.dosProtectionManager = new DosProtectionManager(this) + this.pluginManager = new PluginManager(this) this.version = getVersion() this.gitInfo = getGitInfo() this.statistics = { @@ -462,9 +464,11 @@ class NodelinkServer { if (!startOptions.isClusterPrimary) { await this.sources.loadFolder() - await this.lyrics.loadFolder() } + + // Load and initialize plugins (may register routes/sources/lyrics) + await this.pluginManager.loadAll() this._createServer() if (startOptions.isClusterWorker) { diff --git a/src/managers/lyricsManager.js b/src/managers/lyricsManager.js index 4e1bb2ce..5330ceab 100644 --- a/src/managers/lyricsManager.js +++ b/src/managers/lyricsManager.js @@ -9,6 +9,13 @@ export default class LyricsManager { this.lyricsSources = new Map() } + addLyricsSource(name, instance) { + if (!name || !instance) return false + this.lyricsSources.set(name, instance) + logger('info', 'Lyrics', `Registered lyrics source: ${name}`) + return true + } + async loadFolder() { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -43,7 +50,7 @@ export default class LyricsManager { const instance = new Mod(this.nodelink) if (await instance.setup()) { - this.lyricsSources.set(name, instance) + this.addLyricsSource(name, instance) logger('info', 'Lyrics', `Loaded lyrics source: ${name}`) } else { logger( diff --git a/src/managers/sourceManager.js b/src/managers/sourceManager.js index 013fe344..b0b7ed1e 100644 --- a/src/managers/sourceManager.js +++ b/src/managers/sourceManager.js @@ -11,6 +11,36 @@ export default class SourcesManager { this.patternMap = [] } + addSource(name, instance) { + if (!name || !instance) return false + this.sources.set(name, instance) + + if (Array.isArray(instance.searchTerms)) { + for (const term of instance.searchTerms) { + this.searchTermMap.set(term, name) + } + } + + if (Array.isArray(instance.patterns)) { + for (const regex of instance.patterns) { + if (regex instanceof RegExp) { + this.patternMap.push({ + regex, + sourceName: name, + priority: instance.priority || 0 + }) + } + } + } + this.patternMap.sort((a, b) => b.priority - a.priority) + logger( + 'info', + 'Sources', + `Registered source: ${name} ${instance.searchTerms?.length ? `(terms: ${instance.searchTerms.join(', ')})` : ''}` + ) + return true + } + async loadFolder() { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -43,30 +73,7 @@ export default class SourcesManager { const instance = new Mod(this.nodelink) if (await instance.setup()) { - this.sources.set(name, instance) - - if (Array.isArray(instance.searchTerms)) { - for (const term of instance.searchTerms) { - this.searchTermMap.set(term, name) - } - } - - if (Array.isArray(instance.patterns)) { - for (const regex of instance.patterns) { - if (regex instanceof RegExp) { - this.patternMap.push({ - regex, - sourceName: name, - priority: instance.priority || 0 - }) - } - } - } - logger( - 'info', - 'Sources', - `Loaded source: ${name} ${instance.searchTerms?.length ? `(terms: ${instance.searchTerms.join(', ')})` : ''}` - ) + this.addSource(name, instance) } else { logger( 'error', @@ -85,7 +92,7 @@ export default class SourcesManager { const instance = new Mod(this.nodelink) if (await instance.setup()) { - this.sources.set(name, instance) + this.addSource(name, instance) } else { logger( 'error', @@ -95,23 +102,6 @@ export default class SourcesManager { return } - if (Array.isArray(instance.searchTerms)) { - for (const term of instance.searchTerms) { - this.searchTermMap.set(term, name) - } - } - - if (Array.isArray(instance.patterns)) { - for (const regex of instance.patterns) { - if (regex instanceof RegExp) { - this.patternMap.push({ - regex, - sourceName: name, - priority: instance.priority || 0 - }) - } - } - } logger( 'info', 'Sources', @@ -251,7 +241,17 @@ export default class SourcesManager { async getTrackStream(track, url, protocol, additionalData) { const instance = this.sources.get(track.sourceName) - return await instance.loadStream(track, url, protocol, additionalData) + const finalHandler = () => instance.loadStream(track, url, protocol, additionalData) + if (this.nodelink?.pluginManager?.runStreamPipeline && this.nodelink.pluginManager.streamInterceptors.length > 0) { + return await this.nodelink.pluginManager.runStreamPipeline( + track, + url, + protocol, + additionalData, + finalHandler + ) + } + return await finalHandler() } getAllSources() { diff --git a/src/playback/player.js b/src/playback/player.js index 7a2fe623..0792b379 100644 --- a/src/playback/player.js +++ b/src/playback/player.js @@ -473,6 +473,13 @@ export class Player { return false } + try { + await this.nodelink?.pluginManager?.runBeforePlay?.(this, { + info, + urlData + }) + } catch {} + if (!this.connection) { this._initConnection() } diff --git a/src/plugins/pluginManager.js b/src/plugins/pluginManager.js new file mode 100644 index 00000000..df189305 --- /dev/null +++ b/src/plugins/pluginManager.js @@ -0,0 +1,203 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { logger } from '../utils.js' + +export default class PluginManager { + constructor(nodelink) { + this.nodelink = nodelink + this.plugins = [] + this.routes = { + static: new Map(), + dynamic: [] + } + this.streamInterceptors = [] + this.beforePlayHooks = [] + } + + addRoute(pathnameOrRegex, handler, methods = ['GET']) { + const routeData = { handler, methods } + if (pathnameOrRegex instanceof RegExp) { + this.routes.dynamic.push([pathnameOrRegex, routeData]) + } else if (typeof pathnameOrRegex === 'string') { + this.routes.static.set(pathnameOrRegex, routeData) + } else { + throw new Error('addRoute requires a string path or RegExp') + } + } + + getRoutes() { + return this.routes + } + + getPluginList() { + return this.plugins.map((p) => p.name) + } + + registerStreamInterceptor(fn) { + if (typeof fn !== 'function') throw new Error('Stream interceptor must be a function') + this.streamInterceptors.push(fn) + } + + registerBeforePlay(fn) { + if (typeof fn !== 'function') throw new Error('Before-play hook must be a function') + this.beforePlayHooks.push(fn) + } + + async runStreamPipeline(track, url, protocol, additionalData, finalHandler) { + let idx = -1 + const dispatch = async (i) => { + if (i <= idx) throw new Error('next() called multiple times') + idx = i + const interceptor = this.streamInterceptors[i] + if (interceptor) { + return interceptor( + this.nodelink, + track, + url, + protocol, + additionalData, + () => dispatch(i + 1) + ) + } + return finalHandler() + } + return dispatch(0) + } + + async runBeforePlay(player, context) { + for (const hook of this.beforePlayHooks) { + try { + await hook(this.nodelink, player, context) + } catch (e) { + logger('warn', 'Plugin', `beforePlay hook error: ${e.message}`) + } + } + } + + async loadAll() { + await this.#loadLocalPlugins() + await this.#loadPackagePlugins() + } + + async #loadLocalPlugins() { + try { + const __filename = fileURLToPath(import.meta.url) + const __dirname = path.dirname(__filename) + const pluginsDir = path.join(__dirname) // src/plugins + const pluginsSrcDir = path.join(pluginsDir, 'src') // src/plugins/src + + // Only search for plugin files in src/plugins/src/ as requested. + const pluginFiles = [] + try { + const files = await fs.readdir(pluginsSrcDir) + const jsFiles = files.filter((f) => f.endsWith('.js') && f !== 'pluginManager.js') + for (const f of jsFiles) { + pluginFiles.push(path.join(pluginsSrcDir, f)) + } + } catch (err) { + logger('debug', 'Plugin', `Skipping plugin dir '${pluginsSrcDir}': ${err.message}`) + } + + for (const filePath of pluginFiles) { + await this.#loadPluginModule(filePath) + } + } catch (e) { + logger('warn', 'Plugin', `Failed to load local plugins: ${e.message}`) + } + } + + async #loadPackagePlugins() { + // Discover installed packages that look like nodelink plugins + let pkgJson + try { + const pkgUrl = pathToFileURL(path.resolve(process.cwd(), 'package.json')) + pkgJson = (await import(pkgUrl)).default + } catch (e) { + logger('debug', 'Plugin', 'No package.json found for plugin discovery') + return + } + + const deps = { + ...(pkgJson.dependencies || {}), + ...(pkgJson.devDependencies || {}) + } + + const pluginNames = Object.keys(deps).filter((name) => + name.toLowerCase().startsWith('nodelink-plugin-') + ) + + for (const name of pluginNames) { + try { + const mod = await import(name) + await this.#initializePluginModule(mod, name) + } catch (e) { + logger( + 'error', + 'Plugin', + `Failed to load package plugin '${name}': ${e.message}` + ) + } + } + } + + async #loadPluginModule(filePath) { + try { + const fileUrl = pathToFileURL(filePath) + const mod = await import(fileUrl) + const name = path.basename(filePath) + await this.#initializePluginModule(mod, name) + } catch (e) { + logger('error', 'Plugin', `Failed to load plugin '${filePath}': ${e.message}`) + } + } + + async #initializePluginModule(mod, name = 'unknown') { + const plugin = mod?.default || mod + if (!plugin) { + logger('warn', 'Plugin', `Plugin '${name}' has no default export`) + return + } + + const api = this.#buildApi() + try { + if (typeof plugin === 'function') { + await plugin(this.nodelink, api) + } else if (plugin && typeof plugin.register === 'function') { + await plugin.register(this.nodelink, api) + } else if (plugin && typeof plugin.init === 'function') { + await plugin.init(this.nodelink, api) + } else { + logger( + 'warn', + 'Plugin', + `Plugin '${name}' does not export a function or {register|init}` + ) + return + } + this.plugins.push({ name }) + logger('info', 'Plugin', `Loaded plugin: ${name}`) + } catch (e) { + logger('error', 'Plugin', `Plugin '${name}' initialization failed: ${e.message}`) + } + } + + #buildApi() { + return { + // HTTP routes + addRoute: (pathnameOrRegex, handler, methods) => + this.addRoute(pathnameOrRegex, handler, methods), + // Sources/Lyrics registration helpers + registerSource: (name, instance) => + this.nodelink?.sources?.addSource?.(name, instance), + registerLyricsSource: (name, instance) => + this.nodelink?.lyrics?.addLyricsSource?.(name, instance), + registerStreamInterceptor: (fn) => this.registerStreamInterceptor(fn), + registerBeforePlay: (fn) => this.registerBeforePlay(fn), + // Utilities + logger, + config: this.nodelink?.options, + version: this.nodelink?.version + } + } +} diff --git a/src/plugins/src/audio-cache-plugin.js b/src/plugins/src/audio-cache-plugin.js new file mode 100644 index 00000000..0cee5833 --- /dev/null +++ b/src/plugins/src/audio-cache-plugin.js @@ -0,0 +1,340 @@ +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import path from 'node:path' +import crypto from 'node:crypto' +import { PassThrough } from 'node:stream' + +function sha1(input) { + return crypto.createHash('sha1').update(String(input)).digest('hex') +} + +async function ensureDir(dir) { + await fsp.mkdir(dir, { recursive: true }).catch(() => { }) +} + +function nowMs() { + return Date.now() +} + +export default async function audioCachePlugin(nodelink, api) { + const cfg = api.config?.plugins?.audioCache || {} + const cacheDir = path.resolve(process.cwd(), cfg.dir || path.join('cache', 'audio')) + const ttlDays = Number.isFinite(cfg.ttlDays) ? cfg.ttlDays : 7 + const cleanupIntervalHours = Number.isFinite(cfg.cleanupIntervalHours) + ? cfg.cleanupIntervalHours + : 12 + + // Optional maximum cache size (bytes). Accepts number or string like '10GB'. + const maxSizeBytes = (() => { + const v = cfg.maxSizeBytes ?? cfg.maxSize + if (v == null) return null + if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v + if (typeof v === 'string') { + const m = v.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i) + if (m) { + const num = parseFloat(m[1]) + const unit = (m[2] || 'b').toLowerCase() + const mul = unit === 'tb' ? 1024 ** 4 : unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1 + return Math.max(0, Math.floor(num * mul)) + } + } + return null + })() + + // Optional: protect recently used files from eviction (minutes) + const protectRecentMs = (() => { + const v = cfg.protectRecentMinutes + if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v * 60 * 1000) + return 0 + })() + + // Optional: if all files are in-use or recently used, allow exceeding the cap + const allowExceedWhenAllRecent = Boolean(cfg.allowExceedWhenAllRecent) + + await ensureDir(cacheDir) + + const inUse = new Map() + + function keyFor(track, url) { + const t = track && typeof track === 'object' ? (track.info || track) : {} + const idCandidate = t.identifier || t.uri || url || JSON.stringify(t) + const source = t.sourceName || track?.sourceName || 'unknown' + return `${source}-${sha1(String(idCandidate))}` + } + + function filePathForKey(key) { + const shard = key.slice(0, 2) + return path.join(cacheDir, shard, `${key}.dat`) + } + + async function exists(p) { + try { + await fsp.access(p) + return true + } catch { + return false + } + } + + async function existsNonEmpty(p) { + try { + const st = await fsp.stat(p) + return st.isFile() && st.size > 0 + } catch { + return false + } + } + + function markUse(p, delta) { + const cur = inUse.get(p) || 0 + const next = cur + delta + if (next <= 0) inUse.delete(p) + else inUse.set(p, next) + } + + async function touch(p) { + const now = new Date() + try { + await fsp.utimes(p, now, now) + } catch { } + } + + async function getStats() { + let files = 0 + let bytes = 0 + async function walk(dir) { + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + const p = path.join(dir, e.name) + if (e.isDirectory()) await walk(p) + else if (e.isFile()) { + files++ + try { + const st = await fsp.stat(p) + bytes += st.size + } catch { } + } + } + } + await walk(cacheDir) + return { files, bytes, maxBytes: maxSizeBytes } + } + + async function cleanup() { + const ttlMs = ttlDays * 24 * 60 * 60 * 1000 + const cutoff = nowMs() - ttlMs + let removed = 0 + let bytesFreed = 0 + async function walk(dir) { + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + const p = path.join(dir, e.name) + if (e.isDirectory()) { + await walk(p) + try { + const left = await fsp.readdir(p) + if (left.length === 0) await fsp.rmdir(p) + } catch { } + } else if (e.isFile()) { + try { + const st = await fsp.stat(p) + const atime = st.atimeMs || st.mtimeMs || st.ctimeMs + if (atime < cutoff && !inUse.has(p)) { + await fsp.unlink(p) + removed++ + bytesFreed += st.size || 0 + } + } catch { } + } + } + } + await walk(cacheDir) + api.logger('info', 'Plugin-Cache', `Cleanup finished. Removed ${removed} files, freed ${bytesFreed} bytes`) + return { removed, bytesFreed } + } + + async function currentFilesWithStats() { + const out = [] + async function walk(dir) { + let entries + try { + entries = await fsp.readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + const p = path.join(dir, e.name) + if (e.isDirectory()) await walk(p) + else if (e.isFile()) { + try { + const st = await fsp.stat(p) + out.push({ path: p, size: st.size || 0, atime: st.atimeMs || st.mtimeMs || st.ctimeMs }) + } catch { } + } + } + } + await walk(cacheDir) + return out + } + + async function trimToMax(reserveBytes = 0) { + if (!maxSizeBytes || maxSizeBytes <= 0) return { removed: 0, bytesFreed: 0 } + let files = await currentFilesWithStats() + let total = files.reduce((acc, f) => acc + f.size, 0) + const target = Math.max(0, maxSizeBytes - reserveBytes) + if (total <= target) return { removed: 0, bytesFreed: 0 } + + // Build eviction candidates honoring in-use and recent-protection rules + const now = nowMs() + const notInUse = files.filter(f => !inUse.has(f.path)) + let candidates = notInUse + if (protectRecentMs > 0) { + candidates = candidates.filter(f => (f.atime || 0) < (now - protectRecentMs)) + } + + if (candidates.length === 0) { + if (notInUse.length === 0) { + api.logger('info', 'Plugin-Cache', `Trim skipped: all files in use; allowing overflow (total=${total}, target=${target})`) + return { removed: 0, bytesFreed: 0 } + } + if (allowExceedWhenAllRecent) { + api.logger('info', 'Plugin-Cache', `Trim skipped: all files are recent; allowing overflow (total=${total}, target=${target})`) + return { removed: 0, bytesFreed: 0 } + } + // Enforce cap by evicting least-recent not-in-use files (even if within protect window) + files = notInUse + } else { + files = candidates + } + + files.sort((a, b) => (a.atime || 0) - (b.atime || 0)) // oldest first + let removed = 0 + let bytesFreed = 0 + for (const f of files) { + if (total <= target) break + if (inUse.has(f.path)) continue + try { + await fsp.unlink(f.path) + removed++ + total -= f.size + bytesFreed += f.size + } catch { } + } + api.logger('info', 'Plugin-Cache', `Trimmed cache: removed ${removed}, freed ${bytesFreed} bytes, total≈${total}`) + return { removed, bytesFreed } + } + + api.addRoute('/v4/cache/stats', async (server, req, res, sendResponse) => { + const s = await getStats() + const cap = s.maxBytes || null + const usage = cap ? Math.min(100, Math.round((s.bytes / cap) * 100)) : null + sendResponse(req, res, { ok: true, files: s.files, bytes: s.bytes, maxBytes: cap, usagePercent: usage }, 200) + }) + + api.addRoute('/v4/cache/cleanup', async (server, req, res, sendResponse) => { + const result = await cleanup() + sendResponse(req, res, { ok: true, ...result }, 200) + }, ['POST']) + + const intervalMs = cleanupIntervalHours * 60 * 60 * 1000 + const timer = setInterval(() => { + cleanup() + .then(() => trimToMax()) + .catch(() => { }) + }, intervalMs) + timer.unref?.() + + api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { + const key = keyFor(track, url) + const filePath = filePathForKey(key) + await ensureDir(path.dirname(filePath)) + + if (await existsNonEmpty(filePath)) { + api.logger('info', 'Plugin-Cache', `HIT key=${key} file=${filePath}`) + await touch(filePath) + markUse(filePath, 1) + const rs = fs.createReadStream(filePath) + rs.on('close', () => markUse(filePath, -1)) + rs.once('end', () => rs.emit('finishBuffering')) + return { stream: rs } + } + + let original + try { + // Try to enforce cap before starting a new write + await trimToMax() + original = await next() + } catch (e) { + throw e + } + + try { + api.logger('info', 'Plugin-Cache', `MISS key=${key} -> caching to ${filePath}`) + try { + const st0 = await fsp.stat(filePath) + if (!st0 || st0.size === 0) await fsp.unlink(filePath).catch(() => { }) + } catch { } + const tee = new PassThrough({ highWaterMark: 1 << 20 }) + const ws = fs.createWriteStream(filePath) + let completed = false + + const origStream = original?.stream || original + origStream.pipe(tee) + tee.pipe(ws) + + origStream.on?.('finishBuffering', () => { + tee.emit('finishBuffering') + }) + + tee.on('close', async () => { + try { + const origStream = original?.stream || original + origStream.destroy?.() + } catch { } + try { + if (!completed) { + const st = await fsp.stat(filePath).catch(() => null) + if (!st || st.size === 0) await fsp.unlink(filePath).catch(() => { }) + } + } catch { } + }) + + const cleanupOnError = (err) => { + try { ws.destroy() } catch { } + fsp.unlink(filePath).catch(() => { }) + if (!tee.destroyed && err) tee.destroy(err) + } + + origStream.on('error', cleanupOnError) + tee.on('error', cleanupOnError) + ws.on('error', cleanupOnError) + + ws.on('finish', async () => { + completed = true + touch(filePath).catch(() => { }) + }) + ws.on('close', async () => { + if (!completed) { + const st = await fsp.stat(filePath).catch(() => null) + if (!st || st.size === 0) await fsp.unlink(filePath).catch(() => { }) + } + }) + + return { stream: tee, type: original?.type } + } catch (e) { + return original + } + }) + + api.logger('info', 'Plugin-Cache', `audio-cache-plugin initialized at ${cacheDir} (ttlDays=${ttlDays})`) +} diff --git a/src/plugins/src/example-plugin.js b/src/plugins/src/example-plugin.js new file mode 100644 index 00000000..59857f9a --- /dev/null +++ b/src/plugins/src/example-plugin.js @@ -0,0 +1,14 @@ +// Example plugin demonstrating the public API +// Export either a default function (nodelink, api) => void +// or an object with register(nodelink, api) + +export default async function examplePlugin(nodelink, api) { + // Add a simple GET route: /v4/example-plugin/ping + api.addRoute(`/v4/example-plugin/ping`, (server, req, res, sendResponse) => { + sendResponse(req, res, { ok: true, plugin: 'example-plugin', pid: process.pid }, 200) + }, ['GET']) + + // Log on load + api.logger('info', 'Plugin', 'example-plugin initialized') +} + diff --git a/src/plugins/src/prebuffer-plugin.js b/src/plugins/src/prebuffer-plugin.js new file mode 100644 index 00000000..112421b5 --- /dev/null +++ b/src/plugins/src/prebuffer-plugin.js @@ -0,0 +1,120 @@ +import { PassThrough } from 'node:stream' + +function parseSize(input, fallback = 0) { + if (input == null) return fallback + if (typeof input === 'number' && Number.isFinite(input)) return Math.max(0, input | 0) + if (typeof input === 'string') { + const m = input.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i) + if (m) { + const num = parseFloat(m[1]) + const unit = (m[2] || 'b').toLowerCase() + const mul = unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1 + return Math.max(0, Math.floor(num * mul)) + } + } + return fallback +} + +export default async function prebufferPlugin(nodelink, api) { + const cfg = api.config?.plugins?.prebuffer || {} + if (!cfg.enabled) { + api.logger('info', 'Plugin-Prebuffer', 'Disabled by config') + return + } + + const targetBytes = parseSize(cfg.bytes, 512 * 1024) + const timeoutMs = typeof cfg.timeoutMs === 'number' && cfg.timeoutMs > 0 ? Math.floor(cfg.timeoutMs) : 0 + const highWaterMark = parseSize(cfg.highWaterMark, 1 << 20) || (1 << 20) + + api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { + // Skip obvious live/continuous streams to avoid extra latency + const isLive = Boolean(track?.info?.isStream) + if (isLive || targetBytes <= 0) { + return next() + } + + const original = await next() + const input = original?.stream || original + if (!input || typeof input.on !== 'function') return original + + let released = false + let bufferedBytes = 0 + let timer = null + const bufferChunks = [] + + const out = new PassThrough({ highWaterMark }) + + const release = () => { + if (released) return + released = true + try { + if (timer) { clearTimeout(timer); timer = null } + for (const chunk of bufferChunks) { + if (!out.destroyed) out.write(chunk) + } + } finally { + bufferChunks.length = 0 + } + } + + const startTimerIfNeeded = () => { + if (timeoutMs > 0 && !timer) { + timer = setTimeout(() => { + release() + }, timeoutMs) + timer.unref?.() + } + } + + // Wire events + input.on('data', (chunk) => { + if (released) { + out.write(chunk) + return + } + bufferChunks.push(chunk) + bufferedBytes += chunk.length || 0 + if (bufferedBytes >= targetBytes) { + release() + } else { + startTimerIfNeeded() + } + }) + + input.on('end', () => { + // Flush anything we have and close + release() + out.end() + out.emit('finishBuffering') + }) + + input.on('close', () => { + // Make sure to flush and end if closed before end + release() + out.end() + }) + + input.on('error', (err) => { + // Propagate error + if (!out.destroyed) out.emit('error', err) + }) + + // Forward finishBuffering marker from upstream if any + input.on?.('finishBuffering', () => { + // If upstream indicates done buffering, ensure we release + release() + out.emit('finishBuffering') + }) + + // If consumer destroys our out stream, stop reading + out.on('close', () => { + try { input.destroy?.() } catch {} + }) + + api.logger('debug', 'Plugin-Prebuffer', `Prebuffering up to ${targetBytes} bytes${timeoutMs ? ` or ${timeoutMs}ms` : ''} (hwm=${highWaterMark})`) + return { stream: out, type: original?.type } + }) + + api.logger('info', 'Plugin-Prebuffer', `Initialized (bytes=${targetBytes}, timeoutMs=${timeoutMs}, hwm=${highWaterMark})`) +} + diff --git a/src/worker.js b/src/worker.js index 2a9d030e..1a490559 100644 --- a/src/worker.js +++ b/src/worker.js @@ -5,13 +5,14 @@ import RoutePlannerManager from './managers/routePlannerManager.js' import SourceManager from './managers/sourceManager.js' import StatsManager from './managers/statsManager.js' import { Player } from './playback/player.js' +import PluginManager from './plugins/pluginManager.js' import { initLogger, logger } from './utils.js' let config try { config = (await import('../config.js')).default } catch { - config = (await import('../config.default.js')).default + config = (await import('../config.js')).default } initLogger(config) @@ -28,10 +29,12 @@ nodelink.sources = new SourceManager(nodelink) nodelink.lyrics = new LyricsManager(nodelink) nodelink.routePlanner = new RoutePlannerManager(nodelink) nodelink.connectionManager = new ConnectionManager(nodelink) +nodelink.pluginManager = new PluginManager(nodelink) async function initialize() { await nodelink.sources.loadFolder() await nodelink.lyrics.loadFolder() + await nodelink.pluginManager.loadAll() logger( 'info', 'Worker', From c0aa14179457d26f81c70e74a3c56ae63090c3a3 Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 14:54:15 +0700 Subject: [PATCH 02/18] feat: enhance plugin metadata extraction and initialization process --- src/plugins/pluginManager.js | 48 +++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/plugins/pluginManager.js b/src/plugins/pluginManager.js index df189305..c8f70c34 100644 --- a/src/plugins/pluginManager.js +++ b/src/plugins/pluginManager.js @@ -34,6 +34,10 @@ export default class PluginManager { return this.plugins.map((p) => p.name) } + getPlugins() { + return this.plugins.slice() + } + registerStreamInterceptor(fn) { if (typeof fn !== 'function') throw new Error('Stream interceptor must be a function') this.streamInterceptors.push(fn) @@ -84,10 +88,9 @@ export default class PluginManager { try { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) - const pluginsDir = path.join(__dirname) // src/plugins - const pluginsSrcDir = path.join(pluginsDir, 'src') // src/plugins/src + const pluginsDir = path.join(__dirname) + const pluginsSrcDir = path.join(pluginsDir, 'src') - // Only search for plugin files in src/plugins/src/ as requested. const pluginFiles = [] try { const files = await fs.readdir(pluginsSrcDir) @@ -175,13 +178,50 @@ export default class PluginManager { ) return } - this.plugins.push({ name }) + const meta = this.#extractMetadata(mod, plugin, name) + this.plugins.push(meta) logger('info', 'Plugin', `Loaded plugin: ${name}`) } catch (e) { logger('error', 'Plugin', `Plugin '${name}' initialization failed: ${e.message}`) } } + #extractMetadata(mod, plugin, fallbackName) { + const tryMeta = (obj) => { + if (!obj || typeof obj !== 'object') return null + const { name, description, version } = obj + if (!(name || description || version)) return null + return { + name: typeof name === 'string' && name.trim() ? name.trim() : undefined, + description: + typeof description === 'string' && description.trim() + ? description.trim() + : undefined, + version: + typeof version === 'string' && version.trim() ? version.trim() : undefined + } + } + + let meta = null + if (typeof plugin === 'function') { + meta = tryMeta(plugin.pluginInfo) || tryMeta(plugin.meta) || null + } + meta = meta || tryMeta(mod?.pluginInfo) || tryMeta(mod?.meta) || null + if (!meta && plugin && typeof plugin === 'object' && (plugin.name || plugin.description || plugin.version)) { + meta = tryMeta(plugin) + } + + return { + name: meta?.name || String(fallbackName), + description: meta?.description || 'unknown', + version: meta?.version || 'unknown' + } + } + + async initialize(mod, name = 'unknown') { + return this.#initializePluginModule(mod, name) + } + #buildApi() { return { // HTTP routes From c4323e9b5b2349f798ae0fed11e487efc5bafb3f Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 14:54:31 +0700 Subject: [PATCH 03/18] feat: add plugin metadata for audio-cache, example, and prebuffer plugins --- src/plugins/src/audio-cache-plugin.js | 16 ++++++++++------ src/plugins/src/example-plugin.js | 8 ++++++++ src/plugins/src/prebuffer-plugin.js | 16 ++++++++-------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/plugins/src/audio-cache-plugin.js b/src/plugins/src/audio-cache-plugin.js index 0cee5833..c8b920be 100644 --- a/src/plugins/src/audio-cache-plugin.js +++ b/src/plugins/src/audio-cache-plugin.js @@ -16,6 +16,12 @@ function nowMs() { return Date.now() } +export const pluginInfo = { + name: 'audio-cache', + description: 'Caches audio streams to disk with TTL and size limits', + version: '1.0.0' +} + export default async function audioCachePlugin(nodelink, api) { const cfg = api.config?.plugins?.audioCache || {} const cacheDir = path.resolve(process.cwd(), cfg.dir || path.join('cache', 'audio')) @@ -24,7 +30,6 @@ export default async function audioCachePlugin(nodelink, api) { ? cfg.cleanupIntervalHours : 12 - // Optional maximum cache size (bytes). Accepts number or string like '10GB'. const maxSizeBytes = (() => { const v = cfg.maxSizeBytes ?? cfg.maxSize if (v == null) return null @@ -41,14 +46,12 @@ export default async function audioCachePlugin(nodelink, api) { return null })() - // Optional: protect recently used files from eviction (minutes) const protectRecentMs = (() => { const v = cfg.protectRecentMinutes if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v * 60 * 1000) return 0 })() - // Optional: if all files are in-use or recently used, allow exceeding the cap const allowExceedWhenAllRecent = Boolean(cfg.allowExceedWhenAllRecent) await ensureDir(cacheDir) @@ -211,13 +214,12 @@ export default async function audioCachePlugin(nodelink, api) { api.logger('info', 'Plugin-Cache', `Trim skipped: all files are recent; allowing overflow (total=${total}, target=${target})`) return { removed: 0, bytesFreed: 0 } } - // Enforce cap by evicting least-recent not-in-use files (even if within protect window) files = notInUse } else { files = candidates } - files.sort((a, b) => (a.atime || 0) - (b.atime || 0)) // oldest first + files.sort((a, b) => (a.atime || 0) - (b.atime || 0)) let removed = 0 let bytesFreed = 0 for (const f of files) { @@ -271,7 +273,6 @@ export default async function audioCachePlugin(nodelink, api) { let original try { - // Try to enforce cap before starting a new write await trimToMax() original = await next() } catch (e) { @@ -338,3 +339,6 @@ export default async function audioCachePlugin(nodelink, api) { api.logger('info', 'Plugin-Cache', `audio-cache-plugin initialized at ${cacheDir} (ttlDays=${ttlDays})`) } + +// Attach metadata for discovery +audioCachePlugin.pluginInfo = pluginInfo diff --git a/src/plugins/src/example-plugin.js b/src/plugins/src/example-plugin.js index 59857f9a..b0eb771c 100644 --- a/src/plugins/src/example-plugin.js +++ b/src/plugins/src/example-plugin.js @@ -2,6 +2,12 @@ // Export either a default function (nodelink, api) => void // or an object with register(nodelink, api) +export const pluginInfo = { + name: 'example-plugin', + description: 'Example plugin demonstrating the public API', + version: '1.0.0' +} + export default async function examplePlugin(nodelink, api) { // Add a simple GET route: /v4/example-plugin/ping api.addRoute(`/v4/example-plugin/ping`, (server, req, res, sendResponse) => { @@ -12,3 +18,5 @@ export default async function examplePlugin(nodelink, api) { api.logger('info', 'Plugin', 'example-plugin initialized') } +// Also attach metadata to the default export for discovery +examplePlugin.pluginInfo = pluginInfo diff --git a/src/plugins/src/prebuffer-plugin.js b/src/plugins/src/prebuffer-plugin.js index 112421b5..feeb3cf8 100644 --- a/src/plugins/src/prebuffer-plugin.js +++ b/src/plugins/src/prebuffer-plugin.js @@ -15,6 +15,12 @@ function parseSize(input, fallback = 0) { return fallback } +export const pluginInfo = { + name: 'prebuffer', + description: 'Buffers initial bytes of non-live streams for smoother start', + version: '1.0.0' +} + export default async function prebufferPlugin(nodelink, api) { const cfg = api.config?.plugins?.prebuffer || {} if (!cfg.enabled) { @@ -27,7 +33,6 @@ export default async function prebufferPlugin(nodelink, api) { const highWaterMark = parseSize(cfg.highWaterMark, 1 << 20) || (1 << 20) api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { - // Skip obvious live/continuous streams to avoid extra latency const isLive = Boolean(track?.info?.isStream) if (isLive || targetBytes <= 0) { return next() @@ -66,7 +71,6 @@ export default async function prebufferPlugin(nodelink, api) { } } - // Wire events input.on('data', (chunk) => { if (released) { out.write(chunk) @@ -82,31 +86,25 @@ export default async function prebufferPlugin(nodelink, api) { }) input.on('end', () => { - // Flush anything we have and close release() out.end() out.emit('finishBuffering') }) input.on('close', () => { - // Make sure to flush and end if closed before end release() out.end() }) input.on('error', (err) => { - // Propagate error if (!out.destroyed) out.emit('error', err) }) - // Forward finishBuffering marker from upstream if any input.on?.('finishBuffering', () => { - // If upstream indicates done buffering, ensure we release release() out.emit('finishBuffering') }) - // If consumer destroys our out stream, stop reading out.on('close', () => { try { input.destroy?.() } catch {} }) @@ -118,3 +116,5 @@ export default async function prebufferPlugin(nodelink, api) { api.logger('info', 'Plugin-Prebuffer', `Initialized (bytes=${targetBytes}, timeoutMs=${timeoutMs}, hwm=${highWaterMark})`) } +// Attach metadata for discovery +prebufferPlugin.pluginInfo = pluginInfo From c582967a6cfc60ab268f57f5cab6a2be67624843 Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 14:54:37 +0700 Subject: [PATCH 04/18] feat: add audioCache and prebuffer plugin configurations to default settings --- config.default.js | 23 +++++++++++++++++++++++ package.json | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/config.default.js b/config.default.js index 0a26297c..463b2201 100644 --- a/config.default.js +++ b/config.default.js @@ -203,5 +203,28 @@ export default { delayMs: 500, blockDurationMs: 300000 // 5 minutes } + }, + plugins: { + audioCache: { + dir: 'cache/audio', + ttlDays: 7, + cleanupIntervalHours: 12, + maxSize: '1GB', // or maxSizeBytes: 10 * 1024 * 1024 * 1024 + // Do not evict files accessed within this window (minutes) + protectRecentMinutes: 60, + // If all files are in-use or recently used, allow exceeding max size + allowExceedWhenAllRecent: true + }, + // Start playback only after some data has buffered. + // Applies to non-live streams. Uses compressed bytes (source stream) as reference. + prebuffer: { + enabled: true, + // Buffer target before releasing data downstream. Accepts number (bytes) or string: '512KB', '1MB', etc. + bytes: '512KB', + // Max time to wait before starting even if bytes not reached (ms). Set 0 to disable. + timeoutMs: 2000, + // Internal stream highWaterMark for the output wrapper + highWaterMark: '1MB' + } } } diff --git a/package.json b/package.json index 6b0a97b7..11ac66e2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "nodelink", "version": "3.0.0", "scripts": { - "start": "node --dns-result-order=ipv4first --openssl-legacy-provider src/index.js" + "start": "node --dns-result-order=ipv4first --openssl-legacy-provider src/index.js", + "test": "node --test" }, "type": "module", "main": "src/index.js", From cdfe3483fbecd0ec121278cc2675a452fd9e56a4 Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 14:58:37 +0700 Subject: [PATCH 05/18] feat: update plugin documentation with metadata and example enhancements --- docs/plugins.md | 55 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index d10c0fa5..e417b403 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -6,10 +6,11 @@ Overview - Register HTTP routes (static path or RegExp). - Register custom audio sources. - Register custom lyrics sources. + - Intercept audio streams or run hooks before playback. Where Plugins Live -- Local (recommended for development): place files in `src/plugins/*.js`. - - Example: `src/plugins/example-plugin.js` is included and registers a `/v4/example-plugin/ping` route. +- Local (recommended for development): place files in `src/plugins/src/*.js`. + - Example: `src/plugins/src/example-plugin.js` is included and registers a `/v4/example-plugin/ping` route. - Packages (publishable): install NPM packages whose name starts with `nodelink-plugin-` and they will be auto-loaded. Exports @@ -18,6 +19,31 @@ Exports - `export default { register(nodelink, api) { /* ... */ } }` - `export default { init(nodelink, api) { /* ... */ } }` +Metadata (name, description, version) +- Plugins can provide metadata that appears in `/v4/info` under `plugins`. +- Provide it in one of these ways (in order of precedence): + - Attach to the default function: `myPlugin.pluginInfo = { name, description, version }` + - Export a named value: `export const pluginInfo = { name, description, version }` + - Export an object with fields: `export default { name, description, version, register() { ... } }` + +Example with function export: +```js +export const pluginInfo = { + name: 'my-plugin', + description: 'Short summary of what it does', + version: '1.2.3' +} + +export default async function myPlugin(nodelink, api) { + api.addRoute('/v4/my-plugin/health', (server, req, res, sendResponse) => { + sendResponse(req, res, { status: 'ok' }, 200) + }) +} + +// Optional: also attach to the function (supported as well) +myPlugin.pluginInfo = pluginInfo +``` + Plugin API - `api.addRoute(pathOrRegex, handler, methods = ['GET'])` - `pathOrRegex`: string like `/v4/your/route` or a `RegExp` to match dynamic paths. @@ -31,8 +57,9 @@ Plugin API - Registers a lyrics provider with methods like `setup()` and `getLyrics(trackInfo)`. - `api.registerStreamInterceptor(fn)` - Intercepts audio stream loading. Useful for caching, metering, or transformation. - - Signature: `(nodelink, track, url, protocol, additionalData, next) => Readable|Promise` - - Call `await next()` to get the original stream, then return your wrapped stream. + - Signature: `(nodelink, track, url, protocol, additionalData, next) => (Readable|{ stream: Readable, type?: string }) | Promise<...>` + - Call `await next()` to get the original stream (or `{ stream, type }`), then return your wrapped stream. + - If you need to preserve the detected format, return `{ stream, type }`. - `api.registerBeforePlay(fn)` - Runs right before playback starts for a track, after the server resolves the stream URL but before the audio pipeline is created. - Signature: `(nodelink, player, context) => void|Promise` @@ -41,8 +68,8 @@ Plugin API - `context.urlData`: `{ url, protocol, format?, additionalData?, newTrack? }` as returned by the source. - Use cases: enable filters (e.g., compressor), tag telemetry, or adjust internal state. Best practice: do not override user volume unless your plugin explicitly requires it. - `api.logger(level, category?, message)` -- `api.config` – resolved server config. -- `api.version` – server version string. +- `api.config` — resolved server config. +- `api.version` — server version string. Route Matching Order 1) Built-in routes @@ -52,7 +79,7 @@ Route Matching Order Example Plugin (Local) ```js -// src/plugins/my-plugin.js +// src/plugins/src/my-plugin.js export default async function (nodelink, api) { api.addRoute('/v4/my-plugin/health', (server, req, res, sendResponse) => { sendResponse(req, res, { status: 'ok' }, 200) @@ -64,12 +91,12 @@ export default async function (nodelink, api) { Audio Cache Example ```js -// src/plugins/audio-cache-plugin.js +// src/plugins/src/audio-cache-plugin.js // Intercepts streams and caches them on disk; see repository version for a full implementation. export default async function (nodelink, api) { api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { const original = await next() - // Return original or a PassThrough tee’d into a file + // Return original or a PassThrough tee into a file return original }) } @@ -77,7 +104,7 @@ export default async function (nodelink, api) { Before-Play Hook Example (Normalization) ```js -// src/plugins/audio-normalizer-plugin.js +// src/plugins/src/audio-normalizer-plugin.js export default async function (nodelink, api) { // Gentle compressor to even out loudness; do not change user volume const compressor = { threshold: -18, ratio: 3, attack: 10, release: 120, gain: 6 } @@ -94,6 +121,9 @@ Package Plugin Skeleton ```js // index.js in an npm package named: nodelink-plugin-hello export default { + name: 'nodelink-plugin-hello', + description: 'Hello demo routes', + version: '0.1.0', async register(nodelink, api) { api.addRoute(/\/v4\/hello\/?.*/, (server, req, res, sendResponse, url) => { sendResponse(req, res, { route: url.pathname }, 200) @@ -106,3 +136,8 @@ Notes - Plugins load after built-in sources and lyrics in single-process mode and in worker processes. - If a plugin registers sources/lyrics, it can be used immediately for requests. - If you need more hooks, open an issue or contribute a PR to extend the plugin API. + +Introspection +- The `/v4/info` endpoint lists loaded plugins with their `name`, `description`, and `version`. +- The server also logs `Loaded plugin: ` on successful initialization. + From ffc7115a5fb9a19724d477a548f580e86972ea74 Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 15:05:04 +0700 Subject: [PATCH 06/18] feat: add integration tests for example and audio-cache plugins --- .gitignore | 2 + test/plugins.integration.test.js | 153 +++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 test/plugins.integration.test.js diff --git a/.gitignore b/.gitignore index 6b6e23b1..e8d92e51 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ !docs/** !src !src/** +!test/ +!test/** !.dockerignore !.gitignore !constants.js diff --git a/test/plugins.integration.test.js b/test/plugins.integration.test.js new file mode 100644 index 00000000..307a8933 --- /dev/null +++ b/test/plugins.integration.test.js @@ -0,0 +1,153 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import requestHandler from '../src/api/index.js' +import PluginManager from '../src/plugins/pluginManager.js' +import examplePlugin from '../src/plugins/src/example-plugin.js' +import audioCachePlugin from '../src/plugins/src/audio-cache-plugin.js' + +function createMockServer(password = 'test-pass') { + const nodelink = { + options: { server: { password }, filters: { enabled: {} }, plugins: {} }, + statsManager: { incrementApiRequest: () => {} }, + dosProtectionManager: { check: () => ({ allowed: true }) }, + rateLimitManager: { check: () => true }, + version: 'test-0.0.0', + gitInfo: { commitTime: Date.now(), branch: 'test', commit: 'test' } + } + nodelink.pluginManager = new PluginManager(nodelink) + + const api = { + addRoute: (pathnameOrRegex, handler, methods) => + nodelink.pluginManager.addRoute(pathnameOrRegex, handler, methods), + registerStreamInterceptor: () => {}, + registerBeforePlay: () => {}, + logger: () => {}, + config: nodelink.options, + version: 'test' + } + + return { nodelink, api } +} + +function invoke(nodelink, { method = 'GET', path = '/', body = undefined, headers = {} } = {}) { + return new Promise((resolve) => { + const req = new (class { + constructor() { + this.method = method + this.url = path + this.headers = { + host: 'localhost:3000', + authorization: nodelink.options.server.password, + 'user-agent': 'node-test', + ...(body ? { 'content-type': 'application/json' } : {}), + ...headers + } + this.socket = { remoteAddress: '127.0.0.1', remotePort: 12345 } + this._listeners = {} + // For non-GET, simulate data/end events after handler attaches listeners + if (method !== 'GET') { + process.nextTick(() => { + if (body !== undefined) { + const payload = typeof body === 'string' ? body : JSON.stringify(body) + this._emit('data', Buffer.from(payload)) + } + this._emit('end') + }) + } + } + on(event, fn) { + this._listeners[event] = this._listeners[event] || [] + this._listeners[event].push(fn) + } + _emit(event, ...args) { + for (const fn of this._listeners[event] || []) fn(...args) + } + })() + + let status = 0 + let resHeaders = {} + let bodyBufs = [] + const res = { + writeHead(code, headers) { + status = code + resHeaders = headers || {} + }, + end(chunk) { + if (chunk) bodyBufs.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + const raw = Buffer.concat(bodyBufs).toString('utf8') + let json = null + try { json = raw ? JSON.parse(raw) : null } catch { /* not json */ } + resolve({ status, headers: resHeaders, raw, json }) + } + } + + // Fire handler + // eslint-disable-next-line promise/prefer-await-to-then + Promise.resolve(requestHandler(nodelink, req, res)).catch((err) => { + // If handler throws, surface as test failure + resolve({ status: status || 500, headers: resHeaders, raw: String(err), json: null }) + }) + }) +} + +test('plugin static route (example-plugin) responds with ok', async () => { + const { nodelink, api } = createMockServer() + await examplePlugin(nodelink, api) + + const res = await invoke(nodelink, { method: 'GET', path: '/v4/example-plugin/ping' }) + assert.equal(res.status, 200) + assert.ok(res.json && res.json.ok === true) + assert.equal(res.json.plugin, 'example-plugin') +}) + +test('plugin dynamic route via RegExp matches and responds', async () => { + const { nodelink, api } = createMockServer() + // Register a simple dynamic route + api.addRoute(/^\/v4\/custom\/item\/\d+$/, (server, req, res, sendResponse) => { + sendResponse(req, res, { matched: true }, 200) + }, ['GET']) + + const ok = await invoke(nodelink, { method: 'GET', path: '/v4/custom/item/42' }) + assert.equal(ok.status, 200) + assert.deepEqual(ok.json, { matched: true }) + + const notFound = await invoke(nodelink, { method: 'GET', path: '/v4/custom/item/abc' }) + assert.equal(notFound.status, 404) +}) + +test('audio-cache plugin: GET /v4/cache/stats returns payload', async () => { + const { nodelink, api } = createMockServer() + await audioCachePlugin(nodelink, api) + + const res = await invoke(nodelink, { method: 'GET', path: '/v4/cache/stats' }) + assert.equal(res.status, 200) + assert.ok(res.json && res.json.ok === true) + assert.ok('files' in res.json) + assert.ok('bytes' in res.json) +}) + +test('audio-cache plugin: method enforcement on /v4/cache/cleanup', async () => { + const { nodelink, api } = createMockServer() + await audioCachePlugin(nodelink, api) + + const res = await invoke(nodelink, { method: 'GET', path: '/v4/cache/cleanup' }) + assert.equal(res.status, 405) + assert.ok(res.json && res.json.status === 405) +}) + +test('info endpoint includes plugin metadata when initialized via manager', async () => { + const { nodelink } = createMockServer() + // Load via manager to record metadata + await nodelink.pluginManager.initialize(examplePlugin, 'example-plugin.js') + await nodelink.pluginManager.initialize(audioCachePlugin, 'audio-cache-plugin.js') + + const res = await invoke(nodelink, { method: 'GET', path: '/v4/info' }) + assert.equal(res.status, 200) + const plugins = res.json?.plugins || [] + const map = new Map(plugins.map((p) => [p.name, p])) + assert.ok(map.has('example-plugin')) + assert.equal(map.get('example-plugin').version, '1.0.0') + assert.ok(map.has('audio-cache')) + assert.equal(map.get('audio-cache').version, '1.0.0') +}) From 5bbebdbfeb875e5a299925d006c5dd626004a0a6 Mon Sep 17 00:00:00 2001 From: Tapao <40026698+Tapao-NonSen@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:24:01 +0700 Subject: [PATCH 07/18] Update config import to use default file Signed-off-by: Tapao <40026698+Tapao-NonSen@users.noreply.github.com> --- src/worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/worker.js b/src/worker.js index 1a490559..8d7a8a7a 100644 --- a/src/worker.js +++ b/src/worker.js @@ -12,7 +12,7 @@ let config try { config = (await import('../config.js')).default } catch { - config = (await import('../config.js')).default + config = (await import('../config.default.js')).default } initLogger(config) From 3728ab90c2818c60743e8a3033479a63fc5d1a31 Mon Sep 17 00:00:00 2001 From: Tapao <40026698+Tapao-NonSen@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:30:12 +0700 Subject: [PATCH 08/18] Disable audioCache and prebuffer plugins Signed-off-by: Tapao <40026698+Tapao-NonSen@users.noreply.github.com> --- config.default.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config.default.js b/config.default.js index 463b2201..7b6e6465 100644 --- a/config.default.js +++ b/config.default.js @@ -206,6 +206,7 @@ export default { }, plugins: { audioCache: { + enabled: false, dir: 'cache/audio', ttlDays: 7, cleanupIntervalHours: 12, @@ -218,7 +219,7 @@ export default { // Start playback only after some data has buffered. // Applies to non-live streams. Uses compressed bytes (source stream) as reference. prebuffer: { - enabled: true, + enabled: false, // Buffer target before releasing data downstream. Accepts number (bytes) or string: '512KB', '1MB', etc. bytes: '512KB', // Max time to wait before starting even if bytes not reached (ms). Set 0 to disable. From 0cbaae328077e216a03ccb146362ba1c2d25a55d Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 15:38:21 +0700 Subject: [PATCH 09/18] feat: update configuration loading and refine .gitignore entries --- .gitignore | 4 +++- config.default.js | 3 ++- src/worker.js | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index e8d92e51..4729a4a9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ !package.json !README.md !config.default.js -!biome.json \ No newline at end of file +!biome.json +!test +!test/** diff --git a/config.default.js b/config.default.js index 463b2201..7b6e6465 100644 --- a/config.default.js +++ b/config.default.js @@ -206,6 +206,7 @@ export default { }, plugins: { audioCache: { + enabled: false, dir: 'cache/audio', ttlDays: 7, cleanupIntervalHours: 12, @@ -218,7 +219,7 @@ export default { // Start playback only after some data has buffered. // Applies to non-live streams. Uses compressed bytes (source stream) as reference. prebuffer: { - enabled: true, + enabled: false, // Buffer target before releasing data downstream. Accepts number (bytes) or string: '512KB', '1MB', etc. bytes: '512KB', // Max time to wait before starting even if bytes not reached (ms). Set 0 to disable. diff --git a/src/worker.js b/src/worker.js index 1a490559..8d7a8a7a 100644 --- a/src/worker.js +++ b/src/worker.js @@ -12,7 +12,7 @@ let config try { config = (await import('../config.js')).default } catch { - config = (await import('../config.js')).default + config = (await import('../config.default.js')).default } initLogger(config) From fd0e4a87688c12c4208a83fb7e030f34dc7e844b Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 22:09:46 +0700 Subject: [PATCH 10/18] refactor: simplify source manager logic and streamline plugin retrieval --- src/api/info.js | 25 +++++-------------------- src/plugins/src/audio-cache-plugin.js | 11 +---------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/src/api/info.js b/src/api/info.js index dcf18a5d..57c952f3 100644 --- a/src/api/info.js +++ b/src/api/info.js @@ -21,27 +21,12 @@ async function handler(nodelink, req, res, sendResponse) { }, sourceManagers: nodelink.workerManager ? nodelink.supportedSourcesCache || - (nodelink.supportedSourcesCache = await nodelink.getSourcesFromWorker()) - : (nodelink.sources?.sources && Array.from(nodelink.sources.sources.keys())) || [], + (nodelink.supportedSourcesCache = await nodelink.getSourcesFromWorker()) + : nodelink.sources?.sources + ? Array.from(nodelink.sources.sources.keys()) + : [], filters, - plugins: (() => { - const pm = nodelink.pluginManager - // Prefer detailed list if available - if (pm && Array.isArray(pm.plugins)) { - return pm.plugins.map((p) => { - if (typeof p === 'string') return { name: p, version: 'unknown', description: 'unknown' } - return { - name: p?.name || 'unknown', - version: p?.version || 'unknown', - description: p?.description || 'unknown' - } - }) - } - if (pm && typeof pm.getPluginList === 'function') { - return pm.getPluginList().map((name) => ({ name, version: 'unknown', description: 'unknown' })) - } - return [] - })() + plugins: await nodelink.pluginManager.getPluginList() || [] } sendResponse(req, res, response, 200) } diff --git a/src/plugins/src/audio-cache-plugin.js b/src/plugins/src/audio-cache-plugin.js index c8b920be..7c4ce270 100644 --- a/src/plugins/src/audio-cache-plugin.js +++ b/src/plugins/src/audio-cache-plugin.js @@ -70,15 +70,6 @@ export default async function audioCachePlugin(nodelink, api) { return path.join(cacheDir, shard, `${key}.dat`) } - async function exists(p) { - try { - await fsp.access(p) - return true - } catch { - return false - } - } - async function existsNonEmpty(p) { try { const st = await fsp.stat(p) @@ -232,7 +223,7 @@ export default async function audioCachePlugin(nodelink, api) { bytesFreed += f.size } catch { } } - api.logger('info', 'Plugin-Cache', `Trimmed cache: removed ${removed}, freed ${bytesFreed} bytes, total≈${total}`) + api.logger('info', 'Plugin-Cache', `Trimmed cache: removed ${removed}, freed ${bytesFreed} bytes, total~=${total}`) return { removed, bytesFreed } } From 057f0a2261301882d3156a02d50891422afc45af Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 22:17:32 +0700 Subject: [PATCH 11/18] fix: update plugin retrieval method in API response --- README.md | 2 +- src/api/info.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60ca0605..b17169b1 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Once started, NodeLink runs a Lavalink-compatible WebSocket server, ready for im NodeLink now supports a simple plugin system so you can add custom HTTP routes and register new sources or lyrics providers without modifying core files. -- Local development: add files under `src/plugins/*.js`. +- Local development: add files under `src/plugins/src/*.js`. - Package plugins: install NPM packages named `nodelink-plugin-*`. See `docs/plugins.md` for the full API and examples. diff --git a/src/api/info.js b/src/api/info.js index 57c952f3..550f7690 100644 --- a/src/api/info.js +++ b/src/api/info.js @@ -26,7 +26,7 @@ async function handler(nodelink, req, res, sendResponse) { ? Array.from(nodelink.sources.sources.keys()) : [], filters, - plugins: await nodelink.pluginManager.getPluginList() || [] + plugins: await nodelink.pluginManager.getPlugins() || [] } sendResponse(req, res, response, 200) } From 8974e580ad0c5f6b372e6f897b5e658585b90042 Mon Sep 17 00:00:00 2001 From: Tapao Date: Wed, 5 Nov 2025 22:20:18 +0700 Subject: [PATCH 12/18] feat: enhance audio cache plugin settings with maxSize and protection options --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b17169b1..6498acb4 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,13 @@ See `docs/plugins.md` for the full API and examples. ```js plugins: { audioCache: { - dir: 'cache/audio', // cache directory - ttlDays: 7, // days to keep inactive files - cleanupIntervalHours: 12 // periodic cleanup interval + enabled: true, + dir: 'cache/audio', // cache directory + ttlDays: 7, // days to keep inactive files + cleanupIntervalHours: 12, // periodic cleanup interval + maxSize: '1GB', // or maxSizeBytes: 10 * 1024 * 1024 * 1024 + protectRecentMinutes: 60, // Do not evict files accessed within this window (minutes) + allowExceedWhenAllRecent: true // If all files are in-use or recently used, allow exceeding max size } } ``` From 7043b803fe04d92bb71c2d00db5f595b1dae625f Mon Sep 17 00:00:00 2001 From: Tapao Date: Thu, 6 Nov 2025 01:18:53 +0700 Subject: [PATCH 13/18] refactor: rename variable for clarity in plugin handler function --- src/api/plugins.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/api/plugins.js b/src/api/plugins.js index edac440f..11c60e6d 100644 --- a/src/api/plugins.js +++ b/src/api/plugins.js @@ -1,9 +1,9 @@ import { sendResponse } from '../utils.js' function handler(nodelink, req, res) { - const pm = nodelink.pluginManager - const names = pm?.getPluginList?.() || [] - const routes = pm?.getRoutes?.() + const pluginManager = nodelink.pluginManager + const names = pluginManager?.getPluginList?.() || [] + const routes = pluginManager?.getRoutes?.() const pluginStatic = routes ? Array.from(routes.static.keys()) : [] const pluginDynamic = routes ? routes.dynamic.map(([regex]) => String(regex)) : [] @@ -26,4 +26,3 @@ export default { handler, methods: ['GET'] } - From 44240cebb5a4152d1be9395dd300aa0b2be59201 Mon Sep 17 00:00:00 2001 From: Tapao Date: Thu, 6 Nov 2025 01:19:16 +0700 Subject: [PATCH 14/18] refactor: improve code clarity and consistency in PluginManager class --- src/plugins/pluginManager.js | 131 ++++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 54 deletions(-) diff --git a/src/plugins/pluginManager.js b/src/plugins/pluginManager.js index c8f70c34..b43bd542 100644 --- a/src/plugins/pluginManager.js +++ b/src/plugins/pluginManager.js @@ -15,6 +15,15 @@ export default class PluginManager { this.beforePlayHooks = [] } + /** + * Register an HTTP route exposed by a plugin. + * - Static routes use an exact string path (e.g., `/v4/my-plugin/health`). + * - Dynamic routes can be registered with a RegExp to match multiple paths. + * The handler receives `(nodelink, req, res, sendResponse, parsedUrl)`. + * @param {string|RegExp} pathnameOrRegex + * @param {Function} handler + * @param {string[]} [methods=['GET']] + */ addRoute(pathnameOrRegex, handler, methods = ['GET']) { const routeData = { handler, methods } if (pathnameOrRegex instanceof RegExp) { @@ -26,34 +35,48 @@ export default class PluginManager { } } + /** + * Return the full route registry for plugins. + * - `static`: Map + * - `dynamic`: Array<[RegExp, {handler, methods}]> + */ getRoutes() { return this.routes } + /** + * Return the list of loaded plugin names. + * If a plugin does not declare metadata, a fallback name is used. + * @returns {string[]} + */ getPluginList() { return this.plugins.map((p) => p.name) } + /** + * Return shallow copies of loaded plugin descriptors. + * Each entry contains at least `{ name, description, version }`. + */ getPlugins() { return this.plugins.slice() } - registerStreamInterceptor(fn) { - if (typeof fn !== 'function') throw new Error('Stream interceptor must be a function') - this.streamInterceptors.push(fn) + registerStreamInterceptor(interceptor) { + if (typeof interceptor !== 'function') throw new Error('Stream interceptor must be a function') + this.streamInterceptors.push(interceptor) } - registerBeforePlay(fn) { - if (typeof fn !== 'function') throw new Error('Before-play hook must be a function') - this.beforePlayHooks.push(fn) + registerBeforePlay(hook) { + if (typeof hook !== 'function') throw new Error('Before-play hook must be a function') + this.beforePlayHooks.push(hook) } async runStreamPipeline(track, url, protocol, additionalData, finalHandler) { - let idx = -1 - const dispatch = async (i) => { - if (i <= idx) throw new Error('next() called multiple times') - idx = i - const interceptor = this.streamInterceptors[i] + let currentIndex = -1 + const dispatch = async (index) => { + if (index <= currentIndex) throw new Error('next() called multiple times') + currentIndex = index + const interceptor = this.streamInterceptors[index] if (interceptor) { return interceptor( this.nodelink, @@ -61,7 +84,7 @@ export default class PluginManager { url, protocol, additionalData, - () => dispatch(i + 1) + () => dispatch(index + 1) ) } return finalHandler() @@ -73,8 +96,8 @@ export default class PluginManager { for (const hook of this.beforePlayHooks) { try { await hook(this.nodelink, player, context) - } catch (e) { - logger('warn', 'Plugin', `beforePlay hook error: ${e.message}`) + } catch (error) { + logger('warn', 'Plugin', `beforePlay hook error: ${error.message}`) } } } @@ -112,33 +135,33 @@ export default class PluginManager { async #loadPackagePlugins() { // Discover installed packages that look like nodelink plugins - let pkgJson + let packageJson try { - const pkgUrl = pathToFileURL(path.resolve(process.cwd(), 'package.json')) - pkgJson = (await import(pkgUrl)).default - } catch (e) { + const packageUrl = pathToFileURL(path.resolve(process.cwd(), 'package.json')) + packageJson = (await import(packageUrl)).default + } catch (error) { logger('debug', 'Plugin', 'No package.json found for plugin discovery') return } - const deps = { - ...(pkgJson.dependencies || {}), - ...(pkgJson.devDependencies || {}) + const dependencies = { + ...(packageJson.dependencies || {}), + ...(packageJson.devDependencies || {}) } - const pluginNames = Object.keys(deps).filter((name) => + const pluginNames = Object.keys(dependencies).filter((name) => name.toLowerCase().startsWith('nodelink-plugin-') ) for (const name of pluginNames) { try { - const mod = await import(name) - await this.#initializePluginModule(mod, name) - } catch (e) { + const moduleData = await import(name) + await this.#initializePluginModule(moduleData, name) + } catch (error) { logger( 'error', 'Plugin', - `Failed to load package plugin '${name}': ${e.message}` + `Failed to load package plugin '${name}': ${error.message}` ) } } @@ -147,29 +170,29 @@ export default class PluginManager { async #loadPluginModule(filePath) { try { const fileUrl = pathToFileURL(filePath) - const mod = await import(fileUrl) + const moduleData = await import(fileUrl) const name = path.basename(filePath) - await this.#initializePluginModule(mod, name) - } catch (e) { - logger('error', 'Plugin', `Failed to load plugin '${filePath}': ${e.message}`) + await this.#initializePluginModule(moduleData, name) + } catch (error) { + logger('error', 'Plugin', `Failed to load plugin '${filePath}': ${error.message}`) } } - async #initializePluginModule(mod, name = 'unknown') { - const plugin = mod?.default || mod + async #initializePluginModule(moduleData, name = 'unknown') { + const plugin = moduleData?.default || moduleData if (!plugin) { logger('warn', 'Plugin', `Plugin '${name}' has no default export`) return } - const api = this.#buildApi() + const pluginApi = this.#buildPluginApi() try { if (typeof plugin === 'function') { - await plugin(this.nodelink, api) + await plugin(this.nodelink, pluginApi) } else if (plugin && typeof plugin.register === 'function') { - await plugin.register(this.nodelink, api) + await plugin.register(this.nodelink, pluginApi) } else if (plugin && typeof plugin.init === 'function') { - await plugin.init(this.nodelink, api) + await plugin.init(this.nodelink, pluginApi) } else { logger( 'warn', @@ -178,16 +201,16 @@ export default class PluginManager { ) return } - const meta = this.#extractMetadata(mod, plugin, name) + const meta = this.#extractMetadata(moduleData, plugin, name) this.plugins.push(meta) logger('info', 'Plugin', `Loaded plugin: ${name}`) - } catch (e) { - logger('error', 'Plugin', `Plugin '${name}' initialization failed: ${e.message}`) + } catch (error) { + logger('error', 'Plugin', `Plugin '${name}' initialization failed: ${error.message}`) } } - #extractMetadata(mod, plugin, fallbackName) { - const tryMeta = (obj) => { + #extractMetadata(moduleData, plugin, fallbackName) { + const tryMetadata = (obj) => { if (!obj || typeof obj !== 'object') return null const { name, description, version } = obj if (!(name || description || version)) return null @@ -202,27 +225,27 @@ export default class PluginManager { } } - let meta = null + let metadata = null if (typeof plugin === 'function') { - meta = tryMeta(plugin.pluginInfo) || tryMeta(plugin.meta) || null + metadata = tryMetadata(plugin.pluginInfo) || tryMetadata(plugin.meta) || null } - meta = meta || tryMeta(mod?.pluginInfo) || tryMeta(mod?.meta) || null - if (!meta && plugin && typeof plugin === 'object' && (plugin.name || plugin.description || plugin.version)) { - meta = tryMeta(plugin) + metadata = metadata || tryMetadata(moduleData?.pluginInfo) || tryMetadata(moduleData?.meta) || null + if (!metadata && plugin && typeof plugin === 'object' && (plugin.name || plugin.description || plugin.version)) { + metadata = tryMetadata(plugin) } return { - name: meta?.name || String(fallbackName), - description: meta?.description || 'unknown', - version: meta?.version || 'unknown' + name: metadata?.name || String(fallbackName), + description: metadata?.description || 'unknown', + version: metadata?.version || 'unknown' } } - async initialize(mod, name = 'unknown') { - return this.#initializePluginModule(mod, name) + async initialize(moduleData, name = 'unknown') { + return this.#initializePluginModule(moduleData, name) } - #buildApi() { + #buildPluginApi() { return { // HTTP routes addRoute: (pathnameOrRegex, handler, methods) => @@ -232,8 +255,8 @@ export default class PluginManager { this.nodelink?.sources?.addSource?.(name, instance), registerLyricsSource: (name, instance) => this.nodelink?.lyrics?.addLyricsSource?.(name, instance), - registerStreamInterceptor: (fn) => this.registerStreamInterceptor(fn), - registerBeforePlay: (fn) => this.registerBeforePlay(fn), + registerStreamInterceptor: (interceptor) => this.registerStreamInterceptor(interceptor), + registerBeforePlay: (hook) => this.registerBeforePlay(hook), // Utilities logger, config: this.nodelink?.options, From 565eadb4a8759d69d2fb48a67abfbdeff7ade04c Mon Sep 17 00:00:00 2001 From: Tapao Date: Thu, 6 Nov 2025 01:19:29 +0700 Subject: [PATCH 15/18] refactor: rename functions and variables for improved clarity in audio cache and prebuffer plugins --- src/plugins/src/audio-cache-plugin.js | 126 +++++++++++++------------- src/plugins/src/example-plugin.js | 6 +- src/plugins/src/prebuffer-plugin.js | 58 ++++++------ 3 files changed, 95 insertions(+), 95 deletions(-) diff --git a/src/plugins/src/audio-cache-plugin.js b/src/plugins/src/audio-cache-plugin.js index 7c4ce270..04cffd8b 100644 --- a/src/plugins/src/audio-cache-plugin.js +++ b/src/plugins/src/audio-cache-plugin.js @@ -4,15 +4,15 @@ import path from 'node:path' import crypto from 'node:crypto' import { PassThrough } from 'node:stream' -function sha1(input) { +function computeSha1Hex(input) { return crypto.createHash('sha1').update(String(input)).digest('hex') } -async function ensureDir(dir) { +async function ensureDirectoryExists(dir) { await fsp.mkdir(dir, { recursive: true }).catch(() => { }) } -function nowMs() { +function nowMilliseconds() { return Date.now() } @@ -22,39 +22,39 @@ export const pluginInfo = { version: '1.0.0' } -export default async function audioCachePlugin(nodelink, api) { - const cfg = api.config?.plugins?.audioCache || {} - const cacheDir = path.resolve(process.cwd(), cfg.dir || path.join('cache', 'audio')) - const ttlDays = Number.isFinite(cfg.ttlDays) ? cfg.ttlDays : 7 - const cleanupIntervalHours = Number.isFinite(cfg.cleanupIntervalHours) - ? cfg.cleanupIntervalHours +export default async function audioCachePlugin(nodelink, pluginApi) { + const configuration = pluginApi.config?.plugins?.audioCache || {} + const cacheDir = path.resolve(process.cwd(), configuration.dir || path.join('cache', 'audio')) + const ttlDays = Number.isFinite(configuration.ttlDays) ? configuration.ttlDays : 7 + const cleanupIntervalHours = Number.isFinite(configuration.cleanupIntervalHours) + ? configuration.cleanupIntervalHours : 12 const maxSizeBytes = (() => { - const v = cfg.maxSizeBytes ?? cfg.maxSize - if (v == null) return null - if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v - if (typeof v === 'string') { - const m = v.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i) - if (m) { - const num = parseFloat(m[1]) - const unit = (m[2] || 'b').toLowerCase() - const mul = unit === 'tb' ? 1024 ** 4 : unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1 - return Math.max(0, Math.floor(num * mul)) + const value = configuration.maxSizeBytes ?? configuration.maxSize + if (value == null) return null + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value + if (typeof value === 'string') { + const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i) + if (match) { + const numberValue = parseFloat(match[1]) + const unit = (match[2] || 'b').toLowerCase() + const multiplier = unit === 'tb' ? 1024 ** 4 : unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1 + return Math.max(0, Math.floor(numberValue * multiplier)) } } return null })() - const protectRecentMs = (() => { - const v = cfg.protectRecentMinutes - if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v * 60 * 1000) + const protectRecentMilliseconds = (() => { + const minutes = configuration.protectRecentMinutes + if (typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0) return Math.floor(minutes * 60 * 1000) return 0 })() - const allowExceedWhenAllRecent = Boolean(cfg.allowExceedWhenAllRecent) + const allowExceedWhenAllRecent = Boolean(configuration.allowExceedWhenAllRecent) - await ensureDir(cacheDir) + await ensureDirectoryExists(cacheDir) const inUse = new Map() @@ -62,7 +62,7 @@ export default async function audioCachePlugin(nodelink, api) { const t = track && typeof track === 'object' ? (track.info || track) : {} const idCandidate = t.identifier || t.uri || url || JSON.stringify(t) const source = t.sourceName || track?.sourceName || 'unknown' - return `${source}-${sha1(String(idCandidate))}` + return `${source}-${computeSha1Hex(String(idCandidate))}` } function filePathForKey(key) { @@ -121,7 +121,7 @@ export default async function audioCachePlugin(nodelink, api) { async function cleanup() { const ttlMs = ttlDays * 24 * 60 * 60 * 1000 - const cutoff = nowMs() - ttlMs + const cutoff = nowMilliseconds() - ttlMs let removed = 0 let bytesFreed = 0 async function walk(dir) { @@ -153,7 +153,7 @@ export default async function audioCachePlugin(nodelink, api) { } } await walk(cacheDir) - api.logger('info', 'Plugin-Cache', `Cleanup finished. Removed ${removed} files, freed ${bytesFreed} bytes`) + pluginApi.logger('info', 'Plugin-Cache', `Cleanup finished. Removed ${removed} files, freed ${bytesFreed} bytes`) return { removed, bytesFreed } } @@ -189,20 +189,20 @@ export default async function audioCachePlugin(nodelink, api) { if (total <= target) return { removed: 0, bytesFreed: 0 } // Build eviction candidates honoring in-use and recent-protection rules - const now = nowMs() + const now = nowMilliseconds() const notInUse = files.filter(f => !inUse.has(f.path)) let candidates = notInUse - if (protectRecentMs > 0) { - candidates = candidates.filter(f => (f.atime || 0) < (now - protectRecentMs)) + if (protectRecentMilliseconds > 0) { + candidates = candidates.filter(f => (f.atime || 0) < (now - protectRecentMilliseconds)) } if (candidates.length === 0) { if (notInUse.length === 0) { - api.logger('info', 'Plugin-Cache', `Trim skipped: all files in use; allowing overflow (total=${total}, target=${target})`) + pluginApi.logger('info', 'Plugin-Cache', `Trim skipped: all files in use; allowing overflow (total=${total}, target=${target})`) return { removed: 0, bytesFreed: 0 } } if (allowExceedWhenAllRecent) { - api.logger('info', 'Plugin-Cache', `Trim skipped: all files are recent; allowing overflow (total=${total}, target=${target})`) + pluginApi.logger('info', 'Plugin-Cache', `Trim skipped: all files are recent; allowing overflow (total=${total}, target=${target})`) return { removed: 0, bytesFreed: 0 } } files = notInUse @@ -223,18 +223,18 @@ export default async function audioCachePlugin(nodelink, api) { bytesFreed += f.size } catch { } } - api.logger('info', 'Plugin-Cache', `Trimmed cache: removed ${removed}, freed ${bytesFreed} bytes, total~=${total}`) + pluginApi.logger('info', 'Plugin-Cache', `Trimmed cache: removed ${removed}, freed ${bytesFreed} bytes, total~=${total}`) return { removed, bytesFreed } } - api.addRoute('/v4/cache/stats', async (server, req, res, sendResponse) => { + pluginApi.addRoute('/v4/cache/stats', async (server, req, res, sendResponse) => { const s = await getStats() const cap = s.maxBytes || null const usage = cap ? Math.min(100, Math.round((s.bytes / cap) * 100)) : null sendResponse(req, res, { ok: true, files: s.files, bytes: s.bytes, maxBytes: cap, usagePercent: usage }, 200) }) - api.addRoute('/v4/cache/cleanup', async (server, req, res, sendResponse) => { + pluginApi.addRoute('/v4/cache/cleanup', async (server, req, res, sendResponse) => { const result = await cleanup() sendResponse(req, res, { ok: true, ...result }, 200) }, ['POST']) @@ -247,19 +247,19 @@ export default async function audioCachePlugin(nodelink, api) { }, intervalMs) timer.unref?.() - api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { + pluginApi.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { const key = keyFor(track, url) const filePath = filePathForKey(key) - await ensureDir(path.dirname(filePath)) + await ensureDirectoryExists(path.dirname(filePath)) if (await existsNonEmpty(filePath)) { - api.logger('info', 'Plugin-Cache', `HIT key=${key} file=${filePath}`) + pluginApi.logger('info', 'Plugin-Cache', `HIT key=${key} file=${filePath}`) await touch(filePath) markUse(filePath, 1) - const rs = fs.createReadStream(filePath) - rs.on('close', () => markUse(filePath, -1)) - rs.once('end', () => rs.emit('finishBuffering')) - return { stream: rs } + const readStream = fs.createReadStream(filePath) + readStream.on('close', () => markUse(filePath, -1)) + readStream.once('end', () => readStream.emit('finishBuffering')) + return { stream: readStream } } let original @@ -271,27 +271,27 @@ export default async function audioCachePlugin(nodelink, api) { } try { - api.logger('info', 'Plugin-Cache', `MISS key=${key} -> caching to ${filePath}`) + pluginApi.logger('info', 'Plugin-Cache', `MISS key=${key} -> caching to ${filePath}`) try { const st0 = await fsp.stat(filePath) if (!st0 || st0.size === 0) await fsp.unlink(filePath).catch(() => { }) } catch { } - const tee = new PassThrough({ highWaterMark: 1 << 20 }) - const ws = fs.createWriteStream(filePath) + const teeStream = new PassThrough({ highWaterMark: 1 << 20 }) + const writeStream = fs.createWriteStream(filePath) let completed = false - const origStream = original?.stream || original - origStream.pipe(tee) - tee.pipe(ws) + const originalStream = original?.stream || original + originalStream.pipe(teeStream) + teeStream.pipe(writeStream) - origStream.on?.('finishBuffering', () => { - tee.emit('finishBuffering') + originalStream.on?.('finishBuffering', () => { + teeStream.emit('finishBuffering') }) - tee.on('close', async () => { + teeStream.on('close', async () => { try { - const origStream = original?.stream || original - origStream.destroy?.() + const originalStream = original?.stream || original + originalStream.destroy?.() } catch { } try { if (!completed) { @@ -301,34 +301,34 @@ export default async function audioCachePlugin(nodelink, api) { } catch { } }) - const cleanupOnError = (err) => { - try { ws.destroy() } catch { } + const cleanupOnError = (error) => { + try { writeStream.destroy() } catch { } fsp.unlink(filePath).catch(() => { }) - if (!tee.destroyed && err) tee.destroy(err) + if (!teeStream.destroyed && error) teeStream.destroy(error) } - origStream.on('error', cleanupOnError) - tee.on('error', cleanupOnError) - ws.on('error', cleanupOnError) + originalStream.on('error', cleanupOnError) + teeStream.on('error', cleanupOnError) + writeStream.on('error', cleanupOnError) - ws.on('finish', async () => { + writeStream.on('finish', async () => { completed = true touch(filePath).catch(() => { }) }) - ws.on('close', async () => { + writeStream.on('close', async () => { if (!completed) { const st = await fsp.stat(filePath).catch(() => null) if (!st || st.size === 0) await fsp.unlink(filePath).catch(() => { }) } }) - return { stream: tee, type: original?.type } + return { stream: teeStream, type: original?.type } } catch (e) { return original } }) - api.logger('info', 'Plugin-Cache', `audio-cache-plugin initialized at ${cacheDir} (ttlDays=${ttlDays})`) + pluginApi.logger('info', 'Plugin-Cache', `audio-cache-plugin initialized at ${cacheDir} (ttlDays=${ttlDays})`) } // Attach metadata for discovery diff --git a/src/plugins/src/example-plugin.js b/src/plugins/src/example-plugin.js index b0eb771c..e6ae001e 100644 --- a/src/plugins/src/example-plugin.js +++ b/src/plugins/src/example-plugin.js @@ -8,14 +8,14 @@ export const pluginInfo = { version: '1.0.0' } -export default async function examplePlugin(nodelink, api) { +export default async function examplePlugin(nodelink, pluginApi) { // Add a simple GET route: /v4/example-plugin/ping - api.addRoute(`/v4/example-plugin/ping`, (server, req, res, sendResponse) => { + pluginApi.addRoute(`/v4/example-plugin/ping`, (server, req, res, sendResponse) => { sendResponse(req, res, { ok: true, plugin: 'example-plugin', pid: process.pid }, 200) }, ['GET']) // Log on load - api.logger('info', 'Plugin', 'example-plugin initialized') + pluginApi.logger('info', 'Plugin', 'example-plugin initialized') } // Also attach metadata to the default export for discovery diff --git a/src/plugins/src/prebuffer-plugin.js b/src/plugins/src/prebuffer-plugin.js index feeb3cf8..60f686f8 100644 --- a/src/plugins/src/prebuffer-plugin.js +++ b/src/plugins/src/prebuffer-plugin.js @@ -4,12 +4,12 @@ function parseSize(input, fallback = 0) { if (input == null) return fallback if (typeof input === 'number' && Number.isFinite(input)) return Math.max(0, input | 0) if (typeof input === 'string') { - const m = input.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i) - if (m) { - const num = parseFloat(m[1]) - const unit = (m[2] || 'b').toLowerCase() - const mul = unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1 - return Math.max(0, Math.floor(num * mul)) + const match = input.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i) + if (match) { + const numberValue = parseFloat(match[1]) + const unit = (match[2] || 'b').toLowerCase() + const multiplier = unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1 + return Math.max(0, Math.floor(numberValue * multiplier)) } } return fallback @@ -21,18 +21,18 @@ export const pluginInfo = { version: '1.0.0' } -export default async function prebufferPlugin(nodelink, api) { - const cfg = api.config?.plugins?.prebuffer || {} - if (!cfg.enabled) { - api.logger('info', 'Plugin-Prebuffer', 'Disabled by config') +export default async function prebufferPlugin(nodelink, pluginApi) { + const configuration = pluginApi.config?.plugins?.prebuffer || {} + if (!configuration.enabled) { + pluginApi.logger('info', 'Plugin-Prebuffer', 'Disabled by configuration') return } - const targetBytes = parseSize(cfg.bytes, 512 * 1024) - const timeoutMs = typeof cfg.timeoutMs === 'number' && cfg.timeoutMs > 0 ? Math.floor(cfg.timeoutMs) : 0 - const highWaterMark = parseSize(cfg.highWaterMark, 1 << 20) || (1 << 20) + const targetBytes = parseSize(configuration.bytes, 512 * 1024) + const timeoutMilliseconds = typeof configuration.timeoutMs === 'number' && configuration.timeoutMs > 0 ? Math.floor(configuration.timeoutMs) : 0 + const highWaterMark = parseSize(configuration.highWaterMark, 1 << 20) || (1 << 20) - api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { + pluginApi.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { const isLive = Boolean(track?.info?.isStream) if (isLive || targetBytes <= 0) { return next() @@ -47,7 +47,7 @@ export default async function prebufferPlugin(nodelink, api) { let timer = null const bufferChunks = [] - const out = new PassThrough({ highWaterMark }) + const output = new PassThrough({ highWaterMark }) const release = () => { if (released) return @@ -55,7 +55,7 @@ export default async function prebufferPlugin(nodelink, api) { try { if (timer) { clearTimeout(timer); timer = null } for (const chunk of bufferChunks) { - if (!out.destroyed) out.write(chunk) + if (!output.destroyed) output.write(chunk) } } finally { bufferChunks.length = 0 @@ -63,17 +63,17 @@ export default async function prebufferPlugin(nodelink, api) { } const startTimerIfNeeded = () => { - if (timeoutMs > 0 && !timer) { + if (timeoutMilliseconds > 0 && !timer) { timer = setTimeout(() => { release() - }, timeoutMs) + }, timeoutMilliseconds) timer.unref?.() } } input.on('data', (chunk) => { if (released) { - out.write(chunk) + output.write(chunk) return } bufferChunks.push(chunk) @@ -87,33 +87,33 @@ export default async function prebufferPlugin(nodelink, api) { input.on('end', () => { release() - out.end() - out.emit('finishBuffering') + output.end() + output.emit('finishBuffering') }) input.on('close', () => { release() - out.end() + output.end() }) - input.on('error', (err) => { - if (!out.destroyed) out.emit('error', err) + input.on('error', (error) => { + if (!output.destroyed) output.emit('error', error) }) input.on?.('finishBuffering', () => { release() - out.emit('finishBuffering') + output.emit('finishBuffering') }) - out.on('close', () => { + output.on('close', () => { try { input.destroy?.() } catch {} }) - api.logger('debug', 'Plugin-Prebuffer', `Prebuffering up to ${targetBytes} bytes${timeoutMs ? ` or ${timeoutMs}ms` : ''} (hwm=${highWaterMark})`) - return { stream: out, type: original?.type } + pluginApi.logger('debug', 'Plugin-Prebuffer', `Prebuffering up to ${targetBytes} bytes${timeoutMilliseconds ? ` or ${timeoutMilliseconds}ms` : ''} (highWaterMark=${highWaterMark})`) + return { stream: output, type: original?.type } }) - api.logger('info', 'Plugin-Prebuffer', `Initialized (bytes=${targetBytes}, timeoutMs=${timeoutMs}, hwm=${highWaterMark})`) + pluginApi.logger('info', 'Plugin-Prebuffer', `Initialized (bytes=${targetBytes}, timeoutMs=${timeoutMilliseconds}, highWaterMark=${highWaterMark})`) } // Attach metadata for discovery From 03b83632378b75b5ff8e3b1e52980ec34951461e Mon Sep 17 00:00:00 2001 From: Tapao Date: Thu, 6 Nov 2025 01:19:42 +0700 Subject: [PATCH 16/18] docs: update plugin documentation with prebuffer settings and naming conventions --- README.md | 16 +- docs/plugins.md | 396 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 298 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 6498acb4..ef723f23 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,12 @@ Once started, NodeLink runs a Lavalink-compatible WebSocket server, ready for im --- ## Plugins - NodeLink now supports a simple plugin system so you can add custom HTTP routes and register new sources or lyrics providers without modifying core files. - Local development: add files under `src/plugins/src/*.js`. - Package plugins: install NPM packages named `nodelink-plugin-*`. -See `docs/plugins.md` for the full API and examples. +See `docs/plugins.md` for the full API and examples. Prefer descriptive names over abbreviations in plugins (e.g., `pluginApi`, `configuration`). ### Audio Cache Plugin Settings - Configure in `config.js` under `plugins.audioCache`: @@ -86,6 +85,19 @@ plugins: { } ``` +### Prebuffer Plugin Settings +- Configure in `config.js` under `plugins.prebuffer`: +```js +plugins: { + prebuffer: { + enabled: true, + bytes: '512 KB', // target initial buffered bytes + timeoutMs: 2000, // optional maximum wait for prebuffer + highWaterMark: '1 MB' // internal stream buffer size + } +} +``` + --- ## Usage diff --git a/docs/plugins.md b/docs/plugins.md index e417b403..020826cf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,143 +1,315 @@ -Plugin System - -Overview -- NodeLink exposes a lightweight plugin system so others can extend the server without changing core files. -- Plugins can: - - Register HTTP routes (static path or RegExp). - - Register custom audio sources. - - Register custom lyrics sources. - - Intercept audio streams or run hooks before playback. - -Where Plugins Live -- Local (recommended for development): place files in `src/plugins/src/*.js`. - - Example: `src/plugins/src/example-plugin.js` is included and registers a `/v4/example-plugin/ping` route. -- Packages (publishable): install NPM packages whose name starts with `nodelink-plugin-` and they will be auto-loaded. - -Exports -- A plugin may export one of the following as its default export: - - `export default function (nodelink, api) { /* ... */ }` - - `export default { register(nodelink, api) { /* ... */ } }` - - `export default { init(nodelink, api) { /* ... */ } }` - -Metadata (name, description, version) -- Plugins can provide metadata that appears in `/v4/info` under `plugins`. -- Provide it in one of these ways (in order of precedence): - - Attach to the default function: `myPlugin.pluginInfo = { name, description, version }` - - Export a named value: `export const pluginInfo = { name, description, version }` - - Export an object with fields: `export default { name, description, version, register() { ... } }` - -Example with function export: +# 🧩 NodeLink Plugin Guide (Detailed Edition) +NodeLink supports a **modular plugin system** that lets you extend its core behavior without modifying the main source. +You can add new endpoints, register audio sources, intercept audio streams, and even inject logic before a song starts playing. + +This guide explains every part of the system clearly and in depth, with real examples. + +--- + +## 🧠 Overview: What Plugins Are +A **plugin** in NodeLink is a small piece of JavaScript code that runs when the NodeLink server starts. +It interacts with the system through a provided API called `pluginApi`. + +You can use it to: +| Capability | Description | Example Use Case | +| ----------------------- | ------------------------------------ | -------------------------------------------------- | +| **HTTP routes** | Add custom endpoints under `/v4/...` | Create a `/v4/status` route to check server health | +| **Audio sources** | Integrate new content providers | Add a custom SoundCloud, TikTok, or S3 source | +| **Lyrics providers** | Supply your own lyric fetcher | Pull lyrics from a private API | +| **Stream interceptors** | Modify or monitor audio streams | Cache audio, tag metadata, pre-buffer tracks | +| **Before-play hooks** | Run logic before playback | Normalize volume or apply dynamic filters | + +Plugins allow developers to expand NodeLink’s behavior without touching its internals — keeping updates simple and compatibility strong. + +--- + +## ⚙️ How Plugins Are Loaded +NodeLink automatically loads plugins from **two sources**: local files and npm packages. + +### 1. Local Plugins (for development) +If you’re experimenting or writing your own plugin: +* Create a file in `src/plugins/src/`. +* Example: + ``` + src/plugins/src/my-plugin.js + ``` +* NodeLink automatically loads any `.js` file in this directory. + +You can look at the built-in reference: +``` +src/plugins/src/example-plugin.js +``` +This example demonstrates the minimal setup for defining metadata and adding routes. + +--- + +### 2. Package Plugins (for sharing or production) +If you want to share your plugin or install one written by someone else: + +* Install it like a normal npm package. +* It **must** be named with this pattern: + ``` + nodelink-plugin- + ``` +* NodeLink scans installed packages and automatically loads any matching ones. +This makes distribution seamless — just `npm install` and restart NodeLink. + +--- + +## 🧱 Writing Your First Plugin +Below is a simple example plugin that adds a `/v4/my-plugin/health` endpoint. ```js +// src/plugins/src/my-plugin.js export const pluginInfo = { name: 'my-plugin', - description: 'Short summary of what it does', - version: '1.2.3' + description: 'Shows a health route', + version: '1.0.0' } -export default async function myPlugin(nodelink, api) { - api.addRoute('/v4/my-plugin/health', (server, req, res, sendResponse) => { +export default async function myPlugin(nodelink, pluginApi) { + pluginApi.addRoute('/v4/my-plugin/health', (_server, req, res, sendResponse) => { sendResponse(req, res, { status: 'ok' }, 200) }) } -// Optional: also attach to the function (supported as well) +// Optional alternative way to attach metadata myPlugin.pluginInfo = pluginInfo ``` -Plugin API -- `api.addRoute(pathOrRegex, handler, methods = ['GET'])` - - `pathOrRegex`: string like `/v4/your/route` or a `RegExp` to match dynamic paths. - - `handler(nodelink, req, res, sendResponse, parsedUrl)`: - - Use `sendResponse(req, res, data, status)` to reply JSON. -- `api.registerSource(name, instance)` - - Registers an audio source instance. The instance should follow the same interface as built-in sources: - - `setup(): Promise`, `search(query)`, `resolve(url)`, `loadStream(track, url, protocol, additionalData)`, etc. - - Optionally provide `searchTerms: string[]`, `patterns: RegExp[]`, and `priority: number`. -- `api.registerLyricsSource(name, instance)` - - Registers a lyrics provider with methods like `setup()` and `getLyrics(trackInfo)`. -- `api.registerStreamInterceptor(fn)` - - Intercepts audio stream loading. Useful for caching, metering, or transformation. - - Signature: `(nodelink, track, url, protocol, additionalData, next) => (Readable|{ stream: Readable, type?: string }) | Promise<...>` - - Call `await next()` to get the original stream (or `{ stream, type }`), then return your wrapped stream. - - If you need to preserve the detected format, return `{ stream, type }`. -- `api.registerBeforePlay(fn)` - - Runs right before playback starts for a track, after the server resolves the stream URL but before the audio pipeline is created. - - Signature: `(nodelink, player, context) => void|Promise` - - `player`: the Player instance; you can call `player.setFilters(...)` or read `player.volumePercent`. - - `context.info`: the decoded track info object. - - `context.urlData`: `{ url, protocol, format?, additionalData?, newTrack? }` as returned by the source. - - Use cases: enable filters (e.g., compressor), tag telemetry, or adjust internal state. Best practice: do not override user volume unless your plugin explicitly requires it. -- `api.logger(level, category?, message)` -- `api.config` — resolved server config. -- `api.version` — server version string. - -Route Matching Order -1) Built-in routes -2) Plugin static routes -3) Built-in dynamic routes -4) Plugin dynamic routes - -Example Plugin (Local) +### How It Works +* The `pluginInfo` object provides metadata shown in `/v4/info`. +* The exported function (`default`) is called when NodeLink starts. +* You get two parameters: + + * `nodelink`: The main NodeLink instance + * `pluginApi`: The helper API for registering routes, hooks, etc. +* Inside the function, you register your behavior — in this case, a simple health route returning `{ status: 'ok' }`. + +--- + +## 🧩 Supported Export Shapes +NodeLink is flexible — it can load your plugin in different ways. +| Format | Example | Description | +| -------------------------- | ---------------------------------------------------------- | -------------------------------------- | +| **Function** | `export default function (nodelink, pluginApi) { ... }` | Most common and straightforward | +| **Object with `register`** | `export default { register(nodelink, pluginApi) { ... } }` | Useful for class-like structures | +| **Object with `init`** | `export default { init(nodelink, pluginApi) { ... } }` | Same idea, different naming preference | + +If NodeLink finds any of these forms, it will call the function and initialize the plugin. + +--- + +## 🌐 Adding HTTP Routes +Plugins can register new HTTP endpoints that appear under NodeLink’s built-in API. +These can serve JSON, perform internal actions, or even forward requests to external services. + +### Static Route Example +```js +pluginApi.addRoute('/v4/example/ping', (_server, req, res, sendResponse) => { + sendResponse(req, res, { ok: true }, 200) +}, ['GET']) +``` +This creates a `GET /v4/example/ping` endpoint returning `{ ok: true }`. + +### Dynamic Route Example (with Regex) ```js -// src/plugins/src/my-plugin.js -export default async function (nodelink, api) { - api.addRoute('/v4/my-plugin/health', (server, req, res, sendResponse) => { - sendResponse(req, res, { status: 'ok' }, 200) - }) +pluginApi.addRoute(/\/v4\/example\/item\/([\w-]+)/, (_server, req, res, sendResponse, url) => { + const id = url.pathname.split('/').pop() + sendResponse(req, res, { id }, 200) +}, ['GET']) +``` +This matches any path like `/v4/example/item/abc123`. - api.logger('info', 'Plugin', 'my-plugin loaded') -} +### Route Handler Signature +```js +(nodelink, req, res, sendResponse, parsedUrl) ``` -Audio Cache Example +To send a response: ```js -// src/plugins/src/audio-cache-plugin.js -// Intercepts streams and caches them on disk; see repository version for a full implementation. -export default async function (nodelink, api) { - api.registerStreamInterceptor(async (server, track, url, protocol, additionalData, next) => { - const original = await next() - // Return original or a PassThrough tee into a file - return original - }) -} +sendResponse(req, res, data, statusCode) +``` + +**Tip:** Keep routes simple — avoid heavy blocking operations and move complex logic into helpers. + +--- + +## 🎧 Stream Interceptors +Stream interceptors let you **modify, wrap, or monitor audio streams** before they reach users. + +### Example: Stream Interceptor +```js +pluginApi.registerStreamInterceptor(async (nodelink, track, url, protocol, additionalData, next) => { + const original = await next() // Get original stream + const input = original?.stream || original + if (!input?.on) return original // Skip if invalid stream + + // Here, you could measure data, cache it, or re-encode it. + return { stream: input, type: original?.type } +}) ``` -Before-Play Hook Example (Normalization) +### What You Can Do +* **Prebuffering:** Download the first few seconds before playback. +* **Caching:** Store downloaded audio to disk for faster replays. +* **Monitoring:** Count bytes streamed or log duration. +* **Transforming:** Apply audio filters or compression. + +**Important:** +Always return `{ stream, type }` if you modify the stream — otherwise NodeLink might not detect the format correctly. +Some streams emit `finishBuffering` when they’re ready to play, so you can hook into that event if needed. + +--- + +## 🎚️ Before-Play Hook +Runs just before playback begins — after the source resolves but before the audio pipeline is built. +It’s perfect for automatic filter adjustments, normalization, or analytics. ```js -// src/plugins/src/audio-normalizer-plugin.js -export default async function (nodelink, api) { - // Gentle compressor to even out loudness; do not change user volume - const compressor = { threshold: -18, ratio: 3, attack: 10, release: 120, gain: 6 } - - api.registerBeforePlay(async (_server, player, { info, urlData }) => { - // Enable compressor prior to pipeline creation - player.setFilters({ filters: { compressor } }) - // Respect the user's chosen volume: avoid calling player.volume(...) +pluginApi.registerBeforePlay(async (_nodelink, player, context) => { + player.setFilters({ + filters: { + compressor: { threshold: -18, ratio: 3, attack: 10, release: 120, gain: 6 } + } }) -} +}) ``` -Package Plugin Skeleton +You can access: +* `player`: The current player instance +* `context`: Metadata about what’s being played (guild ID, track info, etc.) + +--- + +## 🎵 Custom Sources & Lyrics Providers +You can add your own **audio source** or **lyrics provider** that NodeLink can use alongside YouTube, Spotify, etc. + +### Audio Source Example +```js +pluginApi.registerSource('my-source', { + async setup() { return true }, // Optional initialization + async search(query) { /* search API logic */ }, + async resolve(url) { /* resolve a track URL */ }, + async loadStream(track, url, protocol, additionalData) { /* return readable stream */ } +}) +``` + +### Lyrics Provider Example +```js +pluginApi.registerLyricsSource('my-lyrics', { + async setup() { return true }, + async getLyrics(trackInfo) { /* fetch or parse lyrics */ } +}) +``` +Each method gives you control over how NodeLink interacts with your service. + +--- + +## ⚙️ Plugin Configuration +Each plugin can have its own settings under the `plugins` key in `config.js`. +Here’s an example using built-in plugins: ```js -// index.js in an npm package named: nodelink-plugin-hello export default { - name: 'nodelink-plugin-hello', - description: 'Hello demo routes', - version: '0.1.0', - async register(nodelink, api) { - api.addRoute(/\/v4\/hello\/?.*/, (server, req, res, sendResponse, url) => { - sendResponse(req, res, { route: url.pathname }, 200) - }) + plugins: { + audioCache: { + enabled: true, + directory: 'cache/audio', + ttlDays: 7, + cleanupIntervalHours: 12, + maxSize: '1 GB', + protectRecentMinutes: 60, + allowExceedWhenAllRecent: true + }, + prebuffer: { + enabled: true, + bytes: '512 KB', + timeoutMs: 2000, + highWaterMark: '1 MB' + } + } +} +``` + +### Explanation +* `audioCache`: Controls caching of audio files. + * Cleans up every 12 hours, deletes old files after 7 days. + * Can protect recently used files. +* `prebuffer`: Defines how much data is downloaded before playback starts. +You can define your own plugin’s config under this same structure, e.g.: +```js +plugins: { + myPlugin: { + enabled: true, + logRequests: false } } ``` -Notes -- Plugins load after built-in sources and lyrics in single-process mode and in worker processes. -- If a plugin registers sources/lyrics, it can be used immediately for requests. -- If you need more hooks, open an issue or contribute a PR to extend the plugin API. +Access it in your plugin via: +```js +const cfg = nodelink.config?.plugins?.myPlugin || {} +``` + +--- + +## 🔍 Route Resolution Order +When multiple routes overlap, NodeLink resolves them in this order: +1. Built-in static routes +2. Plugin static routes +3. Built-in dynamic routes +4. Plugin dynamic routes + +This means plugin routes won’t override critical core endpoints unless explicitly designed to. + +--- + +## 🧾 Discovering Plugins at Runtime +NodeLink exposes API endpoints to help you inspect loaded plugins. +| Endpoint | Description | +| ----------------- | ----------------------------------------------------------- | +| `GET /v4/plugins` | Lists all plugin route registrations | +| `GET /v4/info` | Displays plugin metadata (`name`, `description`, `version`) | + +Use these to verify that your plugin loaded correctly. + +--- + +## 📦 Packaging & Publishing +If you want to publish your plugin for others: +1. **Name your package**: + `nodelink-plugin-yourname` +2. **Export** one of the supported shapes (`default`, `register`, or `init`). +3. **Provide metadata**: + + ```js + export const pluginInfo = { name, description, version } + ``` + + or: + + ```js + myPlugin.pluginInfo = pluginInfo + ``` +4. Publish to npm, or share via GitHub. +NodeLink will auto-detect it when installed. + +--- + +## 💡 Best Practices +* **Use clear names:** prefer `pluginApi` over `pa`. +* **Keep route handlers short.** Extract logic into functions if needed. +* **Use async I/O** — avoid blocking code or `sync` filesystem calls. +* **Log responsibly:** + * `debug` → fine-grained details + * `info` → lifecycle events + * `warn` → recoverable issues + * `error` → real problems -Introspection -- The `/v4/info` endpoint lists loaded plugins with their `name`, `description`, and `version`. -- The server also logs `Loaded plugin: ` on successful initialization. +--- +## 🧰 Troubleshooting +| Problem | Likely Cause | Fix | +| ------------------------------------- | ------------------------------------- | -------------------------------------------------------------- | +| Route doesn’t appear | Wrong directory or missing prefix | Place in `src/plugins/src` or name package `nodelink-plugin-*` | +| Plugin metadata missing in `/v4/info` | `pluginInfo` not exported or attached | Add `export const pluginInfo = {...}` | +| Stream interceptor never fires | Source didn’t trigger `loadStream` | Test using an actual playback source | +| Plugin config ignored | Typo or missing `enabled: true` | Check your `config.js` structure | From e34ca7d8097b70689e2606913b4f8c275728c555 Mon Sep 17 00:00:00 2001 From: Tapao Date: Thu, 6 Nov 2025 03:05:13 +0700 Subject: [PATCH 17/18] docs: restructure and enhance NodeLink plugin documentation for clarity and completeness --- docs/plugins.md | 1190 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 980 insertions(+), 210 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 020826cf..8a01bbd2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,315 +1,1085 @@ -# 🧩 NodeLink Plugin Guide (Detailed Edition) -NodeLink supports a **modular plugin system** that lets you extend its core behavior without modifying the main source. -You can add new endpoints, register audio sources, intercept audio streams, and even inject logic before a song starts playing. +# NodeLink Plugin +## Table of Contents -This guide explains every part of the system clearly and in depth, with real examples. +1. [Introduction](#introduction) +2. [Core Concepts](#core-concepts) +3. [Getting Started](#getting-started) +4. [Plugin API Reference](#plugin-api-reference) +5. [Advanced Features](#advanced-features) +6. [Configuration](#configuration) +7. [Best Practices](#best-practices) +8. [Troubleshooting](#troubleshooting) --- -## 🧠 Overview: What Plugins Are -A **plugin** in NodeLink is a small piece of JavaScript code that runs when the NodeLink server starts. -It interacts with the system through a provided API called `pluginApi`. +## Introduction -You can use it to: -| Capability | Description | Example Use Case | -| ----------------------- | ------------------------------------ | -------------------------------------------------- | -| **HTTP routes** | Add custom endpoints under `/v4/...` | Create a `/v4/status` route to check server health | -| **Audio sources** | Integrate new content providers | Add a custom SoundCloud, TikTok, or S3 source | -| **Lyrics providers** | Supply your own lyric fetcher | Pull lyrics from a private API | -| **Stream interceptors** | Modify or monitor audio streams | Cache audio, tag metadata, pre-buffer tracks | -| **Before-play hooks** | Run logic before playback | Normalize volume or apply dynamic filters | +### What is a Plugin? -Plugins allow developers to expand NodeLink’s behavior without touching its internals — keeping updates simple and compatibility strong. +A NodeLink plugin is a JavaScript module that extends NodeLink's functionality without modifying its core codebase. Plugins receive access to the NodeLink instance and a specialized API (`pluginApi`) that provides safe, structured methods for integration. ---- +### Why Use Plugins? -## ⚙️ How Plugins Are Loaded -NodeLink automatically loads plugins from **two sources**: local files and npm packages. +- **Extend functionality** without forking the codebase +- **Keep updates simple** by separating custom logic from core +- **Share integrations** easily via npm packages +- **Maintain compatibility** across NodeLink versions -### 1. Local Plugins (for development) -If you’re experimenting or writing your own plugin: -* Create a file in `src/plugins/src/`. -* Example: - ``` - src/plugins/src/my-plugin.js - ``` -* NodeLink automatically loads any `.js` file in this directory. +### What Can Plugins Do? -You can look at the built-in reference: -``` -src/plugins/src/example-plugin.js -``` -This example demonstrates the minimal setup for defining metadata and adding routes. +- **Add HTTP endpoints** for custom API routes +- **Register audio sources** for new content providers (SoundCloud, custom APIs, etc.) +- **Provide lyrics** from alternative services +- **Intercept audio streams** for caching, monitoring, or transformation +- **Hook into playback** to apply filters or execute pre-play logic --- -### 2. Package Plugins (for sharing or production) -If you want to share your plugin or install one written by someone else: +## Core Concepts + +### Plugin Lifecycle -* Install it like a normal npm package. -* It **must** be named with this pattern: - ``` - nodelink-plugin- - ``` -* NodeLink scans installed packages and automatically loads any matching ones. -This makes distribution seamless — just `npm install` and restart NodeLink. +1. **Discovery**: NodeLink scans for plugins in local directories and npm packages +2. **Loading**: Each plugin module is imported +3. **Registration**: NodeLink calls your plugin's entry function with `nodelink` and `pluginApi` +4. **Runtime**: Your registered hooks, routes, and sources become active + +### Plugin Structure + +Every plugin needs: + +1. **Entry point**: A function that NodeLink calls during initialization +2. **Metadata** (optional but recommended): Information about your plugin +3. **Registration code**: Calls to `pluginApi` methods to register functionality --- -## 🧱 Writing Your First Plugin -Below is a simple example plugin that adds a `/v4/my-plugin/health` endpoint. -```js -// src/plugins/src/my-plugin.js +## Getting Started + +### Creating Your First Plugin + +#### Step 1: Choose Your Development Method + +**Option A: Local Development** +- Create a file in `src/plugins/src/` +- Best for: Testing, experimentation, private plugins +- Example: `src/plugins/src/my-first-plugin.js` + +**Option B: Package Development** +- Create an npm package named `nodelink-plugin-` +- Best for: Sharing, production use, version control +- Example: `nodelink-plugin-health-check` + +#### Step 2: Write Your Plugin + +Create a new file with this basic structure: + +```javascript +// Define plugin metadata export const pluginInfo = { - name: 'my-plugin', - description: 'Shows a health route', - version: '1.0.0' + name: 'health-check', + description: 'Adds a simple health check endpoint', + version: '1.0.0', + author: 'Your Name' +} + +// Main plugin function +export default async function healthCheckPlugin(nodelink, pluginApi) { + // Register a simple HTTP route + pluginApi.addRoute('/v4/health', (server, req, res, sendResponse) => { + sendResponse(req, res, { + status: 'healthy', + uptime: process.uptime(), + timestamp: Date.now() + }, 200) + }, ['GET']) + + // Log that your plugin loaded + pluginApi.logger('info', 'Health-Check-Plugin', '[HealthCheck] Plugin loaded successfully') } +``` + +#### Step 3: Test Your Plugin + +1. Start NodeLink +2. Check the logs for your plugin's initialization message +3. Test your endpoint: `curl http://localhost:2333/v4/health` +4. Verify plugin appears in: `GET /v4/info` + +--- + +## Plugin API Reference + +### Entry Point Formats + +NodeLink supports three entry point formats: +#### Format 1: Default Export Function (Recommended) + +```javascript export default async function myPlugin(nodelink, pluginApi) { - pluginApi.addRoute('/v4/my-plugin/health', (_server, req, res, sendResponse) => { - sendResponse(req, res, { status: 'ok' }, 200) - }) + // Your initialization code +} +``` + +#### Format 2: Object with `register` Method + +```javascript +export default { + async register(nodelink, pluginApi) { + // Your initialization code + } } +``` + +#### Format 3: Object with `init` Method -// Optional alternative way to attach metadata -myPlugin.pluginInfo = pluginInfo +```javascript +export default { + async init(nodelink, pluginApi) { + // Your initialization code + } +} ``` -### How It Works -* The `pluginInfo` object provides metadata shown in `/v4/info`. -* The exported function (`default`) is called when NodeLink starts. -* You get two parameters: +### Parameters Explained - * `nodelink`: The main NodeLink instance - * `pluginApi`: The helper API for registering routes, hooks, etc. -* Inside the function, you register your behavior — in this case, a simple health route returning `{ status: 'ok' }`. +#### `nodelink` - The NodeLink Instance ---- +The main server instance with access to: + +```javascript +{ + config: {...}, // Configuration object + logger: {...}, // Logging methods (debug, info, warn, error) + players: Map, // Active player instances + sources: {...}, // Registered audio sources + // ... other internal components +} +``` -## 🧩 Supported Export Shapes -NodeLink is flexible — it can load your plugin in different ways. -| Format | Example | Description | -| -------------------------- | ---------------------------------------------------------- | -------------------------------------- | -| **Function** | `export default function (nodelink, pluginApi) { ... }` | Most common and straightforward | -| **Object with `register`** | `export default { register(nodelink, pluginApi) { ... } }` | Useful for class-like structures | -| **Object with `init`** | `export default { init(nodelink, pluginApi) { ... } }` | Same idea, different naming preference | +#### `pluginApi` - The Plugin API -If NodeLink finds any of these forms, it will call the function and initialize the plugin. +Specialized methods for safe plugin integration: + +```javascript +{ + addRoute, // Register HTTP endpoints + registerSource, // Add audio sources + registerLyricsSource, // Add lyrics providers + registerStreamInterceptor, // Intercept audio streams + registerBeforePlay // Hook before playback starts +} +``` --- -## 🌐 Adding HTTP Routes -Plugins can register new HTTP endpoints that appear under NodeLink’s built-in API. -These can serve JSON, perform internal actions, or even forward requests to external services. +## Plugin API Methods -### Static Route Example -```js -pluginApi.addRoute('/v4/example/ping', (_server, req, res, sendResponse) => { - sendResponse(req, res, { ok: true }, 200) -}, ['GET']) +### API Summary + +| Method | Signature | Description | +| --- | --- | --- | +| addRoute | `addRoute(pattern, handler, methods = ['GET'])` | Register custom HTTP endpoints under NodeLink's API. | +| registerSource | `registerSource(name, sourceImplementation)` | Add a custom audio source that can search/resolve tracks and provide streams. | +| registerLyricsSource | `registerLyricsSource(name, lyricsImplementation)` | Add a lyrics provider for fetching song lyrics. | +| registerStreamInterceptor | `registerStreamInterceptor(interceptorFunction)` | Intercept/modify audio streams before they reach the player. | +| registerBeforePlay | `registerBeforePlay(hookFunction)` | Run logic just before a track starts playing. | + +### 1. HTTP Routes: `addRoute()` + +Register custom HTTP endpoints under NodeLink's API. + +#### Signature + +```javascript +pluginApi.addRoute(pattern, handler, methods = ['GET']) ``` -This creates a `GET /v4/example/ping` endpoint returning `{ ok: true }`. -### Dynamic Route Example (with Regex) -```js -pluginApi.addRoute(/\/v4\/example\/item\/([\w-]+)/, (_server, req, res, sendResponse, url) => { - const id = url.pathname.split('/').pop() - sendResponse(req, res, { id }, 200) +#### Handler Function Signature + +```javascript +function handler(nodelink, req, res, sendResponse, parsedUrl) { + // nodelink: NodeLink instance + // req: Node.js IncomingMessage + // res: Node.js ServerResponse + // sendResponse: Helper function for responses + // parsedUrl: Parsed URL object +} +``` + +#### Handler Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| nodelink | object | NodeLink instance with config, logger, players, etc. | +| req | IncomingMessage | Node.js HTTP request object. | +| res | ServerResponse | Node.js HTTP response object. | +| sendResponse | function | Helper to send JSON responses with proper headers and CORS. | +| parsedUrl | URL | Parsed URL object of the incoming request. | + +#### The `sendResponse` Helper + +```javascript +sendResponse(req, res, data, statusCode = 200, headers = {}) +``` + +This helper automatically: +- Serializes `data` to JSON +- Sets appropriate `Content-Type` headers +- Handles CORS if configured +- Sends the response + +#### Example: Static Route + +```javascript +pluginApi.addRoute('/v4/plugins/status', (server, req, res, sendResponse) => { + sendResponse(req, res, { + active: true, + version: '1.0.0' + }, 200) }, ['GET']) ``` -This matches any path like `/v4/example/item/abc123`. -### Route Handler Signature -```js -(nodelink, req, res, sendResponse, parsedUrl) +#### Example: Dynamic Route with Parameters + +```javascript +// Match: /v4/plugins/user/12345 +pluginApi.addRoute(/\/v4\/plugins\/user\/([\w-]+)/, + (server, req, res, sendResponse, url) => { + const userId = url.pathname.split('/').pop() + + sendResponse(req, res, { + userId, + found: true + }, 200) + }, + ['GET'] +) ``` -To send a response: -```js -sendResponse(req, res, data, statusCode) +#### Example: POST Route with Body Parsing + +```javascript +pluginApi.addRoute('/v4/plugins/data', async (server, req, res, sendResponse) => { + // Parse request body + let body = '' + for await (const chunk of req) { + body += chunk + } + + try { + const data = JSON.parse(body) + // Process data... + + sendResponse(req, res, { success: true, received: data }, 200) + } catch (error) { + sendResponse(req, res, { error: 'Invalid JSON' }, 400) + } +}, ['POST']) ``` -**Tip:** Keep routes simple — avoid heavy blocking operations and move complex logic into helpers. +### Route Resolution Priority + +When multiple routes could match a URL, NodeLink checks in this order: + +1. Built-in static routes (exact string matches) +2. Plugin static routes +3. Built-in dynamic routes (regex patterns) +4. Plugin dynamic routes + +This ensures plugins cannot accidentally override critical system endpoints. --- -## 🎧 Stream Interceptors -Stream interceptors let you **modify, wrap, or monitor audio streams** before they reach users. +### 2. Audio Sources: `registerSource()` -### Example: Stream Interceptor -```js -pluginApi.registerStreamInterceptor(async (nodelink, track, url, protocol, additionalData, next) => { - const original = await next() // Get original stream - const input = original?.stream || original - if (!input?.on) return original // Skip if invalid stream +Add custom audio providers that NodeLink can search and play from. + +#### Signature + +```javascript +pluginApi.registerSource(name, sourceImplementation) +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| name | string | Yes | — | Unique source name used in `track.info.sourceName`. | +| sourceImplementation | object | Yes | — | Implementation with lifecycle and data methods (see below). | + +#### Source Implementation Interface + +```javascript +{ + async setup() { + // Optional: Initialize API clients, validate credentials, etc. + // Return true on success, false on failure + return true + }, + + async search(query, options) { + // Search for tracks matching query + // Return: { loadType, data: [...tracks] } + }, + + async resolve(url) { + // Resolve a specific URL to track(s) + // Return: { loadType, data: track | [tracks] | playlist } + }, + + async loadStream(track, url, protocol, additionalData) { + // Return a readable stream for the audio + // Return: ReadableStream | { stream: ReadableStream, type: string } + } +} +``` - // Here, you could measure data, cache it, or re-encode it. - return { stream: input, type: original?.type } +#### Implementation Methods + +| Method | Parameters | Expected Return | Description | +| --- | --- | --- | --- | +| setup | — | `boolean` | Optional initializer. Return `true` to enable, `false` to skip registering the source. | +| search | `(query: string, options?: object)` | `{ loadType, data: Track[] }` | Search for tracks matching a query string. | +| resolve | `(url: string)` | `{ loadType, data: Track | Track[] | Playlist }` | Resolve a URL to a track, list of tracks, or playlist. | +| loadStream | `(track, url, protocol, additionalData)` | `ReadableStream` or `{ stream: ReadableStream, type?: string }` | Provide the playable audio stream (optionally with a content type). | + +#### Example: Custom Audio Source + +```javascript +pluginApi.registerSource('custom-music-api', { + async setup() { + // Verify API key is configured + const apiKey = nodelink.config.plugins?.customMusic?.apiKey + if (!apiKey) { + pluginApi.logger('error', 'CustomMusic', 'API key not configured') + return false + } + + pluginApi.logger('info', 'CustomMusic', 'Source initialized') + return true + }, + + async search(query) { + const response = await fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`) + const results = await response.json() + + return { + loadType: 'search', + data: results.tracks.map(track => ({ + encoded: Buffer.from(track.id).toString('base64'), + info: { + identifier: track.id, + title: track.title, + author: track.artist, + length: track.duration * 1000, + uri: track.url, + sourceName: 'custom-music-api' + } + })) + } + }, + + async resolve(url) { + // Extract track ID from URL + const trackId = url.split('/').pop() + + const response = await fetch(`https://api.example.com/tracks/${trackId}`) + const track = await response.json() + + return { + loadType: 'track', + data: { + encoded: Buffer.from(track.id).toString('base64'), + info: { + identifier: track.id, + title: track.title, + author: track.artist, + length: track.duration * 1000, + uri: url, + sourceName: 'custom-music-api' + } + } + } + }, + + async loadStream(track, url, protocol, additionalData) { + const streamUrl = await fetch(`https://api.example.com/stream/${track.info.identifier}`) + .then(r => r.json()) + .then(data => data.streamUrl) + + const response = await fetch(streamUrl) + return { + stream: response.body, + type: 'arbitrary' // or 'ogg/opus', 'webm/opus', etc. + } + } }) ``` -### What You Can Do -* **Prebuffering:** Download the first few seconds before playback. -* **Caching:** Store downloaded audio to disk for faster replays. -* **Monitoring:** Count bytes streamed or log duration. -* **Transforming:** Apply audio filters or compression. +--- -**Important:** -Always return `{ stream, type }` if you modify the stream — otherwise NodeLink might not detect the format correctly. -Some streams emit `finishBuffering` when they’re ready to play, so you can hook into that event if needed. +### 3. Lyrics Providers: `registerLyricsSource()` + +Add custom lyrics fetching services. + +#### Signature + +```javascript +pluginApi.registerLyricsSource(name, lyricsImplementation) +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| name | string | Yes | — | Unique lyrics source name. | +| lyricsImplementation | object | Yes | — | Implementation that fetches lyrics (see below). | + +#### Lyrics Implementation Interface + +```javascript +{ + async setup() { + // Optional: Initialize API, check credentials + return true + }, + + async getLyrics(trackInfo) { + // Fetch lyrics for the given track + // Return: { text: string } | { lines: [...] } | null + } +} +``` + +#### Implementation Methods + +| Method | Parameters | Expected Return | Description | +| --- | --- | --- | --- | +| setup | — | `boolean` | Optional initializer. Return `true` to enable, `false` to disable. | +| getLyrics | `(trackInfo: { title: string, author?: string, ... })` | `{ text: string }` or `{ lines: Line[] }` or `null` | Fetch lyrics for the given track; `null` if not found. | + +#### Example: Custom Lyrics Provider + +```javascript +pluginApi.registerLyricsSource('genius-lyrics', { + async setup() { + const token = nodelink.config.plugins?.genius?.token + if (!token) { + pluginApi.logger('warn', 'Genius', 'No API token configured') + return false + } + return true + }, + + async getLyrics(trackInfo) { + const { title, author } = trackInfo + const query = `${title} ${author}` + + try { + // Search for song + const searchUrl = `https://api.genius.com/search?q=${encodeURIComponent(query)}` + const searchResponse = await fetch(searchUrl, { + headers: { 'Authorization': `Bearer ${nodelink.config.plugins.genius.token}` } + }) + const searchData = await searchResponse.json() + + if (!searchData.response.hits.length) { + return null + } + + const songUrl = searchData.response.hits[0].result.url + + // Scrape lyrics from page (simplified) + const pageResponse = await fetch(songUrl) + const html = await pageResponse.text() + + // Extract lyrics (you'd use a proper HTML parser here) + const lyrics = extractLyricsFromHtml(html) + + return { + text: lyrics, + source: 'Genius' + } + } catch (error) { + pluginApi.logger('error', 'Genius', `Failed to fetch lyrics: ${error}`) + return null + } + } +}) +``` --- -## 🎚️ Before-Play Hook -Runs just before playback begins — after the source resolves but before the audio pipeline is built. -It’s perfect for automatic filter adjustments, normalization, or analytics. -```js -pluginApi.registerBeforePlay(async (_nodelink, player, context) => { - player.setFilters({ - filters: { - compressor: { threshold: -18, ratio: 3, attack: 10, release: 120, gain: 6 } +### 4. Stream Interceptors: `registerStreamInterceptor()` + +Intercept and modify audio streams before they reach the player. + +#### Signature + +```javascript +pluginApi.registerStreamInterceptor(interceptorFunction) +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| interceptorFunction | function | Yes | — | Async function to intercept streams; must call `next()` to get the original stream. | + +#### Interceptor Function Signature + +```javascript +async function interceptor(nodelink, track, url, protocol, additionalData, next) { + // nodelink: NodeLink instance + // track: Track object being played + // url: Stream URL + // protocol: Protocol type ('http', 'https', etc.) + // additionalData: Extra data from source + // next: Function to call the next interceptor/source + + const originalStream = await next() + + // Return modified stream or original + return originalStream +} +``` + +#### Interceptor Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| nodelink | object | NodeLink instance with config, logger, players, etc. | +| track | Track | Track object being played (`track.info.*`). | +| url | string | Stream URL provided by the source. | +| protocol | string | Protocol of the stream (e.g., `http`, `https`). | +| additionalData | any | Extra data returned by the source (implementation-specific). | +| next | function | Call to obtain the next stream in the chain. Returns a stream or `{ stream, type }`. | + +#### Example: Caching Interceptor + +```javascript +pluginApi.registerStreamInterceptor(async (nodelink, track, url, protocol, additionalData, next) => { + const cacheKey = track.info.identifier + const cachePath = `./cache/${cacheKey}.audio` + + // Check if cached + if (fs.existsSync(cachePath)) { + pluginApi.logger('debug', 'Cache', `Serving from cache: ${track.info.title}`) + return { + stream: fs.createReadStream(cachePath), + type: 'arbitrary' } - }) + } + + // Get original stream + const original = await next() + const input = original?.stream || original + + if (!input?.on) { + return original + } + + // Create cache file and pipe to it + const cacheStream = fs.createWriteStream(cachePath) + const passthrough = new PassThrough() + + input.pipe(passthrough) + input.pipe(cacheStream) + + pluginApi.logger('debug', 'Cache', `Caching: ${track.info.title}`) + + return { + stream: passthrough, + type: original?.type || 'arbitrary' + } }) ``` -You can access: -* `player`: The current player instance -* `context`: Metadata about what’s being played (guild ID, track info, etc.) +#### Example: Monitoring Interceptor + +```javascript +pluginApi.registerStreamInterceptor(async (nodelink, track, url, protocol, additionalData, next) => { + const original = await next() + const input = original?.stream || original + + if (!input?.on) { + return original + } + + let bytesStreamed = 0 + const startTime = Date.now() + + input.on('data', (chunk) => { + bytesStreamed += chunk.length + }) + + input.on('end', () => { + const duration = Date.now() - startTime + pluginApi.logger('info', 'Monitor', `Streamed ${bytesStreamed} bytes in ${duration}ms for: ${track.info.title}`) + }) + + return original +}) +``` --- -## 🎵 Custom Sources & Lyrics Providers -You can add your own **audio source** or **lyrics provider** that NodeLink can use alongside YouTube, Spotify, etc. +### 5. Before-Play Hook: `registerBeforePlay()` + +Execute code just before a track starts playing. + +#### Signature + +```javascript +pluginApi.registerBeforePlay(hookFunction) +``` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| hookFunction | function | Yes | — | Async function invoked before playback begins. | + +#### Hook Function Signature + +```javascript +async function hook(nodelink, player, context) { + // nodelink: NodeLink instance + // player: Player instance about to play + // context: Playback context (guildId, track, etc.) +} +``` + +#### Hook Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| nodelink | object | NodeLink instance with config, logger, players, etc. | +| player | Player | Player instance that will play the track. | +| context | object | Playback context including `guildId`, `track`, and other metadata. | + +#### Example: Auto-Normalize Volume -### Audio Source Example -```js -pluginApi.registerSource('my-source', { - async setup() { return true }, // Optional initialization - async search(query) { /* search API logic */ }, - async resolve(url) { /* resolve a track URL */ }, - async loadStream(track, url, protocol, additionalData) { /* return readable stream */ } +```javascript +pluginApi.registerBeforePlay(async (nodelink, player, context) => { + const track = context.track + + // Apply normalization for specific sources + if (track.info.sourceName === 'youtube') { + player.setVolume(85) // YouTube tends to be louder + } else { + player.setVolume(100) + } + + pluginApi.logger('debug', 'Volume', `Adjusted volume for ${track.info.sourceName}`) }) ``` -### Lyrics Provider Example -```js -pluginApi.registerLyricsSource('my-lyrics', { - async setup() { return true }, - async getLyrics(trackInfo) { /* fetch or parse lyrics */ } +#### Example: Apply Dynamic Filters + +```javascript +pluginApi.registerBeforePlay(async (nodelink, player, context) => { + const guildId = context.guildId + const guildSettings = await getGuildSettings(guildId) + + if (guildSettings.bassBoost) { + player.setFilters({ + filters: { + equalizer: [ + { band: 0, gain: 0.25 }, + { band: 1, gain: 0.15 } + ] + } + }) + } + + if (guildSettings.nightcore) { + player.setFilters({ + filters: { + timescale: { speed: 1.2, pitch: 1.2 } + } + }) + } }) ``` -Each method gives you control over how NodeLink interacts with your service. --- -## ⚙️ Plugin Configuration -Each plugin can have its own settings under the `plugins` key in `config.js`. -Here’s an example using built-in plugins: -```js +## Advanced Features + +### Accessing NodeLink Internals + +While plugins receive the `nodelink` instance, be cautious when accessing internals: + +```javascript +// Safe and recommended +pluginApi.logger('info', 'MyPlugin', 'Message') +nodelink.config.plugins.myPlugin + +// Use with caution (may change between versions) +nodelink.players.get(guildId) +nodelink.sources['youtube'] +``` + +### Error Handling + +Always handle errors gracefully: + +```javascript +export default async function myPlugin(nodelink, pluginApi) { + try { + pluginApi.addRoute('/v4/risky', async (server, req, res, sendResponse) => { + try { + const data = await someRiskyOperation() + sendResponse(req, res, { data }, 200) + } catch (error) { + pluginApi.logger('error', 'MyPlugin', `Route error: ${error}`) + sendResponse(req, res, { error: 'Internal error' }, 500) + } + }) + } catch (error) { + pluginApi.logger('error', 'MyPlugin', `Initialization failed: ${error}`) + } +} +``` + +### Async Operations + +Plugins support async/await throughout: + +```javascript +export default async function myPlugin(nodelink, pluginApi) { + // Wait for external service + await initializeExternalService() + + // Register async route handler + pluginApi.addRoute('/v4/async', async (server, req, res, sendResponse) => { + const result = await fetchExternalData() + sendResponse(req, res, result, 200) + }) +} +``` + +--- + +## Configuration + +### Plugin Configuration Structure + +Add plugin settings to `config.js` under the `plugins` key: + +```javascript export default { + // ... other NodeLink config + plugins: { + myPlugin: { + enabled: true, + apiKey: 'your-key-here', + customOption: 'value' + }, + audioCache: { enabled: true, directory: 'cache/audio', ttlDays: 7, - cleanupIntervalHours: 12, - maxSize: '1 GB', - protectRecentMinutes: 60, - allowExceedWhenAllRecent: true - }, - prebuffer: { - enabled: true, - bytes: '512 KB', - timeoutMs: 2000, - highWaterMark: '1 MB' + maxSize: '1 GB' } } } ``` -### Explanation -* `audioCache`: Controls caching of audio files. - * Cleans up every 12 hours, deletes old files after 7 days. - * Can protect recently used files. -* `prebuffer`: Defines how much data is downloaded before playback starts. -You can define your own plugin’s config under this same structure, e.g.: -```js -plugins: { - myPlugin: { - enabled: true, - logRequests: false +### Accessing Configuration in Plugins + +```javascript +export default async function myPlugin(nodelink, pluginApi) { + // Get plugin-specific config + const config = nodelink.config?.plugins?.myPlugin || {} + + // Check if enabled + if (!config.enabled) { + pluginApi.logger('info', 'MyPlugin', 'Disabled in config') + return + } + + // Use config values + const apiKey = config.apiKey + if (!apiKey) { + pluginApi.logger('error', 'MyPlugin', 'API key not configured') + return } + + // Continue initialization... } ``` -Access it in your plugin via: -```js -const cfg = nodelink.config?.plugins?.myPlugin || {} +### Environment Variables + +For sensitive data, use environment variables: + +```javascript +const apiKey = process.env.MY_PLUGIN_API_KEY || nodelink.config?.plugins?.myPlugin?.apiKey + +if (!apiKey) { + throw new Error('API key required') +} ``` --- -## 🔍 Route Resolution Order -When multiple routes overlap, NodeLink resolves them in this order: -1. Built-in static routes -2. Plugin static routes -3. Built-in dynamic routes -4. Plugin dynamic routes +## Best Practices -This means plugin routes won’t override critical core endpoints unless explicitly designed to. +### 1. Naming Conventions ---- +**Plugin Files** +- Local: `my-feature-plugin.js` +- Package: `nodelink-plugin-my-feature` + +**Route Paths** +- Use plugin name as prefix: `/v4/my-plugin/...` +- Keep paths lowercase with hyphens: `/v4/custom-source/search` + +**Variable Names** +- Be descriptive: `pluginApi` not `pa` +- Use camelCase: `trackInfo`, `streamUrl` + +### 2. Logging + +Use appropriate log levels: + +```javascript +// Detailed debugging information +pluginApi.logger('debug', 'MyPlugin', `Processing request for track: ${trackId}`) -## 🧾 Discovering Plugins at Runtime -NodeLink exposes API endpoints to help you inspect loaded plugins. -| Endpoint | Description | -| ----------------- | ----------------------------------------------------------- | -| `GET /v4/plugins` | Lists all plugin route registrations | -| `GET /v4/info` | Displays plugin metadata (`name`, `description`, `version`) | +// General information about plugin state +pluginApi.logger('info', 'MyPlugin', 'Successfully loaded 45 cached tracks') -Use these to verify that your plugin loaded correctly. +// Recoverable issues +pluginApi.logger('warn', 'MyPlugin', 'API rate limit hit, using fallback') + +// Critical problems +pluginApi.logger('error', 'MyPlugin', `Failed to initialize: ${error}`) +``` + +### 3. Performance + +**Avoid Blocking Operations** +```javascript +// Bad: Synchronous file read +const data = fs.readFileSync('./large-file.json') + +// Good: Asynchronous +const data = await fs.promises.readFile('./large-file.json', 'utf8') +``` + +**Use Streams for Large Data** +```javascript +// Bad: Loading entire file into memory +const audio = await fs.promises.readFile('./track.mp3') +return audio + +// Good: Streaming +return fs.createReadStream('./track.mp3') +``` + +### 4. Metadata Best Practices + +Always provide complete metadata: + +```javascript +export const pluginInfo = { + name: 'my-plugin', + description: 'Clear, concise description of what this does', + version: '1.2.3', + author: 'Your Name', + homepage: 'https://github.com/yourusername/nodelink-plugin-my-plugin' +} +``` --- -## 📦 Packaging & Publishing -If you want to publish your plugin for others: -1. **Name your package**: - `nodelink-plugin-yourname` -2. **Export** one of the supported shapes (`default`, `register`, or `init`). -3. **Provide metadata**: +## Troubleshooting + +### Plugin Not Loading - ```js - export const pluginInfo = { name, description, version } - ``` +**Problem**: Plugin doesn't appear in `/v4/info` - or: +**Solutions**: +1. Check file location: Must be in `src/plugins/src/` or named `nodelink-plugin-*` +2. Verify export format: Must export a default function or object with `register`/`init` +3. Check for syntax errors: Look at NodeLink startup logs +4. Ensure `pluginInfo` is properly exported + +```javascript +// Correct +export const pluginInfo = { name: 'my-plugin', ... } +export default function myPlugin() { ... } + +// Also correct +function myPlugin() { ... } +myPlugin.pluginInfo = { name: 'my-plugin', ... } +export default myPlugin +``` + +### Routes Not Working + +**Problem**: Requests to plugin routes return 404 + +**Solutions**: +1. Verify route prefix: Must start with `/v4/` +2. Check HTTP method: Default is `['GET']`, specify others explicitly +3. Test route pattern: Use simple string first, then regex +4. Check logs for registration errors + +```javascript +// Debug registration +pluginApi.addRoute('/v4/test', (server, req, res, sendResponse) => { + pluginApi.logger('info', 'Test', 'Route called!') + sendResponse(req, res, { ok: true }, 200) +}, ['GET']) +``` + +### Stream Interceptor Not Firing + +**Problem**: Interceptor function never executes + +**Solutions**: +1. Ensure source actually loads streams (not all sources use `loadStream`) +2. Check if track is from a source that supports streaming +3. Add logging to verify interceptor registration: + +```javascript +pluginApi.registerStreamInterceptor(async (nodelink, track, url, protocol, additionalData, next) => { + pluginApi.logger('info', 'Interceptor', `Called for: ${track.info.title}`) + return await next() +}) +``` - ```js - myPlugin.pluginInfo = pluginInfo - ``` -4. Publish to npm, or share via GitHub. -NodeLink will auto-detect it when installed. +### Configuration Not Loading + +**Problem**: Plugin config is undefined + +**Solutions**: +1. Verify `config.js` syntax: Must be valid JavaScript +2. Check nesting: `plugins.myPlugin.option` +3. Use fallback values: + +```javascript +const config = nodelink.config?.plugins?.myPlugin || {} +const apiKey = config.apiKey || 'default-key' +``` + +### Memory Leaks + +**Problem**: NodeLink memory usage grows over time + +**Solutions**: +1. Clear caches periodically +2. Remove event listeners when done +3. Close streams properly: + +```javascript +stream.on('end', () => { + stream.destroy() +}) + +stream.on('error', (err) => { + stream.destroy() +}) +``` --- -## 💡 Best Practices -* **Use clear names:** prefer `pluginApi` over `pa`. -* **Keep route handlers short.** Extract logic into functions if needed. -* **Use async I/O** — avoid blocking code or `sync` filesystem calls. -* **Log responsibly:** - * `debug` → fine-grained details - * `info` → lifecycle events - * `warn` → recoverable issues - * `error` → real problems +## Example Plugins + +### Complete Example: Analytics Plugin + +```javascript +// nodelink-plugin-analytics.js + +export const pluginInfo = { + name: 'analytics', + description: 'Tracks playback statistics and provides analytics endpoints', + version: '1.0.0', + author: 'Your Name' +} + +const stats = { + totalPlays: 0, + trackPlays: new Map(), + sourceStats: new Map() +} + +export default async function analyticsPlugin(nodelink, pluginApi) { + const config = nodelink.config?.plugins?.analytics || {} + + if (!config.enabled) { + return + } + + // Hook into playback + pluginApi.registerBeforePlay(async (nodelink, player, context) => { + const track = context.track + + // Increment counters + stats.totalPlays++ + + const trackId = track.info.identifier + stats.trackPlays.set(trackId, (stats.trackPlays.get(trackId) || 0) + 1) + + const source = track.info.sourceName + stats.sourceStats.set(source, (stats.sourceStats.get(source) || 0) + 1) + + pluginApi.logger('debug', 'Analytics', `Tracked play: ${track.info.title}`) + }) + + // Endpoint: Get overall stats + pluginApi.addRoute('/v4/analytics/stats', (server, req, res, sendResponse) => { + sendResponse(req, res, { + totalPlays: stats.totalPlays, + uniqueTracks: stats.trackPlays.size, + sources: Object.fromEntries(stats.sourceStats) + }, 200) + }) + + // Endpoint: Get top tracks + pluginApi.addRoute('/v4/analytics/top-tracks', (server, req, res, sendResponse) => { + const sorted = Array.from(stats.trackPlays.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([id, plays]) => ({ trackId: id, plays })) + + sendResponse(req, res, sorted, 200) + }) + + // Endpoint: Reset stats + pluginApi.addRoute('/v4/analytics/reset', (server, req, res, sendResponse) => { + stats.totalPlays = 0 + stats.trackPlays.clear() + stats.sourceStats.clear() + + pluginApi.logger('info', 'Analytics', 'Stats reset') + sendResponse(req, res, { success: true }, 200) + }, ['POST']) + + pluginApi.logger('info', 'Analytics', 'Plugin loaded') +} +``` --- -## 🧰 Troubleshooting -| Problem | Likely Cause | Fix | -| ------------------------------------- | ------------------------------------- | -------------------------------------------------------------- | -| Route doesn’t appear | Wrong directory or missing prefix | Place in `src/plugins/src` or name package `nodelink-plugin-*` | -| Plugin metadata missing in `/v4/info` | `pluginInfo` not exported or attached | Add `export const pluginInfo = {...}` | -| Stream interceptor never fires | Source didn’t trigger `loadStream` | Test using an actual playback source | -| Plugin config ignored | Typo or missing `enabled: true` | Check your `config.js` structure | +## Additional Resources + +### API Endpoints for Plugin Discovery + +- **`GET /v4/plugins`**: Lists all registered plugin routes +- **`GET /v4/info`**: Shows loaded plugins with metadata + +### Reference Implementation + +Check the example plugin included with NodeLink: +``` +src/plugins/src/example-plugin.js +``` From d162d39b83f1736106512a15a01a5a31d1fd326f Mon Sep 17 00:00:00 2001 From: Tapao Date: Thu, 6 Nov 2025 03:15:10 +0700 Subject: [PATCH 18/18] docs: expand and reorganize plugin documentation for improved clarity and usability --- docs/plugins.md | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 8a01bbd2..de1f4884 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,13 +2,46 @@ ## Table of Contents 1. [Introduction](#introduction) + - [What is a Plugin?](#what-is-a-plugin) + - [Why Use Plugins?](#why-use-plugins) + - [What Can Plugins Do?](#what-can-plugins-do) 2. [Core Concepts](#core-concepts) + - [Plugin Lifecycle](#plugin-lifecycle) + - [Plugin Structure](#plugin-structure) 3. [Getting Started](#getting-started) + - [Creating Your First Plugin](#creating-your-first-plugin) + - [Step 1: Choose Your Development Method](#step-1-choose-your-development-method) + - [Option A: Local Development](#option-a-local-development) + - [Option B: Package Development](#option-b-package-development) + - [Step 2: Write Your Plugin](#step-2-write-your-plugin) + - [Step 3: Test Your Plugin](#step-3-test-your-plugin) 4. [Plugin API Reference](#plugin-api-reference) + - [Entry Point Formats](#entry-point-formats) + - [Format 1: Default Export Function (Recommended)](#format-1-default-export-function-recommended) + - [Format 2: Object with register Method](#format-2-object-with-register-method) + - [Format 3: Object with init Method](#format-3-object-with-init-method) + - [Parameters Explained](#parameters-explained) + - [nodelink - The NodeLink Instance](#nodelink---the-nodelink-instance) + - [pluginApi - The Plugin API](#pluginapi---the-plugin-api) 5. [Advanced Features](#advanced-features) + - [Accessing NodeLink Internals](#accessing-nodelink-internals) + - [Error Handling](#error-handling) + - [Async Operations](#async-operations) 6. [Configuration](#configuration) + - [Plugin Configuration Structure](#plugin-configuration-structure) + - [Accessing Configuration in Plugins](#accessing-configuration-in-plugins) + - [Environment Variables](#environment-variables) 7. [Best Practices](#best-practices) + - [1. Naming Conventions](#1-naming-conventions) + - [2. Logging](#2-logging) + - [3. Performance](#3-performance) + - [4. Metadata Best Practices](#4-metadata-best-practices) 8. [Troubleshooting](#troubleshooting) + - [Plugin Not Loading](#plugin-not-loading) + - [Routes Not Working](#routes-not-working) + - [Stream Interceptor Not Firing](#stream-interceptor-not-firing) + - [Configuration Not Loading](#configuration-not-loading) + - [Memory Leaks](#memory-leaks) --- @@ -180,11 +213,11 @@ Specialized methods for safe plugin integration: | Method | Signature | Description | | --- | --- | --- | -| addRoute | `addRoute(pattern, handler, methods = ['GET'])` | Register custom HTTP endpoints under NodeLink's API. | -| registerSource | `registerSource(name, sourceImplementation)` | Add a custom audio source that can search/resolve tracks and provide streams. | -| registerLyricsSource | `registerLyricsSource(name, lyricsImplementation)` | Add a lyrics provider for fetching song lyrics. | -| registerStreamInterceptor | `registerStreamInterceptor(interceptorFunction)` | Intercept/modify audio streams before they reach the player. | -| registerBeforePlay | `registerBeforePlay(hookFunction)` | Run logic just before a track starts playing. | +| [addRoute](#1-http-routes-addroute) | `addRoute(pattern, handler, methods = ['GET'])` | Register custom HTTP endpoints under NodeLink's API. | +| [registerSource](#2-audio-sources-registersource) | `registerSource(name, sourceImplementation)` | Add a custom audio source that can search/resolve tracks and provide streams. | +| [registerLyricsSource](#3-lyrics-providers-registerlyricssource) | `registerLyricsSource(name, lyricsImplementation)` | Add a lyrics provider for fetching song lyrics. | +| [registerStreamInterceptor](#4-stream-interceptors-registerstreaminterceptor) | `registerStreamInterceptor(interceptorFunction)` | Intercept/modify audio streams before they reach the player. | +| [registerBeforePlay](#5-before-play-hook-registerbeforeplay) | `registerBeforePlay(hookFunction)` | Run logic just before a track starts playing. | ### 1. HTTP Routes: `addRoute()` @@ -342,7 +375,7 @@ pluginApi.registerSource(name, sourceImplementation) | --- | --- | --- | --- | | setup | — | `boolean` | Optional initializer. Return `true` to enable, `false` to skip registering the source. | | search | `(query: string, options?: object)` | `{ loadType, data: Track[] }` | Search for tracks matching a query string. | -| resolve | `(url: string)` | `{ loadType, data: Track | Track[] | Playlist }` | Resolve a URL to a track, list of tracks, or playlist. | +| resolve | `(url: string)` | `{ loadType, data: Track OR Track[] OR Playlist }` | Resolve a URL to a track, list of tracks, or playlist. | | loadStream | `(track, url, protocol, additionalData)` | `ReadableStream` or `{ stream: ReadableStream, type?: string }` | Provide the playable audio stream (optionally with a content type). | #### Example: Custom Audio Source