Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
11acf4e
fix: update Spotify source for 2026 Web API changes
1Lucas1apk Apr 6, 2026
b913e95
improve: spotify zero-config support and multi-tier resilience
1Lucas1apk Apr 6, 2026
838a8e2
improve: spotify zero-config support and multi-tier resilience
1Lucas1apk Apr 6, 2026
1a6da4b
Merge branch 'dev' of https://github.com/PerformanC/NodeLink into dev
1Lucas1apk Apr 6, 2026
d324b8e
improve: overhaul plugin system for multi-process architecture
1Lucas1apk Apr 6, 2026
db9c271
improve: bump version
1Lucas1apk Apr 6, 2026
22e2565
fix: align seek position and startTime handling
Tomato6966 Apr 7, 2026
09439af
fix: normalize seek offsets across sources
Tomato6966 Apr 7, 2026
9ea4f7c
fix: stuck loop
Tomato6966 Apr 7, 2026
b3af86b
Merge pull request #200 from Tomato6966/dev
1Lucas1apk Apr 8, 2026
0a9f6e3
improve: worker scalability and health metrics
ToddyTheNoobDud Apr 10, 2026
c84921c
fix: finished event waiting extra 10 seconds before emitting
ToddyTheNoobDud Apr 11, 2026
0db6d86
update: version date in package.json
ToddyTheNoobDud Apr 11, 2026
ff02c22
add: implement Google Drive source for audio and biome lint
1Lucas1apk Apr 12, 2026
037458a
improve: reconnection logic in Google Drive and Yandex Music sources
1Lucas1apk Apr 12, 2026
be12b95
improve: enhance logging with guildId context
1Lucas1apk Apr 12, 2026
cbe5b33
fix: trackstream api check for undefined instead of falsy
UnschooledGamer Apr 13, 2026
4cff466
update: version date in package.json
UnschooledGamer Apr 13, 2026
941f7f1
update: dist build to latest changes
UnschooledGamer Apr 13, 2026
31b664c
fix: lyrics runtime contract is incomplete
UnschooledGamer Apr 16, 2026
0e99c82
update: version date in package.json
UnschooledGamer Apr 16, 2026
b410085
fix: config server port in dist and update package.json in it
UnschooledGamer Apr 16, 2026
2e943a9
add: native SponsorBlock support with precision skipping
1Lucas1apk Apr 18, 2026
552bb40
improve: bump version
1Lucas1apk Apr 18, 2026
3deb224
add: recovery attemps limit
ToddyTheNoobDud Apr 19, 2026
db344e0
fix: crash with bun on http2
ToddyTheNoobDud Apr 19, 2026
db6e52c
update: monochrome urls & segment fetching
ToddyTheNoobDud Apr 20, 2026
d9102c3
update: bump all packages to latest
ToddyTheNoobDud Apr 20, 2026
81c0f6b
update: bun http2 handling
ToddyTheNoobDud Apr 20, 2026
5817482
feat(sessions): implement session resuming support
dripink Apr 21, 2026
9a7c1b0
Merge pull request #202 from doyimmiuink/feat/session-resuming
1Lucas1apk Apr 22, 2026
bc5384e
improve: update Node.js version requirements and add runtime checks
1Lucas1apk Apr 23, 2026
66200ab
improve: config overrides and proxy/monochrome resilience
1Lucas1apk Apr 25, 2026
2e08552
update: Contributor Assistant
ToddyTheNoobDud Apr 25, 2026
86729ae
update: change path signature to nodelink in cla
ToddyTheNoobDud Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/cla.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
uses: ThePedroo/contributor-assistant@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERFORMANC_BOT_ACCESS_TOKEN }}
Expand All @@ -29,5 +29,5 @@ jobs:
remote-organization-name: 'PerformanC'
remote-repository-name: 'CLA-Signatures'
create-file-commit-message: 'add: file for storing CLA Signatures'
signed-commit-message: 'add: @$contributorName to the list of signed contributors in $owner'
signed-commit-message: 'add: @$contributorName to the list of signed contributors in $owner/$repo#$pullRequestNo'
custom-allsigned-prcomment: 'All Contributors have signed the CLA. The PR is now allowed to be merged.'
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

