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,