diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js
index 5c1b235e..239f5305 100644
--- a/dist/src/sources/applemusic.js
+++ b/dist/src/sources/applemusic.js
@@ -1,4 +1,4 @@
-import { encodeTrack, getBestMatch, http1makeRequest } from '../utils.js';
+import { capTotalTracks, encodeTrack, getBestMatch, http1makeRequest } from '../utils.js';
/**
* Apple Music source implementation.
* Provides integration with the Apple Music Catalog API for track resolution and searching.
@@ -360,7 +360,15 @@ export default class AppleMusicSource {
selectedTrack: 0
},
tracks,
- pluginInfo: {}
+ pluginInfo: {
+ type: 'playlist',
+ identifier: id,
+ url: playlist.attributes?.url ??
+ `https://music.apple.com/${country}/playlist/${id}`,
+ artworkUrl: this.parseArtwork(playlist.attributes?.artwork) || '',
+ author: playlist.attributes?.curatorName?.trim() || '',
+ totalTracks: capTotalTracks(playlist.relationships?.tracks?.meta?.total ?? tracks.length, this.nodelink.options.playback?.maxPlaylistLength)
+ }
}
};
}
@@ -406,7 +414,12 @@ export default class AppleMusicSource {
const pages = Math.ceil(total / limit);
const maxPages = parent.type === 'albums' ? this.albumPageLimit : this.playlistPageLimit;
const allowed = maxPages > 0 ? Math.min(pages, maxPages) : pages;
- const artwork = this.parseArtwork(parent.attributes?.artwork);
+ // Album tracks share the album's cover. Playlist tracks come from many
+ // albums and carry their own artwork, so the playlist cover must not
+ // replace it.
+ const artwork = parent.type === 'albums'
+ ? this.parseArtwork(parent.attributes?.artwork)
+ : null;
const editorialVideo = this.extractVideoUrl(parent.attributes);
const results = baseTracks
.map((t) => this.buildTrack(t, artwork, editorialVideo))
diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js
index 24075f23..9caea0b7 100644
--- a/dist/src/sources/deezer.js
+++ b/dist/src/sources/deezer.js
@@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
import { PassThrough } from 'node:stream';
import BlowfishCBC from '../decrypters/blowfish-cbc.js';
-import { encodeTrack, getBestMatch, http1makeRequest, logger, makeRequest } from '../utils.js';
+import { capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, logger, makeRequest } from '../utils.js';
/**
* Static IV used by Deezer's Blowfish-CBC chunk decryption scheme.
*/
@@ -282,9 +282,28 @@ export default class DeezerSource {
tracksResponse.data.length === 0) {
return this.createException(tracksError ?? 'Could not fetch playlist tracks.', 'common');
}
- const artworkUrl = entity.cover_xl ?? entity.picture_xl ?? null;
+ // Album track listings don't embed an `album` object, so their tracks
+ // fall back to the album's own cover. Playlist tracks do carry one, so
+ // they must keep their own album cover instead of the playlist picture.
+ const trackArtworkFallback = type === 'album'
+ ? (entity.cover_xl ??
+ entity.cover_big ??
+ entity.cover_medium ??
+ null)
+ : null;
+ const author = type === 'album'
+ ? entity.artist?.name?.trim() || ''
+ : entity.creator?.name?.trim() || entity.user?.name?.trim() || '';
+ const thumbnail = entity.cover_xl ||
+ entity.cover_big ||
+ entity.cover_medium ||
+ entity.picture_xl ||
+ entity.picture_big ||
+ entity.picture_medium ||
+ entity.picture ||
+ '';
const tracks = tracksResponse.data
- .map((item) => this.buildTrack(item, artworkUrl))
+ .map((item) => this.buildTrack(item, trackArtworkFallback))
.filter((track) => track !== null);
if (tracks.length === 0)
return { loadType: 'empty', data: {} };
@@ -295,7 +314,14 @@ export default class DeezerSource {
name: entity.title ?? 'Unknown Deezer Collection',
selectedTrack: 0
},
- pluginInfo: {},
+ pluginInfo: {
+ type,
+ identifier: String(id),
+ url: entity.link ?? `https://www.deezer.com/${type}/${id}`,
+ artworkUrl: thumbnail,
+ author,
+ totalTracks: capTotalTracks(entity.nb_tracks ?? tracks.length, this.config.playback?.maxPlaylistLength)
+ },
tracks
}
};
diff --git a/dist/src/sources/qobuz.js b/dist/src/sources/qobuz.js
index a46a78b1..83c442f6 100644
--- a/dist/src/sources/qobuz.js
+++ b/dist/src/sources/qobuz.js
@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
-import { encodeTrack, getBestMatch, http1makeRequest, logger } from '../utils.js';
+import { capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, logger } from '../utils.js';
const API_URL = 'https://www.qobuz.com/api.json/0.2';
const WEB_PLAYER_BASE_URL = 'https://play.qobuz.com';
/**
@@ -311,7 +311,16 @@ export default class QobuzSource {
selectedTrack: 0
},
tracks,
- pluginInfo: {}
+ pluginInfo: {
+ type: 'playlist',
+ identifier: id,
+ url: `https://open.qobuz.com/playlist/${id}`,
+ artworkUrl: this.getPlaylistCoverUrl(data),
+ author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '',
+ totalTracks: capTotalTracks(this.asNumber(data.tracks_count) ??
+ this.asNumber(tracksBlock.total) ??
+ tracks.length, max)
+ }
}
};
}
@@ -516,6 +525,24 @@ export default class QobuzSource {
const encoded = (seed + info + extras).slice(0, -44);
return Buffer.from(encoded, 'base64').toString();
}
+ /**
+ * Picks the cover image for a playlist payload.
+ * Qobuz returns up to four track covers per playlist at several sizes; the
+ * first one at the largest size is used as the thumbnail.
+ * @param playlist - Raw `/playlist/get` payload.
+ * @returns The cover URL, or an empty string when none is present.
+ */
+ getPlaylistCoverUrl(playlist) {
+ for (const key of ['images300', 'images150', 'images']) {
+ const images = playlist[key];
+ if (!Array.isArray(images))
+ continue;
+ const first = images.find((url) => typeof url === 'string' && url.length > 0);
+ if (first)
+ return first;
+ }
+ return '';
+ }
/**
* Builds encoded track payload.
* @param item - Qobuz track object.
diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js
index 469ab79f..ca170ece 100644
--- a/dist/src/sources/soundcloud.js
+++ b/dist/src/sources/soundcloud.js
@@ -1,7 +1,7 @@
import { PassThrough } from 'node:stream';
import HLSHandler from '../playback/hls/HLSHandler.js';
import { SEARCH_TYPE_MAP } from '../typings/sources/soundcloud.types.js';
-import { encodeTrack, http1makeRequest, logger, makeRequest } from '../utils.js';
+import { capTotalTracks, encodeTrack, http1makeRequest, logger, makeRequest } from '../utils.js';
const BASE_URL = 'https://api-v2.soundcloud.com';
const SOUNDCLOUD_URL = 'https://soundcloud.com';
const ASSET_PATTERN = /https:\/\/a-v2\.sndcdn\.com\/assets\/[a-zA-Z0-9-]+\.js/g;
@@ -482,11 +482,30 @@ export default class SoundCloudSource {
name: body.title || 'Untitled playlist',
selectedTrack: 0
},
- pluginInfo: {},
+ pluginInfo: {
+ type: 'playlist',
+ identifier: String(body.id ?? ''),
+ url: body.permalink_url ?? '',
+ artworkUrl: this._upgradeArtworkUrl(body.artwork_url ??
+ tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl),
+ author: body.user?.username?.trim() || '',
+ totalTracks: capTotalTracks(body.track_count ?? body.tracks?.length ?? tracks.length, limit)
+ },
tracks
}
};
}
+ /**
+ * Upgrades a SoundCloud artwork URL to the 500x500 variant.
+ * The API returns the 100x100 `-large` size; `original` is left untouched.
+ * @param url - Artwork URL from the API.
+ * @returns The upgraded URL, or an empty string when there is none.
+ */
+ _upgradeArtworkUrl(url) {
+ if (!url)
+ return '';
+ return url.replace(/-(?:large|small|tiny|badge|mini|crop|t\d+x\d+)(\.[a-z0-9]+)(?=$|\?)/i, '-t500x500$1');
+ }
_processTracks(collection) {
const max = this.nodelink.options.search.maxResults ?? 50;
const tracks = [];
diff --git a/dist/src/sources/spotify.js b/dist/src/sources/spotify.js
index a722191a..f18517b3 100644
--- a/dist/src/sources/spotify.js
+++ b/dist/src/sources/spotify.js
@@ -1,6 +1,6 @@
import { getLocalToken } from '../modules/spotifyAuth.js';
import { fetchCanvas } from '../modules/spotifyCanvas.js';
-import { encodeTrack, getBestMatch, http1makeRequest, logger } from '../utils.js';
+import { capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, logger } from '../utils.js';
/**
* Base URL for the official Spotify Web API.
* Used for standard catalog data and public resource resolution.
@@ -1001,6 +1001,27 @@ export default class SpotifySource {
}
: { loadType: 'empty', data: {} };
}
+ /**
+ * Picks the highest-resolution image URL from a list of sources.
+ * Sources without dimensions (e.g. user-uploaded playlist covers) count as
+ * width 0, so the first one wins when none report a size.
+ *
+ * @param sources - Image sources from a Spotify GraphQL response.
+ * @returns The best URL, or an empty string when there is none.
+ * @internal
+ */
+ _pickLargestImage(sources) {
+ let best = '';
+ let bestWidth = -1;
+ for (const source of sources ?? []) {
+ const width = source.width ?? 0;
+ if (source.url && width > bestWidth) {
+ best = source.url;
+ bestWidth = width;
+ }
+ }
+ return best;
+ }
/**
* Resolves a playlist collection and its wrapped tracks.
*
@@ -1012,6 +1033,17 @@ export default class SpotifySource {
const maxTracks = this.nodelink.options.playback.maxPlaylistLength || 1000;
const tracks = [];
let name = 'Unknown Playlist';
+ let author = '';
+ let thumbnail = '';
+ let totalTracks = 0;
+ const buildPluginInfo = () => ({
+ type: 'playlist',
+ identifier: id,
+ url: `https://open.spotify.com/playlist/${id}`,
+ artworkUrl: thumbnail,
+ author,
+ totalTracks: capTotalTracks(totalTracks || tracks.length, maxTracks)
+ });
if (this.anonymousToken || id.startsWith('37i9dQZ')) {
let offset = 0;
const limit = 100;
@@ -1027,7 +1059,10 @@ export default class SpotifySource {
break;
if (offset === 0) {
name = data.playlistV2.name;
+ author = data.playlistV2.ownerV2?.data?.name?.trim() || '';
+ thumbnail = this._pickLargestImage(data.playlistV2.images?.items?.[0]?.sources);
total = data.playlistV2.content?.totalCount || 0;
+ totalTracks = total;
}
const items = data.playlistV2.content?.items || [];
if (items.length === 0)
@@ -1054,19 +1089,24 @@ export default class SpotifySource {
data: {
info: { name, selectedTrack: 0 },
tracks,
- pluginInfo: {}
+ pluginInfo: buildPluginInfo()
}
};
}
}
const metaRes = await this._apiRequest(`/playlists/${id}?market=${this.market}`);
- if (metaRes)
+ if (metaRes) {
name = metaRes.name;
+ author = metaRes.owner?.display_name?.trim() || author;
+ thumbnail = metaRes.images?.[0]?.url || thumbnail;
+ }
let nextUrl = `/playlists/${id}/items?market=${this.market}`;
while (nextUrl && tracks.length < maxTracks) {
const itemsRes = await this._apiRequest(nextUrl);
if (!itemsRes?.items || itemsRes.items.length === 0)
break;
+ if (!totalTracks)
+ totalTracks = itemsRes.total;
for (const it of itemsRes.items) {
const node = it.item || it.track;
if (!node)
@@ -1087,7 +1127,7 @@ export default class SpotifySource {
data: {
info: { name, selectedTrack: 0 },
tracks,
- pluginInfo: {}
+ pluginInfo: buildPluginInfo()
}
}
: { loadType: 'empty', data: {} };
diff --git a/dist/src/sources/tidal.js b/dist/src/sources/tidal.js
index 2bfd1ff5..5ef7ee8d 100644
--- a/dist/src/sources/tidal.js
+++ b/dist/src/sources/tidal.js
@@ -4,7 +4,7 @@
*/
import path from 'node:path';
import { PassThrough } from 'node:stream';
-import { encodeTrack, getBestMatch, http1makeRequest, logger, makeRequest } from '../utils.js';
+import { capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, logger, makeRequest } from '../utils.js';
const API_BASE = 'https://api.tidal.com/v1/';
const CACHE_VALIDITY_DAYS = 7;
const TIDAL_ASSET_URL = 'https://tidal.com/assets/index-CJ0DsMmf.js';
@@ -489,7 +489,15 @@ export default class TidalSource {
selectedTrack: 0
},
tracks,
- pluginInfo: {}
+ pluginInfo: {
+ type: 'playlist',
+ identifier: id,
+ url: `https://tidal.com/playlist/${id}`,
+ artworkUrl: this.getPlaylistCoverUrl(playlistData),
+ // The playlist endpoint only exposes `creator.id`, not a name.
+ author: '',
+ totalTracks: capTotalTracks(totalTracks, this.nodelink.options.playback?.maxPlaylistLength)
+ }
}
};
}
@@ -566,6 +574,30 @@ export default class TidalSource {
}
return this.asRecord(body);
}
+ /**
+ * Builds a Tidal CDN image URL from an image UUID.
+ * @param uuid - Image UUID, dash-separated (e.g. `a178483d-e537-...`).
+ * @param size - Dimensions such as `1080x1080`.
+ * @returns The resource URL.
+ */
+ buildImageUrl(uuid, size) {
+ return `https://resources.tidal.com/images/${uuid.replace(/-/g, '/')}/${size}.jpg`;
+ }
+ /**
+ * Picks the cover image for a playlist payload.
+ * Prefers the square cover, falling back to the 3:2 `image`.
+ * @param playlist - Raw playlist payload.
+ * @returns The cover URL, or an empty string when none is present.
+ */
+ getPlaylistCoverUrl(playlist) {
+ const square = this.asString(playlist.squareImage);
+ if (square)
+ return this.buildImageUrl(square, '1080x1080');
+ const image = this.asString(playlist.image);
+ if (image)
+ return this.buildImageUrl(image, '1080x720');
+ return '';
+ }
/**
* Creates encoded track payload from raw Tidal item.
* @param item - Raw Tidal track object.
@@ -591,9 +623,7 @@ export default class TidalSource {
title: this.asString(item.title) || 'Unknown Title',
uri: this.asString(item.url) ||
`https://tidal.com/browse/track/${identifier}`,
- artworkUrl: cover
- ? `https://resources.tidal.com/images/${cover.replace(/-/g, '/')}/1280x1280.jpg`
- : null,
+ artworkUrl: cover ? this.buildImageUrl(cover, '1280x1280') : null,
isrc: this.asString(item.isrc) || null,
sourceName: 'tidal'
};
diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js
index 37c69a7b..bb08a202 100644
--- a/dist/src/sources/youtube/YouTube.js
+++ b/dist/src/sources/youtube/YouTube.js
@@ -1,10 +1,11 @@
import { PassThrough } from 'node:stream';
import HLSHandler from '../../playback/hls/HLSHandler.js';
-import { encodeTrack, getBestMatch, http1makeRequest, logger, makeRequest } from '../../utils.js';
+import { capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, logger, makeRequest } from '../../utils.js';
import CipherManager from './CipherManager.js';
import { checkURLType, YOUTUBE_CONSTANTS, YOUTUBE_WEBM_ITAGS } from './common.js';
import YouTubeLiveChat from './LiveChat.js';
import OAuth from './OAuth.js';
+import { parsePlaylistMeta } from './playlistMeta.js';
import { SabrStream } from './sabr/sabr.js';
/** Size in bytes of each range-request chunk for direct HTTP streaming. */
const CHUNK_SIZE = 64 * 1024;
@@ -576,6 +577,9 @@ export default class YouTubeSource {
*/
async resolve(url, type) {
const result = await this._resolveWorker(url, type);
+ if (result.loadType === 'playlist') {
+ await this._applyPlaylistMeta(url, result.data);
+ }
if (this.config.mirrorOfficialAlbums &&
url.includes('list=OLAK') &&
result.loadType === 'playlist') {
@@ -595,6 +599,67 @@ export default class YouTubeSource {
}
return result;
}
+ /**
+ * Fills the collection metadata (`type`, `identifier`, `url`, `artworkUrl`,
+ * `author`, `totalTracks`) in `pluginInfo` on a resolved playlist.
+ *
+ * The innertube `next`/`browse` responses used by the clients don't carry the
+ * playlist owner or cover art, so those are read from the public playlist
+ * page instead. Best-effort: any failure leaves them empty, and values a
+ * client already provided are never overwritten.
+ * @internal
+ */
+ async _applyPlaylistMeta(url, playlist) {
+ if (!playlist?.info)
+ return;
+ const playlistId = url.match(/[?&]list=([\w-]+)/)?.[1];
+ if (!playlistId)
+ return;
+ const current = playlist.pluginInfo ?? {};
+ const meta = current.author && current.artworkUrl
+ ? { author: '', thumbnail: '' }
+ : await this._fetchPlaylistMeta(playlistId);
+ playlist.pluginInfo = {
+ ...current,
+ type: current.type ?? 'playlist',
+ identifier: current.identifier ?? playlistId,
+ url: current.url ?? `https://www.youtube.com/playlist?list=${playlistId}`,
+ artworkUrl: current.artworkUrl || meta.thumbnail,
+ author: current.author || meta.author,
+ totalTracks: capTotalTracks(current.totalTracks ?? playlist.tracks.length, this.nodelink.options.playback?.maxPlaylistLength)
+ };
+ }
+ /**
+ * Scrapes the playlist page for its owner and thumbnail.
+ * @param playlistId - YouTube playlist ID (`PL...`, `OLAK...`, etc.).
+ * @internal
+ */
+ async _fetchPlaylistMeta(playlistId) {
+ const empty = { author: '', thumbnail: '' };
+ const proxy = this.getProxy();
+ const startedAt = Date.now();
+ try {
+ const { body, statusCode } = await makeRequest(`https://www.youtube.com/playlist?list=${encodeURIComponent(playlistId)}&hl=en`, {
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ // Skips the EU cookie-consent interstitial.
+ Cookie: 'SOCS=CAI'
+ },
+ timeout: 5000,
+ proxy: proxy
+ });
+ this.reportProxyStatus(proxy, statusCode === 200, statusCode ?? 0, Date.now() - startedAt);
+ if (statusCode !== 200 || typeof body !== 'string')
+ return empty;
+ return parsePlaylistMeta(body);
+ }
+ catch (err) {
+ logger('debug', 'YouTube', `Failed to fetch playlist metadata for ${playlistId}: ${err instanceof Error ? err.message : String(err)}`);
+ return empty;
+ }
+ }
/**
* Internal worker for URL resolution.
* @internal
diff --git a/dist/src/sources/youtube/playlistMeta.js b/dist/src/sources/youtube/playlistMeta.js
new file mode 100644
index 00000000..2cc03e0a
--- /dev/null
+++ b/dist/src/sources/youtube/playlistMeta.js
@@ -0,0 +1,104 @@
+const HTML_ENTITIES = {
+ '&': '&',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ '<': '<',
+ '>': '>'
+};
+/** Decodes the handful of entities YouTube emits inside meta attributes. */
+function decodeHtmlEntities(value) {
+ return value.replace(/&(?:amp|quot|#39|#x27|lt|gt);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
+}
+/**
+ * Reads `og:image` from the page and returns a clean URL.
+ * `days_since_epoch` is a cache-buster that changes daily, so it is dropped to
+ * keep the URL stable between requests.
+ */
+function extractThumbnail(html) {
+ const metaTags = html.match(/]*>/gi) ?? [];
+ for (const tag of metaTags) {
+ if (!/\bproperty=["']og:image["']/i.test(tag))
+ continue;
+ const content = tag.match(/\bcontent=["']([^"']+)["']/i)?.[1];
+ if (!content)
+ continue;
+ // Edit the query string by hand: round-tripping through URLSearchParams
+ // would re-encode characters such as the trailing `=` in `sqp=...=`.
+ return decodeHtmlEntities(content)
+ .replace(/([?&])days_since_epoch=[^]*&?/, '$1')
+ .replace(/[?&]$/, '');
+ }
+ return '';
+}
+/**
+ * Depth-first search for the first object stored under `key`.
+ * Iterative so a deeply nested `ytInitialData` cannot overflow the stack.
+ */
+function findKey(root, key) {
+ const stack = [root];
+ while (stack.length > 0) {
+ const node = stack.pop();
+ if (!node || typeof node !== 'object')
+ continue;
+ const record = node;
+ if (key in record && record[key] && typeof record[key] === 'object') {
+ return record[key];
+ }
+ for (const value of Object.values(record)) {
+ if (value && typeof value === 'object')
+ stack.push(value);
+ }
+ }
+ return undefined;
+}
+/** Joins the text of a YouTube `{ runs: [...] }` / `{ simpleText }` object. */
+function textOf(value) {
+ if (!value || typeof value !== 'object')
+ return '';
+ const text = value;
+ if (typeof text.simpleText === 'string')
+ return text.simpleText;
+ if (Array.isArray(text.runs)) {
+ return text.runs
+ .map((run) => (typeof run?.text === 'string' ? run.text : ''))
+ .join('');
+ }
+ return '';
+}
+/**
+ * Reads the playlist owner from `ytInitialData`.
+ *
+ * Tries the classic sidebar layout first
+ * (`playlistSidebarSecondaryInfoRenderer.videoOwner.videoOwnerRenderer.title`),
+ * then the newer `playlistHeaderRenderer.ownerText`.
+ */
+function extractAuthor(html) {
+ const match = html.match(/ytInitialData\s*=\s*(\{[\s\S]*?\});\s*<\/script>/);
+ if (!match?.[1])
+ return '';
+ let data;
+ try {
+ data = JSON.parse(match[1]);
+ }
+ catch {
+ return '';
+ }
+ const sidebar = findKey(data, 'playlistSidebarSecondaryInfoRenderer');
+ const fromSidebar = textOf(sidebar?.videoOwner?.videoOwnerRenderer?.title);
+ if (fromSidebar)
+ return fromSidebar.trim();
+ const header = findKey(data, 'playlistHeaderRenderer');
+ return textOf(header?.ownerText).trim();
+}
+/**
+ * Extracts the author and thumbnail from the HTML of a YouTube playlist page.
+ * Never throws; missing values come back as empty strings.
+ * @param html - Raw HTML of `https://www.youtube.com/playlist?list=...`.
+ */
+export function parsePlaylistMeta(html) {
+ return {
+ author: extractAuthor(html),
+ thumbnail: extractThumbnail(html)
+ };
+}
diff --git a/dist/src/utils.js b/dist/src/utils.js
index b3b5ab78..1a14417f 100644
--- a/dist/src/utils.js
+++ b/dist/src/utils.js
@@ -2348,4 +2348,21 @@ async function fetchSponsorBlockSegments(videoId, categories, actionTypes, apiBa
return [];
}
}
-export { applyEnvOverrides, checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, defaultSanitizer, encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, getDependencyVersion, getGitInfo, getStats, getVersion, http1makeRequest, initLogger, LogSanitizer, logger, makeRequest, parseClient, parseSemver, sendErrorResponse, sendResponse, validateProperty, verifyDiscordID, verifyMethod };
+/**
+ * Caps a collection's reported track count at the configured
+ * `playback.maxPlaylistLength`.
+ *
+ * A source may hold more tracks than NodeLink will ever load, so the total it
+ * reports should not exceed what a client can actually receive. An unset,
+ * zero, negative or non-numeric cap means "no cap".
+ *
+ * @param total - Track count as reported by the source.
+ * @param max - Configured `playback.maxPlaylistLength`.
+ * @returns `Math.min(total, max)`, or `total` when there is no usable cap.
+ */
+function capTotalTracks(total, max) {
+ return typeof max === 'number' && Number.isFinite(max) && max > 0
+ ? Math.min(total, max)
+ : total;
+}
+export { applyEnvOverrides, capTotalTracks, checkDependencyUpdates, checkForUpdates, cleanupHttpAgents, cleanupLogger, decodeTrack, defaultSanitizer, encodeTrack, fetchSponsorBlockSegments, generateRandomLetters, getDependencyVersion, getGitInfo, getStats, getVersion, http1makeRequest, initLogger, LogSanitizer, logger, makeRequest, parseClient, parseSemver, sendErrorResponse, sendResponse, validateProperty, verifyDiscordID, verifyMethod };
diff --git a/src/sources/applemusic.ts b/src/sources/applemusic.ts
index 5e42ebec..b36715c6 100644
--- a/src/sources/applemusic.ts
+++ b/src/sources/applemusic.ts
@@ -8,7 +8,12 @@ import type {
TrackUrlResult,
WorkerNodeLink
} from '../typings/sources/source.types.ts'
-import { encodeTrack, getBestMatch, http1makeRequest } from '../utils.ts'
+import {
+ capTotalTracks,
+ encodeTrack,
+ getBestMatch,
+ http1makeRequest
+} from '../utils.ts'
/**
* Metadata for Apple Music catalog resource attributes.
@@ -530,7 +535,19 @@ export default class AppleMusicSource implements SourceInstance {
selectedTrack: 0
},
tracks,
- pluginInfo: {} as Record
+ pluginInfo: {
+ type: 'playlist',
+ identifier: id,
+ url:
+ playlist.attributes?.url ??
+ `https://music.apple.com/${country}/playlist/${id}`,
+ artworkUrl: this.parseArtwork(playlist.attributes?.artwork) || '',
+ author: playlist.attributes?.curatorName?.trim() || '',
+ totalTracks: capTotalTracks(
+ playlist.relationships?.tracks?.meta?.total ?? tracks.length,
+ this.nodelink.options.playback?.maxPlaylistLength
+ )
+ }
}
}
}
@@ -591,7 +608,13 @@ export default class AppleMusicSource implements SourceInstance {
parent.type === 'albums' ? this.albumPageLimit : this.playlistPageLimit
const allowed = maxPages > 0 ? Math.min(pages, maxPages) : pages
- const artwork = this.parseArtwork(parent.attributes?.artwork)
+ // Album tracks share the album's cover. Playlist tracks come from many
+ // albums and carry their own artwork, so the playlist cover must not
+ // replace it.
+ const artwork =
+ parent.type === 'albums'
+ ? this.parseArtwork(parent.attributes?.artwork)
+ : null
const editorialVideo = this.extractVideoUrl(parent.attributes)
const results = baseTracks
diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts
index 1b390830..c5181c0f 100644
--- a/src/sources/deezer.ts
+++ b/src/sources/deezer.ts
@@ -17,6 +17,7 @@ import type {
TrackEncodeInput
} from '../typings/utils.types.ts'
import {
+ capTotalTracks,
encodeTrack,
getBestMatch,
http1makeRequest,
@@ -256,6 +257,11 @@ interface DeezerEntity {
*/
cover_medium?: string | null
+ /**
+ * Redirecting playlist or artist artwork URL, used as a last resort.
+ */
+ picture?: string | null
+
/**
* Largest playlist or artist artwork variant.
*/
@@ -951,9 +957,31 @@ export default class DeezerSource {
)
}
- const artworkUrl = entity.cover_xl ?? entity.picture_xl ?? null
+ // Album track listings don't embed an `album` object, so their tracks
+ // fall back to the album's own cover. Playlist tracks do carry one, so
+ // they must keep their own album cover instead of the playlist picture.
+ const trackArtworkFallback =
+ type === 'album'
+ ? (entity.cover_xl ??
+ entity.cover_big ??
+ entity.cover_medium ??
+ null)
+ : null
+ const author =
+ type === 'album'
+ ? entity.artist?.name?.trim() || ''
+ : entity.creator?.name?.trim() || entity.user?.name?.trim() || ''
+ const thumbnail =
+ entity.cover_xl ||
+ entity.cover_big ||
+ entity.cover_medium ||
+ entity.picture_xl ||
+ entity.picture_big ||
+ entity.picture_medium ||
+ entity.picture ||
+ ''
const tracks = tracksResponse.data
- .map((item) => this.buildTrack(item, artworkUrl))
+ .map((item) => this.buildTrack(item, trackArtworkFallback))
.filter((track): track is DeezerTrackData => track !== null)
if (tracks.length === 0) return { loadType: 'empty', data: {} }
@@ -965,7 +993,17 @@ export default class DeezerSource {
name: entity.title ?? 'Unknown Deezer Collection',
selectedTrack: 0
},
- pluginInfo: {} as Record,
+ pluginInfo: {
+ type,
+ identifier: String(id),
+ url: entity.link ?? `https://www.deezer.com/${type}/${id}`,
+ artworkUrl: thumbnail,
+ author,
+ totalTracks: capTotalTracks(
+ entity.nb_tracks ?? tracks.length,
+ this.config.playback?.maxPlaylistLength
+ )
+ },
tracks
} satisfies DeezerPlaylistData
}
diff --git a/src/sources/qobuz.ts b/src/sources/qobuz.ts
index 8971c08d..84cfc6b0 100644
--- a/src/sources/qobuz.ts
+++ b/src/sources/qobuz.ts
@@ -10,6 +10,7 @@ import type {
TrackEncodeInput
} from '../typings/utils.types.ts'
import {
+ capTotalTracks,
encodeTrack,
getBestMatch,
http1makeRequest,
@@ -409,7 +410,19 @@ export default class QobuzSource {
selectedTrack: 0
},
tracks,
- pluginInfo: {} as Record
+ pluginInfo: {
+ type: 'playlist',
+ identifier: id,
+ url: `https://open.qobuz.com/playlist/${id}`,
+ artworkUrl: this.getPlaylistCoverUrl(data),
+ author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '',
+ totalTracks: capTotalTracks(
+ this.asNumber(data.tracks_count) ??
+ this.asNumber(tracksBlock.total) ??
+ tracks.length,
+ max
+ )
+ }
}
}
}
@@ -682,6 +695,25 @@ export default class QobuzSource {
return Buffer.from(encoded, 'base64').toString()
}
+ /**
+ * Picks the cover image for a playlist payload.
+ * Qobuz returns up to four track covers per playlist at several sizes; the
+ * first one at the largest size is used as the thumbnail.
+ * @param playlist - Raw `/playlist/get` payload.
+ * @returns The cover URL, or an empty string when none is present.
+ */
+ private getPlaylistCoverUrl(playlist: Record): string {
+ for (const key of ['images300', 'images150', 'images']) {
+ const images = playlist[key]
+ if (!Array.isArray(images)) continue
+ const first = images.find(
+ (url): url is string => typeof url === 'string' && url.length > 0
+ )
+ if (first) return first
+ }
+ return ''
+ }
+
/**
* Builds encoded track payload.
* @param item - Qobuz track object.
diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts
index 1f115370..dea263f0 100644
--- a/src/sources/soundcloud.ts
+++ b/src/sources/soundcloud.ts
@@ -20,7 +20,13 @@ import type {
TrackUrlResult
} from '../typings/sources/source.types.ts'
import type { TrackEncodeInput } from '../typings/utils.types.ts'
-import { encodeTrack, http1makeRequest, logger, makeRequest } from '../utils.ts'
+import {
+ capTotalTracks,
+ encodeTrack,
+ http1makeRequest,
+ logger,
+ makeRequest
+} from '../utils.ts'
const BASE_URL = 'https://api-v2.soundcloud.com'
const SOUNDCLOUD_URL = 'https://soundcloud.com'
@@ -681,12 +687,39 @@ export default class SoundCloudSource implements SoundCloudSourceState {
name: body.title || 'Untitled playlist',
selectedTrack: 0
},
- pluginInfo: {},
+ pluginInfo: {
+ type: 'playlist',
+ identifier: String(body.id ?? ''),
+ url: body.permalink_url ?? '',
+ artworkUrl: this._upgradeArtworkUrl(
+ body.artwork_url ??
+ tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl
+ ),
+ author: body.user?.username?.trim() || '',
+ totalTracks: capTotalTracks(
+ body.track_count ?? body.tracks?.length ?? tracks.length,
+ limit
+ )
+ },
tracks
}
}
}
+ /**
+ * Upgrades a SoundCloud artwork URL to the 500x500 variant.
+ * The API returns the 100x100 `-large` size; `original` is left untouched.
+ * @param url - Artwork URL from the API.
+ * @returns The upgraded URL, or an empty string when there is none.
+ */
+ _upgradeArtworkUrl(url?: string | null): string {
+ if (!url) return ''
+ return url.replace(
+ /-(?:large|small|tiny|badge|mini|crop|t\d+x\d+)(\.[a-z0-9]+)(?=$|\?)/i,
+ '-t500x500$1'
+ )
+ }
+
_processTracks(
collection: SoundCloudApiSearchItem[]
): SoundCloudTrackDataResult[] {
diff --git a/src/sources/spotify.ts b/src/sources/spotify.ts
index 5d6bc31b..7ddd1ce7 100644
--- a/src/sources/spotify.ts
+++ b/src/sources/spotify.ts
@@ -2,6 +2,7 @@ import { getLocalToken } from '../modules/spotifyAuth.ts'
import { fetchCanvas } from '../modules/spotifyCanvas.ts'
import type { SpotifySourceConfig } from '../typings/config/config.types.ts'
import type {
+ PlaylistPluginInfo,
SourceInstance,
SourceResult,
TrackData,
@@ -20,11 +21,13 @@ import type {
SpotifyGraphQLTrack,
SpotifyMetadataResponse,
SpotifyPagingObject,
+ SpotifyPlaylist,
SpotifyPlaylistItem,
SpotifyTokenType,
SpotifyTrack
} from '../typings/sources/spotify.types.ts'
import {
+ capTotalTracks,
encodeTrack,
getBestMatch,
http1makeRequest,
@@ -1212,6 +1215,30 @@ export default class SpotifySource implements SourceInstance {
: { loadType: 'empty', data: {} }
}
+ /**
+ * Picks the highest-resolution image URL from a list of sources.
+ * Sources without dimensions (e.g. user-uploaded playlist covers) count as
+ * width 0, so the first one wins when none report a size.
+ *
+ * @param sources - Image sources from a Spotify GraphQL response.
+ * @returns The best URL, or an empty string when there is none.
+ * @internal
+ */
+ private _pickLargestImage(
+ sources?: Array<{ url: string; width?: number | null }> | null
+ ): string {
+ let best = ''
+ let bestWidth = -1
+ for (const source of sources ?? []) {
+ const width = source.width ?? 0
+ if (source.url && width > bestWidth) {
+ best = source.url
+ bestWidth = width
+ }
+ }
+ return best
+ }
+
/**
* Resolves a playlist collection and its wrapped tracks.
*
@@ -1224,6 +1251,18 @@ export default class SpotifySource implements SourceInstance {
(this.nodelink.options.playback.maxPlaylistLength as number) || 1000
const tracks: TrackData[] = []
let name = 'Unknown Playlist'
+ let author = ''
+ let thumbnail = ''
+ let totalTracks = 0
+
+ const buildPluginInfo = (): PlaylistPluginInfo => ({
+ type: 'playlist',
+ identifier: id,
+ url: `https://open.spotify.com/playlist/${id}`,
+ artworkUrl: thumbnail,
+ author,
+ totalTracks: capTotalTracks(totalTracks || tracks.length, maxTracks)
+ })
if (this.anonymousToken || id.startsWith('37i9dQZ')) {
let offset = 0
@@ -1247,7 +1286,12 @@ export default class SpotifySource implements SourceInstance {
if (offset === 0) {
name = data.playlistV2.name
+ author = data.playlistV2.ownerV2?.data?.name?.trim() || ''
+ thumbnail = this._pickLargestImage(
+ data.playlistV2.images?.items?.[0]?.sources
+ )
total = data.playlistV2.content?.totalCount || 0
+ totalTracks = total
}
const items = data.playlistV2.content?.items || []
@@ -1273,16 +1317,20 @@ export default class SpotifySource implements SourceInstance {
data: {
info: { name, selectedTrack: 0 },
tracks,
- pluginInfo: {}
+ pluginInfo: buildPluginInfo()
}
}
}
}
- const metaRes = await this._apiRequest<{ name: string }>(
- `/playlists/${id}?market=${this.market}`
- )
- if (metaRes) name = metaRes.name
+ const metaRes = await this._apiRequest<
+ Pick
+ >(`/playlists/${id}?market=${this.market}`)
+ if (metaRes) {
+ name = metaRes.name
+ author = metaRes.owner?.display_name?.trim() || author
+ thumbnail = metaRes.images?.[0]?.url || thumbnail
+ }
let nextUrl: string | null = `/playlists/${id}/items?market=${this.market}`
while (nextUrl && tracks.length < maxTracks) {
@@ -1291,6 +1339,7 @@ export default class SpotifySource implements SourceInstance {
nextUrl
)
if (!itemsRes?.items || itemsRes.items.length === 0) break
+ if (!totalTracks) totalTracks = itemsRes.total
for (const it of itemsRes.items) {
const node = it.item || it.track
@@ -1311,7 +1360,7 @@ export default class SpotifySource implements SourceInstance {
data: {
info: { name, selectedTrack: 0 },
tracks,
- pluginInfo: {}
+ pluginInfo: buildPluginInfo()
}
}
: { loadType: 'empty', data: {} }
diff --git a/src/sources/tidal.ts b/src/sources/tidal.ts
index 9203a4fe..db5fbc66 100644
--- a/src/sources/tidal.ts
+++ b/src/sources/tidal.ts
@@ -17,6 +17,7 @@ import type {
TrackEncodeInput
} from '../typings/utils.types.ts'
import {
+ capTotalTracks,
encodeTrack,
getBestMatch,
http1makeRequest,
@@ -649,7 +650,18 @@ export default class TidalSource {
selectedTrack: 0
},
tracks,
- pluginInfo: {} as Record
+ pluginInfo: {
+ type: 'playlist',
+ identifier: id,
+ url: `https://tidal.com/playlist/${id}`,
+ artworkUrl: this.getPlaylistCoverUrl(playlistData),
+ // The playlist endpoint only exposes `creator.id`, not a name.
+ author: '',
+ totalTracks: capTotalTracks(
+ totalTracks,
+ this.nodelink.options.playback?.maxPlaylistLength
+ )
+ }
}
}
}
@@ -764,6 +776,32 @@ export default class TidalSource {
return this.asRecord(body)
}
+ /**
+ * Builds a Tidal CDN image URL from an image UUID.
+ * @param uuid - Image UUID, dash-separated (e.g. `a178483d-e537-...`).
+ * @param size - Dimensions such as `1080x1080`.
+ * @returns The resource URL.
+ */
+ private buildImageUrl(uuid: string, size: string): string {
+ return `https://resources.tidal.com/images/${uuid.replace(/-/g, '/')}/${size}.jpg`
+ }
+
+ /**
+ * Picks the cover image for a playlist payload.
+ * Prefers the square cover, falling back to the 3:2 `image`.
+ * @param playlist - Raw playlist payload.
+ * @returns The cover URL, or an empty string when none is present.
+ */
+ private getPlaylistCoverUrl(playlist: Record): string {
+ const square = this.asString(playlist.squareImage)
+ if (square) return this.buildImageUrl(square, '1080x1080')
+
+ const image = this.asString(playlist.image)
+ if (image) return this.buildImageUrl(image, '1080x720')
+
+ return ''
+ }
+
/**
* Creates encoded track payload from raw Tidal item.
* @param item - Raw Tidal track object.
@@ -796,9 +834,7 @@ export default class TidalSource {
uri:
this.asString(item.url) ||
`https://tidal.com/browse/track/${identifier}`,
- artworkUrl: cover
- ? `https://resources.tidal.com/images/${cover.replace(/-/g, '/')}/1280x1280.jpg`
- : null,
+ artworkUrl: cover ? this.buildImageUrl(cover, '1280x1280') : null,
isrc: this.asString(item.isrc) || null,
sourceName: 'tidal'
}
diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts
index a2009607..fb05b62d 100644
--- a/src/sources/youtube/YouTube.ts
+++ b/src/sources/youtube/YouTube.ts
@@ -2,6 +2,7 @@ import { PassThrough } from 'node:stream'
import HLSHandler from '../../playback/hls/HLSHandler.ts'
import type { SabrStreamConfig } from '../../typings/sources/sabr.types.ts'
import type {
+ PlaylistData,
SourceResult,
TrackInfo,
WorkerNodeLink
@@ -24,6 +25,7 @@ import type {
TrackEncodeInput
} from '../../typings/utils.types.ts'
import {
+ capTotalTracks,
encodeTrack,
getBestMatch,
http1makeRequest,
@@ -39,6 +41,7 @@ import {
} from './common.ts'
import YouTubeLiveChat from './LiveChat.ts'
import OAuth from './OAuth.ts'
+import { parsePlaylistMeta, type YouTubePlaylistMeta } from './playlistMeta.ts'
import { SabrStream } from './sabr/sabr.ts'
/** Size in bytes of each range-request chunk for direct HTTP streaming. */
@@ -801,6 +804,10 @@ export default class YouTubeSource {
async resolve(url: string, type?: string): Promise {
const result = await this._resolveWorker(url, type)
+ if (result.loadType === 'playlist') {
+ await this._applyPlaylistMeta(url, result.data as PlaylistData)
+ }
+
if (
this.config.mirrorOfficialAlbums &&
url.includes('list=OLAK') &&
@@ -826,6 +833,94 @@ export default class YouTubeSource {
return result
}
+ /**
+ * Fills the collection metadata (`type`, `identifier`, `url`, `artworkUrl`,
+ * `author`, `totalTracks`) in `pluginInfo` on a resolved playlist.
+ *
+ * The innertube `next`/`browse` responses used by the clients don't carry the
+ * playlist owner or cover art, so those are read from the public playlist
+ * page instead. Best-effort: any failure leaves them empty, and values a
+ * client already provided are never overwritten.
+ * @internal
+ */
+ private async _applyPlaylistMeta(
+ url: string,
+ playlist: PlaylistData
+ ): Promise {
+ if (!playlist?.info) return
+
+ const playlistId = url.match(/[?&]list=([\w-]+)/)?.[1]
+ if (!playlistId) return
+
+ const current = playlist.pluginInfo ?? {}
+ const meta =
+ current.author && current.artworkUrl
+ ? { author: '', thumbnail: '' }
+ : await this._fetchPlaylistMeta(playlistId)
+
+ playlist.pluginInfo = {
+ ...current,
+ type: current.type ?? 'playlist',
+ identifier: current.identifier ?? playlistId,
+ url:
+ current.url ?? `https://www.youtube.com/playlist?list=${playlistId}`,
+ artworkUrl: current.artworkUrl || meta.thumbnail,
+ author: current.author || meta.author,
+ totalTracks: capTotalTracks(
+ current.totalTracks ?? playlist.tracks.length,
+ this.nodelink.options.playback?.maxPlaylistLength
+ )
+ }
+ }
+
+ /**
+ * Scrapes the playlist page for its owner and thumbnail.
+ * @param playlistId - YouTube playlist ID (`PL...`, `OLAK...`, etc.).
+ * @internal
+ */
+ private async _fetchPlaylistMeta(
+ playlistId: string
+ ): Promise {
+ const empty: YouTubePlaylistMeta = { author: '', thumbnail: '' }
+ const proxy = this.getProxy()
+ const startedAt = Date.now()
+
+ try {
+ const { body, statusCode } = await makeRequest(
+ `https://www.youtube.com/playlist?list=${encodeURIComponent(playlistId)}&hl=en`,
+ {
+ method: 'GET',
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ // Skips the EU cookie-consent interstitial.
+ Cookie: 'SOCS=CAI'
+ },
+ timeout: 5000,
+ proxy: proxy as unknown as HttpProxyConfig
+ }
+ )
+
+ this.reportProxyStatus(
+ proxy,
+ statusCode === 200,
+ statusCode ?? 0,
+ Date.now() - startedAt
+ )
+
+ if (statusCode !== 200 || typeof body !== 'string') return empty
+ return parsePlaylistMeta(body)
+ } catch (err: unknown) {
+ logger(
+ 'debug',
+ 'YouTube',
+ `Failed to fetch playlist metadata for ${playlistId}: ${err instanceof Error ? err.message : String(err)}`
+ )
+ return empty
+ }
+ }
+
/**
* Internal worker for URL resolution.
* @internal
diff --git a/src/sources/youtube/playlistMeta.ts b/src/sources/youtube/playlistMeta.ts
new file mode 100644
index 00000000..b8b0f654
--- /dev/null
+++ b/src/sources/youtube/playlistMeta.ts
@@ -0,0 +1,134 @@
+/**
+ * Playlist metadata (author + thumbnail) extracted from a YouTube playlist page.
+ * @public
+ */
+export interface YouTubePlaylistMeta {
+ /** Playlist owner display name, or an empty string when unavailable. */
+ author: string
+ /** Playlist thumbnail URL, or an empty string when unavailable. */
+ thumbnail: string
+}
+
+const HTML_ENTITIES: Record = {
+ '&': '&',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ '<': '<',
+ '>': '>'
+}
+
+/** Decodes the handful of entities YouTube emits inside meta attributes. */
+function decodeHtmlEntities(value: string): string {
+ return value.replace(
+ /&(?:amp|quot|#39|#x27|lt|gt);/g,
+ (entity) => HTML_ENTITIES[entity] ?? entity
+ )
+}
+
+/**
+ * Reads `og:image` from the page and returns a clean URL.
+ * `days_since_epoch` is a cache-buster that changes daily, so it is dropped to
+ * keep the URL stable between requests.
+ */
+function extractThumbnail(html: string): string {
+ const metaTags = html.match(/]*>/gi) ?? []
+
+ for (const tag of metaTags) {
+ if (!/\bproperty=["']og:image["']/i.test(tag)) continue
+
+ const content = tag.match(/\bcontent=["']([^"']+)["']/i)?.[1]
+ if (!content) continue
+
+ // Edit the query string by hand: round-tripping through URLSearchParams
+ // would re-encode characters such as the trailing `=` in `sqp=...=`.
+ return decodeHtmlEntities(content)
+ .replace(/([?&])days_since_epoch=[^]*&?/, '$1')
+ .replace(/[?&]$/, '')
+ }
+
+ return ''
+}
+
+/**
+ * Depth-first search for the first object stored under `key`.
+ * Iterative so a deeply nested `ytInitialData` cannot overflow the stack.
+ */
+function findKey(root: unknown, key: string): unknown {
+ const stack: unknown[] = [root]
+
+ while (stack.length > 0) {
+ const node = stack.pop()
+ if (!node || typeof node !== 'object') continue
+
+ const record = node as Record
+ if (key in record && record[key] && typeof record[key] === 'object') {
+ return record[key]
+ }
+
+ for (const value of Object.values(record)) {
+ if (value && typeof value === 'object') stack.push(value)
+ }
+ }
+
+ return undefined
+}
+
+/** Joins the text of a YouTube `{ runs: [...] }` / `{ simpleText }` object. */
+function textOf(value: unknown): string {
+ if (!value || typeof value !== 'object') return ''
+ const text = value as {
+ simpleText?: unknown
+ runs?: Array<{ text?: unknown }>
+ }
+
+ if (typeof text.simpleText === 'string') return text.simpleText
+ if (Array.isArray(text.runs)) {
+ return text.runs
+ .map((run) => (typeof run?.text === 'string' ? run.text : ''))
+ .join('')
+ }
+ return ''
+}
+
+/**
+ * Reads the playlist owner from `ytInitialData`.
+ *
+ * Tries the classic sidebar layout first
+ * (`playlistSidebarSecondaryInfoRenderer.videoOwner.videoOwnerRenderer.title`),
+ * then the newer `playlistHeaderRenderer.ownerText`.
+ */
+function extractAuthor(html: string): string {
+ const match = html.match(/ytInitialData\s*=\s*(\{[\s\S]*?\});\s*<\/script>/)
+ if (!match?.[1]) return ''
+
+ let data: unknown
+ try {
+ data = JSON.parse(match[1])
+ } catch {
+ return ''
+ }
+
+ const sidebar = findKey(data, 'playlistSidebarSecondaryInfoRenderer') as
+ | { videoOwner?: { videoOwnerRenderer?: { title?: unknown } } }
+ | undefined
+ const fromSidebar = textOf(sidebar?.videoOwner?.videoOwnerRenderer?.title)
+ if (fromSidebar) return fromSidebar.trim()
+
+ const header = findKey(data, 'playlistHeaderRenderer') as
+ | { ownerText?: unknown }
+ | undefined
+ return textOf(header?.ownerText).trim()
+}
+
+/**
+ * Extracts the author and thumbnail from the HTML of a YouTube playlist page.
+ * Never throws; missing values come back as empty strings.
+ * @param html - Raw HTML of `https://www.youtube.com/playlist?list=...`.
+ */
+export function parsePlaylistMeta(html: string): YouTubePlaylistMeta {
+ return {
+ author: extractAuthor(html),
+ thumbnail: extractThumbnail(html)
+ }
+}
diff --git a/src/typings/sources/source.types.ts b/src/typings/sources/source.types.ts
index 2ebd3674..01193fc9 100644
--- a/src/typings/sources/source.types.ts
+++ b/src/typings/sources/source.types.ts
@@ -424,6 +424,25 @@ export interface TrackData {
userData?: unknown
}
+/**
+ * Collection-level metadata carried in `PlaylistData.pluginInfo`.
+ * @public
+ */
+export interface PlaylistPluginInfo extends Record {
+ /** Kind of collection, e.g. `playlist` or `album`. */
+ type?: string
+ /** Source-specific collection ID. */
+ identifier?: string
+ /** Canonical URL of the collection. */
+ url?: string
+ /** Cover image URL. */
+ artworkUrl?: string | null
+ /** Owner / curator / artist name. */
+ author?: string | null
+ /** Total tracks in the collection, which can exceed the tracks returned. */
+ totalTracks?: number
+}
+
/**
* Complete playlist object returned by source methods.
* @public
@@ -436,8 +455,12 @@ export interface PlaylistData {
/** Selected track index. */
selectedTrack: number
}
- /** Source-specific metadata. */
- pluginInfo: Record
+ /**
+ * Collection metadata. Sources that can provide it fill `type`,
+ * `identifier`, `url`, `artworkUrl`, `author` and `totalTracks`; sources may
+ * add their own keys alongside.
+ */
+ pluginInfo: PlaylistPluginInfo
/** Tracks in the playlist. */
tracks: TrackData[]
}
diff --git a/src/typings/sources/spotify.types.ts b/src/typings/sources/spotify.types.ts
index 651e57d6..73fa71c4 100644
--- a/src/typings/sources/spotify.types.ts
+++ b/src/typings/sources/spotify.types.ts
@@ -311,6 +311,18 @@ export interface SpotifyGraphQLPlaylistResponse {
playlistV2?: {
__typename: string
name: string
+ /** Playlist owner. `data.name` is the owner's display name. */
+ ownerV2?: { data?: { name?: string | null } | null } | null
+ /** Playlist cover images. Sources are not guaranteed to carry dimensions. */
+ images?: {
+ items?: Array<{
+ sources?: Array<{
+ url: string
+ width?: number | null
+ height?: number | null
+ }>
+ }>
+ } | null
content: {
totalCount: number
items: Array<{
diff --git a/src/utils.ts b/src/utils.ts
index f0a1ebac..e4698713 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -2931,8 +2931,27 @@ async function fetchSponsorBlockSegments(
}
}
+/**
+ * Caps a collection's reported track count at the configured
+ * `playback.maxPlaylistLength`.
+ *
+ * A source may hold more tracks than NodeLink will ever load, so the total it
+ * reports should not exceed what a client can actually receive. An unset,
+ * zero, negative or non-numeric cap means "no cap".
+ *
+ * @param total - Track count as reported by the source.
+ * @param max - Configured `playback.maxPlaylistLength`.
+ * @returns `Math.min(total, max)`, or `total` when there is no usable cap.
+ */
+function capTotalTracks(total: number, max: unknown): number {
+ return typeof max === 'number' && Number.isFinite(max) && max > 0
+ ? Math.min(total, max)
+ : total
+}
+
export {
applyEnvOverrides,
+ capTotalTracks,
checkDependencyUpdates,
checkForUpdates,
cleanupHttpAgents,