## Prerequisites

* **Node.js** v22 or higher (v24 recommended)
* **Node.js** v22.22.2 or higher (v24 recommended)
* **Git**

---
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
"enabled": true,
Expand Down
19 changes: 19 additions & 0 deletions config.default.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ export default {
enableLoadStreamEndpoint: false,
resolveExternalLinks: false,
fetchChannelInfo: false,
sponsorblock: {
enabled: false,
api: 'https://sponsor.ajay.app',
categories: [
'sponsor',
'selfpromo',
'interaction',
'intro',
'outro',
'preview',
'music_offtopic',
'filler'
],
actionTypes: ['skip'],
skipMarginMs: 150
},
filters: {
enabled: {
tremolo: true,
Expand Down Expand Up @@ -467,6 +483,9 @@ export default {
instances: [], // (optional) list of API instances
streamingInstances: [], // (optional) list of streaming instances
quality: 'HI_RES_LOSSLESS' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW
},
googledrive: {
enabled: true
}
},
lyrics: {
Expand Down
19 changes: 19 additions & 0 deletions dist/config.default.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ export default {
enableLoadStreamEndpoint: false,
resolveExternalLinks: false,
fetchChannelInfo: false,
sponsorblock: {
enabled: false,
api: 'https://sponsor.ajay.app',
categories: [
'sponsor',
'selfpromo',
'interaction',
'intro',
'outro',
'preview',
'music_offtopic',
'filler'
],
actionTypes: ['skip'],
skipMarginMs: 150
},
filters: {
enabled: {
tremolo: true,
Expand Down Expand Up @@ -467,6 +483,9 @@ export default {
instances: [], // (optional) list of API instances
streamingInstances: [], // (optional) list of streaming instances
quality: 'HI_RES_LOSSLESS' // HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW
},
googledrive: {
enabled: true
}
},
lyrics: {
Expand Down
27 changes: 15 additions & 12 deletions dist/package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
{
"name": "nodelink",
"version": "3.7.0",
"version": "3.8.0-dev.20260422.1",
"scripts": {
"build": "tsc --incremental false",
"start": "node --dns-result-order=ipv4first --import tsx src/index.ts",
"start:experimental": "node --experimental-transform-types --dns-result-order=ipv4first src/index.ts",
"start:dist": "node --dns-result-order=ipv4first dist/src/index.js",
"start:bun": "bun run --dns-result-order=ipv4first src/index.js",
"start:bun": "bun run --dns-result-order=ipv4first src/index.ts",
"type-check": "tsc --noEmit",
"prepare": "husky"
},
"type": "module",
"main": "src/index.ts",
"engines": {
"node": ">=22.22.2"
},
"dependencies": {
"@alexanderolsen/libsamplerate-js": "^2.1.2",
"@ecliptia/faad2-wasm": "2.11.2-ecliptia.2",
Expand All @@ -21,21 +24,21 @@
"@toddynnn/symphonia-decoder": "1.0.6",
"@toddynnn/voice-opus": "^1.0.1",
"fastest-validator": "^1.19.1",
"jsdom": "^27.4.0",
"jsdom": "^29.0.2",
"mp4box": "^2.3.0",
"prom-client": "^15.1.3",
"proxy-agent": "^6.5.0"
"proxy-agent": "^8.0.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.0",
"@commitlint/cli": "20.3.0",
"@commitlint/config-conventional": "20.3.0",
"@types/bun": "^1.3.8",
"@types/jsdom": "^27.0.0",
"@types/node": "^25.2.1",
"dotenv": "^17.3.1",
"@biomejs/biome": "^2.4.12",
"@commitlint/cli": "20.5.0",
"@commitlint/config-conventional": "20.5.0",
"@types/bun": "^1.3.12",
"@types/jsdom": "^28.0.1",
"@types/node": "^25.6.0",
"dotenv": "^17.4.2",
"husky": "9.1.7",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
"typescript": "^6.0.3"
}
}
2 changes: 1 addition & 1 deletion dist/src/api/loadLyrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function getLoadLyricsRuntime(nodelink) {
const runtime = nodelink;
if (runtime.sourceWorkerManager === undefined ||
runtime.workerManager === undefined ||
!runtime.lyrics) {
runtime.lyrics === undefined) {
return null;
}
return runtime;
Expand Down
161 changes: 161 additions & 0 deletions dist/src/api/sessions.id.players.id.sponsorblock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { sendErrorResponse } from "../utils.js";
/**
* Builds a strongly typed runtime view for the SponsorBlock route.
*
* @param nodelink - Router-facing runtime instance.
* @returns Runtime compatible with the route, or `null` when the required
* session manager field is unavailable.
*/
function getSponsorBlockRuntime(nodelink) {
const runtime = nodelink;
if (!runtime.sessions || typeof runtime.sessions.get !== 'function') {
return null;
}
return runtime;
}
/**
* Extracts and validates the path parameters used by the route.
*
* @param parsedUrl - Parsed request URL.
* @returns Validated path parameters, or `null` when validation fails.
*/
function getPathParams(parsedUrl) {
const pathParts = parsedUrl.pathname.split('/');
const sessionId = pathParts[3];
const guildId = pathParts[5];
if (!sessionId || !guildId) {
return null;
}
if (!/^\d{17,20}$/.test(guildId)) {
return null;
}
return {
sessionId,
guildId
};
}
/**
* Handles `GET /sessions/:id/players/:guildId/sponsorblock`.
*/
async function handleGetSponsorBlock(req, res, pathParams, runtime, sendResponse) {
const session = runtime.sessions.get(pathParams.sessionId);
if (!session) {
sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '');
return;
}
try {
const state = session.players.getSponsorBlock(pathParams.guildId);
sendResponse(req, res, state, 200);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Player not found';
sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '');
}
}
/**
* Handles `PATCH /sessions/:id/players/:guildId/sponsorblock`.
*/
async function handlePatchSponsorBlock(req, res, pathParams, runtime, sendResponse) {
const session = runtime.sessions.get(pathParams.sessionId);
if (!session) {
sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '');
return;
}
const body = req.body;
if (!body || typeof body !== 'object' || Array.isArray(body)) {
sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid body', req.url || '');
return;
}
try {
session.players.updateSponsorBlock(pathParams.guildId, {
enabled: body.enabled,
categories: body.categories,
actionTypes: body.actionTypes,
skipMarginMs: body.skipMarginMs
});
sendResponse(req, res, session.players.getSponsorBlock(pathParams.guildId), 200);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Player not found';
sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '');
}
}
/**
* Handles `POST /sessions/:id/players/:guildId/sponsorblock`.
*/
async function handlePostSponsorBlock(req, res, pathParams, runtime, sendResponse) {
const session = runtime.sessions.get(pathParams.sessionId);
if (!session) {
sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '');
return;
}
const body = req.body;
if (!body?.segments || !Array.isArray(body.segments)) {
sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid segments array', req.url || '');
return;
}
try {
session.players.setSponsorBlockSegments(pathParams.guildId, body.segments);
sendResponse(req, res, session.players.getSponsorBlock(pathParams.guildId), 200);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Player not found';
sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '');
}
}
/**
* Handles `DELETE /sessions/:id/players/:guildId/sponsorblock`.
*/
async function handleDeleteSponsorBlock(req, res, pathParams, runtime) {
const session = runtime.sessions.get(pathParams.sessionId);
if (!session) {
sendErrorResponse(req, res, 404, 'Not Found', "The provided sessionId doesn't exist.", req.url || '');
return;
}
try {
session.players.clearSponsorBlock(pathParams.guildId);
res.writeHead(204);
res.end();
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Player not found';
sendErrorResponse(req, res, 404, 'Not Found', errorMessage, req.url || '');
}
}
/**
* Handles requests for the SponsorBlock route.
*/
async function handler(nodelink, req, res, sendResponse, parsedUrl) {
const runtime = getSponsorBlockRuntime(nodelink);
if (!runtime) {
sendErrorResponse(req, res, 500, 'Internal Server Error', 'SponsorBlock runtime contract is incomplete.', parsedUrl.pathname, true);
return;
}
const pathParams = getPathParams(parsedUrl);
if (!pathParams) {
sendErrorResponse(req, res, 400, 'Bad Request', 'Invalid path parameters', parsedUrl.pathname, true);
return;
}
if (req.method === 'GET') {
await handleGetSponsorBlock(req, res, pathParams, runtime, sendResponse);
return;
}
if (req.method === 'PATCH') {
await handlePatchSponsorBlock(req, res, pathParams, runtime, sendResponse);
return;
}
if (req.method === 'POST') {
await handlePostSponsorBlock(req, res, pathParams, runtime, sendResponse);
return;
}
if (req.method === 'DELETE') {
await handleDeleteSponsorBlock(req, res, pathParams, runtime);
return;
}
sendErrorResponse(req, res, 405, 'Method Not Allowed', 'Method Not Allowed', parsedUrl.pathname);
}
const sponsorBlockRoute = {
handler,
methods: ['GET', 'POST', 'PATCH', 'DELETE']
};
export default sponsorBlockRoute;
6 changes: 5 additions & 1 deletion dist/src/api/sessions.id.players.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ function getPlayerPatchPayload(body) {
typeof encodedTrack !== 'string') {
return null;
}
const position = payload.position;
const position = payload.position ?? payload.startTime;
if (position !== undefined &&
(typeof position !== 'number' || !Number.isFinite(position) || position < 0)) {
return null;
Expand Down Expand Up @@ -231,6 +231,10 @@ function getPlayerPatchPayload(body) {
? null
: undefined,
position,
startTime: typeof payload.startTime === 'number' &&
Number.isFinite(payload.startTime)
? payload.startTime
: undefined,
endTime: typeof endTime === 'number'
? endTime
: endTime === null
Expand Down
2 changes: 1 addition & 1 deletion dist/src/api/trackstream.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function getItagFromQuery(parsedUrl) {
*/
function getTrackStreamRuntime(nodelink) {
const runtime = nodelink;
if (runtime.workerManager === undefined || !runtime.sources) {
if (runtime.workerManager === undefined || runtime.sources === undefined) {
return null;
}
return runtime;
Expand Down
5 changes: 5 additions & 0 deletions dist/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const SEMVER_PATTERN = /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<p
* @public
*/
export const PATH_VERSION = 'v4';
export const MINIMUM_NODE_VERSION = '22.22.2';
/**
* HTTP status codes that indicate a redirect response
*
Expand Down Expand Up @@ -179,6 +180,10 @@ export const GatewayEvents = {
TRACK_STUCK: 'TrackStuckEvent',
/** An exception occurred while playing track */
TRACK_EXCEPTION: 'TrackExceptionEvent',
/** SponsorBlock segments were loaded */
SPONSORBLOCK_SEGMENTS_LOADED: 'SponsorBlockSegmentsLoadedEvent',
/** SponsorBlock segment was skipped */
SPONSORBLOCK_SEGMENT_SKIPPED: 'SponsorBlockSegmentSkippedEvent',
/** Player position/state update */
PLAYER_UPDATE: 'playerUpdate',
/** Voice connection status changed */
Expand Down
Loading
Loading