From d4ebbe4ae8acff24e3d08f4139c240779e4b785c Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 09:38:18 +0800 Subject: [PATCH 01/12] fix: playlist author + thumbnail support first pass --- src/sources/amazonmusic.ts | 2 +- src/sources/anghami.ts | 5 +++-- src/sources/applemusic.ts | 3 +++ src/sources/audius.ts | 3 +++ src/sources/bandcamp.ts | 1 + src/sources/bilibili.ts | 11 ++++++----- src/sources/deezer.ts | 14 +++++++++++++- src/sources/gaana.ts | 2 +- src/sources/genius.ts | 11 +++++++++++ src/sources/googledrive.ts | 4 ++-- src/sources/lastfm.ts | 2 +- src/sources/monochrome.ts | 2 +- src/sources/pandora.ts | 9 +++++---- src/sources/rss.ts | 12 +++++++++++- src/sources/soundcloud.ts | 1 + src/sources/spotify.ts | 10 ++++++---- src/sources/telegram.ts | 11 +++++++++++ src/sources/tidal.ts | 6 ++++-- src/sources/vkmusic.ts | 6 +++--- src/sources/yandexmusic.ts | 8 ++++---- src/sources/youtube/common.ts | 4 ++-- src/typings/sources/source.types.ts | 4 ++++ 22 files changed, 97 insertions(+), 34 deletions(-) diff --git a/src/sources/amazonmusic.ts b/src/sources/amazonmusic.ts index 1627467a3..3d335d164 100644 --- a/src/sources/amazonmusic.ts +++ b/src/sources/amazonmusic.ts @@ -771,7 +771,7 @@ export default class AmazonMusicSource implements SourceInstance { return { loadType: collectionType as 'playlist' | 'album' | 'artist', data: { - info: { name: collectionName, selectedTrack: 0 }, + info: { name: collectionName, author: '', thumbnail: '', selectedTrack: 0 }, tracks: tracks.map((t) => ({ encoded: encodeTrack({ ...t, details: [] }), info: t, diff --git a/src/sources/anghami.ts b/src/sources/anghami.ts index 66513a318..b882e2dda 100644 --- a/src/sources/anghami.ts +++ b/src/sources/anghami.ts @@ -504,7 +504,7 @@ export default class AnghamiSource { ): Promise<{ loadType: 'playlist' data: { - info: { name: string; selectedTrack: number } + info: { name: string; author: string; thumbnail: string; selectedTrack: number } pluginInfo: Record tracks: AnghamiTrackData[] } @@ -543,7 +543,7 @@ export default class AnghamiSource { return { loadType: 'playlist', - data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } } } @@ -719,6 +719,7 @@ export default class AnghamiSource { this.asString(artistBody.name) ?? this.asString(artistBody.title) ?? 'Artist Top Tracks', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/src/sources/applemusic.ts b/src/sources/applemusic.ts index 5e42ebece..382f4ba79 100644 --- a/src/sources/applemusic.ts +++ b/src/sources/applemusic.ts @@ -493,6 +493,7 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: album.attributes?.name || 'Unknown Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -527,6 +528,7 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: playlist.attributes?.name || 'Unknown Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -564,6 +566,7 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: `${artist.attributes?.name}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/src/sources/audius.ts b/src/sources/audius.ts index 0b1219b63..966834fd3 100644 --- a/src/sources/audius.ts +++ b/src/sources/audius.ts @@ -420,6 +420,7 @@ export default class AudiusSource implements SourceInstance { data: { info: { name: playlistData.playlist_name || 'Audius Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -479,6 +480,7 @@ export default class AudiusSource implements SourceInstance { data: { info: { name: albumData.playlist_name || 'Audius Album', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -560,6 +562,7 @@ export default class AudiusSource implements SourceInstance { data: { info: { name: `${user.name || artist}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { diff --git a/src/sources/bandcamp.ts b/src/sources/bandcamp.ts index 5c9b220b8..d67f0e234 100644 --- a/src/sources/bandcamp.ts +++ b/src/sources/bandcamp.ts @@ -250,6 +250,7 @@ export default class BandcampSource { info: { name: this.normalizeText(tralbum.current?.title) ?? 'BandCamp Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/bilibili.ts b/src/sources/bilibili.ts index c33fe1154..e02b65341 100644 --- a/src/sources/bilibili.ts +++ b/src/sources/bilibili.ts @@ -561,7 +561,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: videoData.title, selectedTrack: 0 }, + info: { name: videoData.title, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -730,7 +730,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: seasonTitle, selectedTrack: 0 }, + info: { name: seasonTitle, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -808,7 +808,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -929,7 +929,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: result.season_title, selectedTrack: 0 }, + info: { name: result.season_title, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1046,6 +1046,7 @@ export default class BilibiliSource implements SourceInstance { data: { info: { name: infoRes?.data?.title || 'Bilibili Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -1190,7 +1191,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts index 1b3908308..d41c258ae 100644 --- a/src/sources/deezer.ts +++ b/src/sources/deezer.ts @@ -557,6 +557,16 @@ interface DeezerPlaylistData { */ name: string + /** + * Playlist author. + */ + author: string + + /** + * Playlist thumbnail. + */ + thumbnail: string + /** * Default selected track index. */ @@ -860,7 +870,7 @@ export default class DeezerSource { if (tracks.length === 0) return { loadType: 'empty', data: {} } const playlist: DeezerPlaylistData = { - info: { name: 'Deezer Recommendations', selectedTrack: 0 }, + info: { name: 'Deezer Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -963,6 +973,7 @@ export default class DeezerSource { data: { info: { name: entity.title ?? 'Unknown Deezer Collection', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, @@ -999,6 +1010,7 @@ export default class DeezerSource { data: { info: { name: `${entity.name ?? 'Unknown Artist'}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/gaana.ts b/src/sources/gaana.ts index 3ea855b04..8439cccd9 100644 --- a/src/sources/gaana.ts +++ b/src/sources/gaana.ts @@ -584,7 +584,7 @@ export default class GaanaSource { return { loadType: 'playlist', data: { - info: { name: infoName, selectedTrack: 0 }, + info: { name: infoName, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type, url, artwork, author: author || undefined }, tracks } diff --git a/src/sources/genius.ts b/src/sources/genius.ts index 99d9ed29e..574916ac4 100644 --- a/src/sources/genius.ts +++ b/src/sources/genius.ts @@ -192,6 +192,16 @@ interface GeniusPlaylistData { */ name: string + /** + * Playlist author. + */ + author: string + + /** + * Playlist thumbnail. + */ + thumbnail: string + /** * Default selected track index. */ @@ -418,6 +428,7 @@ export default class GeniusSource { const playlist: GeniusPlaylistData = { info: { name: `${title} - ${artist} (Genius)`, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/googledrive.ts b/src/sources/googledrive.ts index 31407e36f..2704af38c 100644 --- a/src/sources/googledrive.ts +++ b/src/sources/googledrive.ts @@ -393,7 +393,7 @@ export default class GoogleDriveSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: embeddedResult.title, selectedTrack: 0 }, + info: { name: embeddedResult.title, author: '', thumbnail: '', selectedTrack: 0 }, tracks: embeddedResult.tracks, pluginInfo: {} } @@ -511,7 +511,7 @@ export default class GoogleDriveSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: title, selectedTrack: 0 }, + info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/lastfm.ts b/src/sources/lastfm.ts index 0714c06b0..23f44c680 100644 --- a/src/sources/lastfm.ts +++ b/src/sources/lastfm.ts @@ -305,7 +305,7 @@ export default class LastFMSource { return { loadType: 'playlist', data: { - info: { name: `${collectionTitle} - ${artist}`, selectedTrack: 0 }, + info: { name: `${collectionTitle} - ${artist}`, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, tracks } diff --git a/src/sources/monochrome.ts b/src/sources/monochrome.ts index c366cb0f1..a40a5c09f 100644 --- a/src/sources/monochrome.ts +++ b/src/sources/monochrome.ts @@ -474,7 +474,7 @@ class MonochromeSource implements SourceInstance { return tracks.length > 0 ? { loadType: 'playlist', - data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } } : { loadType: 'empty', data: {} } } diff --git a/src/sources/pandora.ts b/src/sources/pandora.ts index 72ef69f62..228b31955 100644 --- a/src/sources/pandora.ts +++ b/src/sources/pandora.ts @@ -663,7 +663,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'album', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -756,6 +756,7 @@ export default class PandoraSource implements SourceInstance { data: { info: { name: `${artistName}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -810,7 +811,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: data?.name ?? 'Playlist', selectedTrack: 0 }, + info: { name: data?.name ?? 'Playlist', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -845,7 +846,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'station', data: { - info: { name: stationData?.name ?? 'Station', selectedTrack: 0 }, + info: { name: stationData?.name ?? 'Station', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1032,7 +1033,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'podcast', data: { - info: { name: programName ?? 'Podcast', selectedTrack: 0 }, + info: { name: programName ?? 'Podcast', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/rss.ts b/src/sources/rss.ts index 9a64d9249..46aa5bf55 100644 --- a/src/sources/rss.ts +++ b/src/sources/rss.ts @@ -94,6 +94,16 @@ interface RssPlaylistData { */ name: string + /** + * Playlist author. + */ + author: string + + /** + * Playlist thumbnail. + */ + thumbnail: string + /** * Default selected track index. */ @@ -246,7 +256,7 @@ export default class RssSource { } const playlistData: RssPlaylistData = { - info: { name: channelTitle, selectedTrack: 0 }, + info: { name: channelTitle, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { feedUrl: url }, tracks } diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index 1f1153705..30bcf8341 100644 --- a/src/sources/soundcloud.ts +++ b/src/sources/soundcloud.ts @@ -679,6 +679,7 @@ export default class SoundCloudSource implements SoundCloudSourceState { data: { info: { name: body.title || 'Untitled playlist', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/src/sources/spotify.ts b/src/sources/spotify.ts index 5d6bc31b8..a2974fc9f 100644 --- a/src/sources/spotify.ts +++ b/src/sources/spotify.ts @@ -1167,7 +1167,7 @@ export default class SpotifySource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1204,7 +1204,7 @@ export default class SpotifySource implements SourceInstance { ? { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1271,7 +1271,7 @@ export default class SpotifySource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1309,7 +1309,7 @@ export default class SpotifySource implements SourceInstance { ? { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1345,6 +1345,7 @@ export default class SpotifySource implements SourceInstance { data: { info: { name: `${data.artistUnion.profile.name}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -1496,6 +1497,7 @@ export default class SpotifySource implements SourceInstance { data: { info: { name: `Spotify Recommendations for ${id}`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/src/sources/telegram.ts b/src/sources/telegram.ts index 48deb90e8..a5328c001 100644 --- a/src/sources/telegram.ts +++ b/src/sources/telegram.ts @@ -79,6 +79,16 @@ interface TelegramPlaylistData { */ name: string + /** + * Playlist author. + */ + author: string + + /** + * Playlist thumbnail. + */ + thumbnail: string + /** * Default selected track index. */ @@ -262,6 +272,7 @@ export default class TelegramSource { const playlistData: TelegramPlaylistData = { info: { name: title, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/tidal.ts b/src/sources/tidal.ts index 9203a4fe0..5dd3e22cb 100644 --- a/src/sources/tidal.ts +++ b/src/sources/tidal.ts @@ -327,7 +327,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name: `Mix: ${mixId}`, selectedTrack: 0 }, + info: { name: `Mix: ${mixId}`, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -561,6 +561,7 @@ export default class TidalSource { data: { info: { name: this.asString(albumData.title) || 'Unknown Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -646,6 +647,7 @@ export default class TidalSource { data: { info: { name: this.asString(playlistData.title) || 'Unknown Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -716,7 +718,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} as Record } diff --git a/src/sources/vkmusic.ts b/src/sources/vkmusic.ts index f7451a4ae..1ead8cd45 100644 --- a/src/sources/vkmusic.ts +++ b/src/sources/vkmusic.ts @@ -334,7 +334,7 @@ export default class VKMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'VK Recommendations', selectedTrack: 0 }, + info: { name: 'VK Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -418,7 +418,7 @@ export default class VKMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'VK Playlist', selectedTrack: 0 }, + info: { name: 'VK Playlist', author: '', thumbnail: '', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -465,7 +465,7 @@ export default class VKMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'VK Scraped Playlist', selectedTrack: 0 }, + info: { name: 'VK Scraped Playlist', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/yandexmusic.ts b/src/sources/yandexmusic.ts index 674841047..0df7573f0 100644 --- a/src/sources/yandexmusic.ts +++ b/src/sources/yandexmusic.ts @@ -423,7 +423,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'Yandex Music Recommendations', selectedTrack: 0 }, + info: { name: 'Yandex Music Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -775,7 +775,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: album.title || 'Yandex Music Album', selectedTrack: 0 }, + info: { name: album.title || 'Yandex Music Album', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'album' }, tracks } @@ -821,7 +821,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: `${name}'s Top Tracks`, selectedTrack: 0 }, + info: { name: `${name}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'artist' }, tracks } @@ -928,7 +928,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: title, selectedTrack: 0 }, + info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'playlist' }, tracks } diff --git a/src/sources/youtube/common.ts b/src/sources/youtube/common.ts index 90bdd8924..ca93636f7 100644 --- a/src/sources/youtube/common.ts +++ b/src/sources/youtube/common.ts @@ -2678,7 +2678,7 @@ export abstract class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, selectedTrack }, + info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack }, pluginInfo: {}, tracks } @@ -2877,7 +2877,7 @@ export abstract class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, selectedTrack: 0 }, + info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } diff --git a/src/typings/sources/source.types.ts b/src/typings/sources/source.types.ts index 2ebd3674f..d3d71a0f2 100644 --- a/src/typings/sources/source.types.ts +++ b/src/typings/sources/source.types.ts @@ -433,6 +433,10 @@ export interface PlaylistData { info: { /** Playlist name. */ name: string + /** Playlist author. */ + author: string + /** Playlist thumbnail. */ + thumbnail: string /** Selected track index. */ selectedTrack: number } From 0bb7570a44b3bffc65023ad5e79b1287c2fa7cad Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 09:53:35 +0800 Subject: [PATCH 02/12] fix: second pass (yt and qobuz) --- src/sources/qobuz.ts | 6 ++++-- src/sources/youtube/YouTube.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sources/qobuz.ts b/src/sources/qobuz.ts index 8971c08d2..7e07679c5 100644 --- a/src/sources/qobuz.ts +++ b/src/sources/qobuz.ts @@ -243,7 +243,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: 'Qobuz Recommendations', selectedTrack: 0 }, + info: { name: 'Qobuz Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -369,6 +369,7 @@ export default class QobuzSource { data: { info: { name: this.asString(data.title) || 'Unknown Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -406,6 +407,7 @@ export default class QobuzSource { data: { info: { name: this.asString(data.name) || 'Unknown Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -442,7 +444,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: `${artistName}'s Top Tracks`, selectedTrack: 0 }, + info: { name: `${artistName}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} as Record } diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index a20096078..bad378373 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -767,7 +767,7 @@ export default class YouTubeSource { return { loadType: 'playlist', data: { - info: { name: 'YouTube Recommendations', selectedTrack: 0 }, + info: { name: 'YouTube Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } From e70bff27e51f6c2f8122f02c5aab8bdcd489b3dd Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 10:01:37 +0800 Subject: [PATCH 03/12] fix: thrid pass (source manager) --- dist/src/managers/sourceManager.js | 1 + dist/src/sources/amazonmusic.js | 2 +- dist/src/sources/anghami.js | 3 ++- dist/src/sources/applemusic.js | 3 +++ dist/src/sources/audius.js | 3 +++ dist/src/sources/bandcamp.js | 1 + dist/src/sources/bilibili.js | 11 ++++++----- dist/src/sources/deezer.js | 4 +++- dist/src/sources/gaana.js | 2 +- dist/src/sources/genius.js | 1 + dist/src/sources/googledrive.js | 4 ++-- dist/src/sources/lastfm.js | 2 +- dist/src/sources/monochrome.js | 2 +- dist/src/sources/pandora.js | 9 +++++---- dist/src/sources/qobuz.js | 6 ++++-- dist/src/sources/rss.js | 2 +- dist/src/sources/soundcloud.js | 1 + dist/src/sources/spotify.js | 10 ++++++---- dist/src/sources/telegram.js | 1 + dist/src/sources/tidal.js | 6 ++++-- dist/src/sources/vkmusic.js | 6 +++--- dist/src/sources/yandexmusic.js | 8 ++++---- dist/src/sources/youtube/YouTube.js | 2 +- dist/src/sources/youtube/common.js | 4 ++-- src/managers/sourceManager.ts | 1 + 25 files changed, 59 insertions(+), 36 deletions(-) diff --git a/dist/src/managers/sourceManager.js b/dist/src/managers/sourceManager.js index c37d17248..c5f97a429 100644 --- a/dist/src/managers/sourceManager.js +++ b/dist/src/managers/sourceManager.js @@ -294,6 +294,7 @@ export default class SourcesManager { const playlistData = { info: { name: `Search results for: ${query}`, + author: '', thumbnail: '', selectedTrack: -1 }, pluginInfo: {}, diff --git a/dist/src/sources/amazonmusic.js b/dist/src/sources/amazonmusic.js index d3d47f3af..4d8a5677d 100644 --- a/dist/src/sources/amazonmusic.js +++ b/dist/src/sources/amazonmusic.js @@ -587,7 +587,7 @@ export default class AmazonMusicSource { return { loadType: collectionType, data: { - info: { name: collectionName, selectedTrack: 0 }, + info: { name: collectionName, author: '', thumbnail: '', selectedTrack: 0 }, tracks: tracks.map((t) => ({ encoded: encodeTrack({ ...t, details: [] }), info: t, diff --git a/dist/src/sources/anghami.js b/dist/src/sources/anghami.js index bfbcef9e8..22ddc1f1a 100644 --- a/dist/src/sources/anghami.js +++ b/dist/src/sources/anghami.js @@ -437,7 +437,7 @@ export default class AnghamiSource { 'Unknown Playlist'; return { loadType: 'playlist', - data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } }; } extractCollectionTracks(body) { @@ -588,6 +588,7 @@ export default class AnghamiSource { name: this.asString(artistBody.name) ?? this.asString(artistBody.title) ?? 'Artist Top Tracks', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js index 5c1b235e3..c050a5faa 100644 --- a/dist/src/sources/applemusic.js +++ b/dist/src/sources/applemusic.js @@ -332,6 +332,7 @@ export default class AppleMusicSource { data: { info: { name: album.attributes?.name || 'Unknown Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -357,6 +358,7 @@ export default class AppleMusicSource { data: { info: { name: playlist.attributes?.name || 'Unknown Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -385,6 +387,7 @@ export default class AppleMusicSource { data: { info: { name: `${artist.attributes?.name}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/dist/src/sources/audius.js b/dist/src/sources/audius.js index 49f108c37..9833b060e 100644 --- a/dist/src/sources/audius.js +++ b/dist/src/sources/audius.js @@ -346,6 +346,7 @@ export default class AudiusSource { data: { info: { name: playlistData.playlist_name || 'Audius Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -392,6 +393,7 @@ export default class AudiusSource { data: { info: { name: albumData.playlist_name || 'Audius Album', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -457,6 +459,7 @@ export default class AudiusSource { data: { info: { name: `${user.name || artist}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { diff --git a/dist/src/sources/bandcamp.js b/dist/src/sources/bandcamp.js index e1f215271..24ef9ddcb 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -175,6 +175,7 @@ export default class BandcampSource { const playlist = { info: { name: this.normalizeText(tralbum.current?.title) ?? 'BandCamp Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/bilibili.js b/dist/src/sources/bilibili.js index 6dcdc9d0c..767da3be9 100644 --- a/dist/src/sources/bilibili.js +++ b/dist/src/sources/bilibili.js @@ -454,7 +454,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: videoData.title, selectedTrack: 0 }, + info: { name: videoData.title, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -583,7 +583,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: seasonTitle, selectedTrack: 0 }, + info: { name: seasonTitle, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -645,7 +645,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -750,7 +750,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: result.season_title, selectedTrack: 0 }, + info: { name: result.season_title, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -842,6 +842,7 @@ export default class BilibiliSource { data: { info: { name: infoRes?.data?.title || 'Bilibili Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -961,7 +962,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index 24075f23a..a2bca8f6f 100644 --- a/dist/src/sources/deezer.js +++ b/dist/src/sources/deezer.js @@ -219,7 +219,7 @@ export default class DeezerSource { if (tracks.length === 0) return { loadType: 'empty', data: {} }; const playlist = { - info: { name: 'Deezer Recommendations', selectedTrack: 0 }, + info: { name: 'Deezer Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks }; @@ -293,6 +293,7 @@ export default class DeezerSource { data: { info: { name: entity.title ?? 'Unknown Deezer Collection', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, @@ -318,6 +319,7 @@ export default class DeezerSource { data: { info: { name: `${entity.name ?? 'Unknown Artist'}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/gaana.js b/dist/src/sources/gaana.js index 9085eff0e..99f5ae7f3 100644 --- a/dist/src/sources/gaana.js +++ b/dist/src/sources/gaana.js @@ -433,7 +433,7 @@ export default class GaanaSource { return { loadType: 'playlist', data: { - info: { name: infoName, selectedTrack: 0 }, + info: { name: infoName, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type, url, artwork, author: author || undefined }, tracks } diff --git a/dist/src/sources/genius.js b/dist/src/sources/genius.js index 41325a666..4a6aa0b30 100644 --- a/dist/src/sources/genius.js +++ b/dist/src/sources/genius.js @@ -140,6 +140,7 @@ export default class GeniusSource { const playlist = { info: { name: `${title} - ${artist} (Genius)`, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/googledrive.js b/dist/src/sources/googledrive.js index 037fee7fe..c0fb0d5b7 100644 --- a/dist/src/sources/googledrive.js +++ b/dist/src/sources/googledrive.js @@ -296,7 +296,7 @@ export default class GoogleDriveSource { return { loadType: 'playlist', data: { - info: { name: embeddedResult.title, selectedTrack: 0 }, + info: { name: embeddedResult.title, author: '', thumbnail: '', selectedTrack: 0 }, tracks: embeddedResult.tracks, pluginInfo: {} } @@ -391,7 +391,7 @@ export default class GoogleDriveSource { return { loadType: 'playlist', data: { - info: { name: title, selectedTrack: 0 }, + info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/lastfm.js b/dist/src/sources/lastfm.js index f1dc72994..aac169b0c 100644 --- a/dist/src/sources/lastfm.js +++ b/dist/src/sources/lastfm.js @@ -208,7 +208,7 @@ export default class LastFMSource { return { loadType: 'playlist', data: { - info: { name: `${collectionTitle} - ${artist}`, selectedTrack: 0 }, + info: { name: `${collectionTitle} - ${artist}`, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } diff --git a/dist/src/sources/monochrome.js b/dist/src/sources/monochrome.js index 7ddcc9105..b7b218f10 100644 --- a/dist/src/sources/monochrome.js +++ b/dist/src/sources/monochrome.js @@ -345,7 +345,7 @@ class MonochromeSource { return tracks.length > 0 ? { loadType: 'playlist', - data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } } : { loadType: 'empty', data: {} }; } diff --git a/dist/src/sources/pandora.js b/dist/src/sources/pandora.js index f120f7495..8c3069a95 100644 --- a/dist/src/sources/pandora.js +++ b/dist/src/sources/pandora.js @@ -491,7 +491,7 @@ export default class PandoraSource { return { loadType: 'album', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -570,6 +570,7 @@ export default class PandoraSource { data: { info: { name: `${artistName}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -615,7 +616,7 @@ export default class PandoraSource { return { loadType: 'playlist', data: { - info: { name: data?.name ?? 'Playlist', selectedTrack: 0 }, + info: { name: data?.name ?? 'Playlist', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -642,7 +643,7 @@ export default class PandoraSource { return { loadType: 'station', data: { - info: { name: stationData?.name ?? 'Station', selectedTrack: 0 }, + info: { name: stationData?.name ?? 'Station', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -774,7 +775,7 @@ export default class PandoraSource { return { loadType: 'podcast', data: { - info: { name: programName ?? 'Podcast', selectedTrack: 0 }, + info: { name: programName ?? 'Podcast', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/qobuz.js b/dist/src/sources/qobuz.js index a46a78b16..c5499cb75 100644 --- a/dist/src/sources/qobuz.js +++ b/dist/src/sources/qobuz.js @@ -167,7 +167,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: 'Qobuz Recommendations', selectedTrack: 0 }, + info: { name: 'Qobuz Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -279,6 +279,7 @@ export default class QobuzSource { data: { info: { name: this.asString(data.title) || 'Unknown Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -308,6 +309,7 @@ export default class QobuzSource { data: { info: { name: this.asString(data.name) || 'Unknown Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -336,7 +338,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: `${artistName}'s Top Tracks`, selectedTrack: 0 }, + info: { name: `${artistName}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/rss.js b/dist/src/sources/rss.js index 30d8e7bf6..1f4253171 100644 --- a/dist/src/sources/rss.js +++ b/dist/src/sources/rss.js @@ -108,7 +108,7 @@ export default class RssSource { return { loadType: 'empty', data: {} }; } const playlistData = { - info: { name: channelTitle, selectedTrack: 0 }, + info: { name: channelTitle, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { feedUrl: url }, tracks }; diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js index 469ab79f1..b53bad9e0 100644 --- a/dist/src/sources/soundcloud.js +++ b/dist/src/sources/soundcloud.js @@ -480,6 +480,7 @@ export default class SoundCloudSource { data: { info: { name: body.title || 'Untitled playlist', + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/spotify.js b/dist/src/sources/spotify.js index a722191a3..1610137e9 100644 --- a/dist/src/sources/spotify.js +++ b/dist/src/sources/spotify.js @@ -962,7 +962,7 @@ export default class SpotifySource { return { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -994,7 +994,7 @@ export default class SpotifySource { ? { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1052,7 +1052,7 @@ export default class SpotifySource { return { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1085,7 +1085,7 @@ export default class SpotifySource { ? { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1116,6 +1116,7 @@ export default class SpotifySource { data: { info: { name: `${data.artistUnion.profile.name}'s Top Tracks`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -1252,6 +1253,7 @@ export default class SpotifySource { data: { info: { name: `Spotify Recommendations for ${id}`, + author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/dist/src/sources/telegram.js b/dist/src/sources/telegram.js index 8ea03e823..4a3486545 100644 --- a/dist/src/sources/telegram.js +++ b/dist/src/sources/telegram.js @@ -135,6 +135,7 @@ export default class TelegramSource { const playlistData = { info: { name: title, + author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/tidal.js b/dist/src/sources/tidal.js index 2bfd1ff5a..a2f6ece23 100644 --- a/dist/src/sources/tidal.js +++ b/dist/src/sources/tidal.js @@ -255,7 +255,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name: `Mix: ${mixId}`, selectedTrack: 0 }, + info: { name: `Mix: ${mixId}`, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -426,6 +426,7 @@ export default class TidalSource { data: { info: { name: this.asString(albumData.title) || 'Unknown Album', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -486,6 +487,7 @@ export default class TidalSource { data: { info: { name: this.asString(playlistData.title) || 'Unknown Playlist', + author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -529,7 +531,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name, selectedTrack: 0 }, + info: { name, author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/vkmusic.js b/dist/src/sources/vkmusic.js index 44e3d8fda..83f0831ab 100644 --- a/dist/src/sources/vkmusic.js +++ b/dist/src/sources/vkmusic.js @@ -253,7 +253,7 @@ export default class VKMusicSource { return { loadType: 'playlist', data: { - info: { name: 'VK Recommendations', selectedTrack: 0 }, + info: { name: 'VK Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -323,7 +323,7 @@ export default class VKMusicSource { return { loadType: 'playlist', data: { - info: { name: 'VK Playlist', selectedTrack: 0 }, + info: { name: 'VK Playlist', author: '', thumbnail: '', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -363,7 +363,7 @@ export default class VKMusicSource { return { loadType: 'playlist', data: { - info: { name: 'VK Scraped Playlist', selectedTrack: 0 }, + info: { name: 'VK Scraped Playlist', author: '', thumbnail: '', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/yandexmusic.js b/dist/src/sources/yandexmusic.js index 684686ead..130ce6e98 100644 --- a/dist/src/sources/yandexmusic.js +++ b/dist/src/sources/yandexmusic.js @@ -334,7 +334,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: 'Yandex Music Recommendations', selectedTrack: 0 }, + info: { name: 'Yandex Music Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -596,7 +596,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: album.title || 'Yandex Music Album', selectedTrack: 0 }, + info: { name: album.title || 'Yandex Music Album', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'album' }, tracks } @@ -628,7 +628,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: `${name}'s Top Tracks`, selectedTrack: 0 }, + info: { name: `${name}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'artist' }, tracks } @@ -696,7 +696,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: title, selectedTrack: 0 }, + info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'playlist' }, tracks } diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index 37c69a7b8..ea8b9f001 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -547,7 +547,7 @@ export default class YouTubeSource { return { loadType: 'playlist', data: { - info: { name: 'YouTube Recommendations', selectedTrack: 0 }, + info: { name: 'YouTube Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } diff --git a/dist/src/sources/youtube/common.js b/dist/src/sources/youtube/common.js index c59c6a4ff..baac84982 100644 --- a/dist/src/sources/youtube/common.js +++ b/dist/src/sources/youtube/common.js @@ -1889,7 +1889,7 @@ export class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, selectedTrack }, + info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack }, pluginInfo: {}, tracks } @@ -2009,7 +2009,7 @@ export class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, selectedTrack: 0 }, + info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } diff --git a/src/managers/sourceManager.ts b/src/managers/sourceManager.ts index 2282823cd..d43c25553 100644 --- a/src/managers/sourceManager.ts +++ b/src/managers/sourceManager.ts @@ -466,6 +466,7 @@ export default class SourcesManager implements SourceManagerLike { const playlistData: PlaylistData = { info: { name: `Search results for: ${query}`, + author: '', thumbnail: '', selectedTrack: -1 }, pluginInfo: {}, From be671ea00b6eb64fca7fc6f257f11a82310f30d6 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 10:43:52 +0800 Subject: [PATCH 04/12] fix: youtube playlist support --- dist/src/sources/youtube/YouTube.js | 56 ++++++++++ dist/src/sources/youtube/playlistMeta.js | 104 ++++++++++++++++++ src/sources/youtube/YouTube.ts | 77 +++++++++++++ src/sources/youtube/playlistMeta.ts | 134 +++++++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 dist/src/sources/youtube/playlistMeta.js create mode 100644 src/sources/youtube/playlistMeta.ts diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index ea8b9f001..399a67f82 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -5,6 +5,7 @@ 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,58 @@ export default class YouTubeSource { } return result; } + /** + * Fills in `info.author` and `info.thumbnail` on a resolved playlist. + * + * The innertube `next`/`browse` responses used by the clients don't carry the + * playlist owner or cover art, so they are read from the public playlist page + * instead. Best-effort: any failure leaves the existing values untouched. + * @internal + */ + async _applyPlaylistMeta(url, playlist) { + const info = playlist?.info; + if (!info || (info.author && info.thumbnail)) + return; + const playlistId = url.match(/[?&]list=([\w-]+)/)?.[1]; + if (!playlistId) + return; + const meta = await this._fetchPlaylistMeta(playlistId); + if (!info.author && meta.author) + info.author = meta.author; + if (!info.thumbnail && meta.thumbnail) + info.thumbnail = meta.thumbnail; + } + /** + * 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 000000000..2cc03e0ad --- /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/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index bad378373..fb7a49cf5 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 @@ -39,6 +40,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 +803,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 +832,77 @@ export default class YouTubeSource { return result } + /** + * Fills in `info.author` and `info.thumbnail` on a resolved playlist. + * + * The innertube `next`/`browse` responses used by the clients don't carry the + * playlist owner or cover art, so they are read from the public playlist page + * instead. Best-effort: any failure leaves the existing values untouched. + * @internal + */ + private async _applyPlaylistMeta( + url: string, + playlist: PlaylistData + ): Promise { + const info = playlist?.info + if (!info || (info.author && info.thumbnail)) return + + const playlistId = url.match(/[?&]list=([\w-]+)/)?.[1] + if (!playlistId) return + + const meta = await this._fetchPlaylistMeta(playlistId) + if (!info.author && meta.author) info.author = meta.author + if (!info.thumbnail && meta.thumbnail) info.thumbnail = meta.thumbnail + } + + /** + * 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 000000000..b8b0f6542 --- /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) + } +} From 2b937116e8932abf11256cbe049bb4ce7dc9aaf4 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 11:05:34 +0800 Subject: [PATCH 05/12] fix: spotify playlist support (author + thumb) --- dist/src/sources/spotify.js | 34 ++++++++++++++++++-- src/sources/spotify.ts | 47 ++++++++++++++++++++++++---- src/typings/sources/spotify.types.ts | 12 +++++++ 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/dist/src/sources/spotify.js b/dist/src/sources/spotify.js index 1610137e9..0a5aabf88 100644 --- a/dist/src/sources/spotify.js +++ b/dist/src/sources/spotify.js @@ -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,8 @@ export default class SpotifySource { const maxTracks = this.nodelink.options.playback.maxPlaylistLength || 1000; const tracks = []; let name = 'Unknown Playlist'; + let author = ''; + let thumbnail = ''; if (this.anonymousToken || id.startsWith('37i9dQZ')) { let offset = 0; const limit = 100; @@ -1027,6 +1050,8 @@ 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; } const items = data.playlistV2.content?.items || []; @@ -1052,7 +1077,7 @@ export default class SpotifySource { return { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, author, thumbnail, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1060,8 +1085,11 @@ export default class SpotifySource { } } 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); @@ -1085,7 +1113,7 @@ export default class SpotifySource { ? { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, author, thumbnail, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/spotify.ts b/src/sources/spotify.ts index a2974fc9f..50dfde305 100644 --- a/src/sources/spotify.ts +++ b/src/sources/spotify.ts @@ -20,6 +20,7 @@ import type { SpotifyGraphQLTrack, SpotifyMetadataResponse, SpotifyPagingObject, + SpotifyPlaylist, SpotifyPlaylistItem, SpotifyTokenType, SpotifyTrack @@ -1212,6 +1213,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 +1249,8 @@ 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 = '' if (this.anonymousToken || id.startsWith('37i9dQZ')) { let offset = 0 @@ -1247,6 +1274,10 @@ 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 } @@ -1271,7 +1302,7 @@ export default class SpotifySource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, author, thumbnail, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1279,10 +1310,14 @@ export default class SpotifySource implements SourceInstance { } } - 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) { @@ -1309,7 +1344,7 @@ export default class SpotifySource implements SourceInstance { ? { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, author, thumbnail, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/typings/sources/spotify.types.ts b/src/typings/sources/spotify.types.ts index 651e57d69..73fa71c4a 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<{ From d448d4cbe6d2e5035b4b929eb30682033647a6c6 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 11:31:31 +0800 Subject: [PATCH 06/12] fix: deezer playlist support (author + thumb) --- dist/src/sources/deezer.js | 26 +++++++++++++++++++++++--- src/sources/deezer.ts | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index a2bca8f6f..f8a9b2ed2 100644 --- a/dist/src/sources/deezer.js +++ b/dist/src/sources/deezer.js @@ -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: {} }; @@ -293,7 +312,8 @@ export default class DeezerSource { data: { info: { name: entity.title ?? 'Unknown Deezer Collection', - author: '', thumbnail: '', + author, + thumbnail, selectedTrack: 0 }, pluginInfo: {}, diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts index d41c258ae..14eaa7dcd 100644 --- a/src/sources/deezer.ts +++ b/src/sources/deezer.ts @@ -256,6 +256,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. */ @@ -961,9 +966,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: {} } @@ -973,7 +1000,8 @@ export default class DeezerSource { data: { info: { name: entity.title ?? 'Unknown Deezer Collection', - author: '', thumbnail: '', + author, + thumbnail, selectedTrack: 0 }, pluginInfo: {} as Record, From 10f3ec2a586f8f37b3256ce4d061c2f3cef35d63 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 11:52:16 +0800 Subject: [PATCH 07/12] fix: applemusic support (author + thumb) --- dist/src/sources/applemusic.js | 10 ++++++++-- src/sources/applemusic.ts | 11 +++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js index c050a5faa..deacfa8af 100644 --- a/dist/src/sources/applemusic.js +++ b/dist/src/sources/applemusic.js @@ -358,7 +358,8 @@ export default class AppleMusicSource { data: { info: { name: playlist.attributes?.name || 'Unknown Playlist', - author: '', thumbnail: '', + author: playlist.attributes?.curatorName?.trim() || '', + thumbnail: this.parseArtwork(playlist.attributes?.artwork) || '', selectedTrack: 0 }, tracks, @@ -409,7 +410,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/src/sources/applemusic.ts b/src/sources/applemusic.ts index 382f4ba79..fa289bbd1 100644 --- a/src/sources/applemusic.ts +++ b/src/sources/applemusic.ts @@ -528,7 +528,8 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: playlist.attributes?.name || 'Unknown Playlist', - author: '', thumbnail: '', + author: playlist.attributes?.curatorName?.trim() || '', + thumbnail: this.parseArtwork(playlist.attributes?.artwork) || '', selectedTrack: 0 }, tracks, @@ -594,7 +595,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 From 1b563f2daacce1cb55d3b17c67c2e504609d66ee Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 15:48:55 +0800 Subject: [PATCH 08/12] fix: tidal playlist support (thumb only) --- dist/src/sources/tidal.js | 32 ++++++++++++++++++++++++++++---- src/sources/tidal.ts | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/dist/src/sources/tidal.js b/dist/src/sources/tidal.js index a2f6ece23..d2507862c 100644 --- a/dist/src/sources/tidal.js +++ b/dist/src/sources/tidal.js @@ -487,7 +487,9 @@ export default class TidalSource { data: { info: { name: this.asString(playlistData.title) || 'Unknown Playlist', - author: '', thumbnail: '', + // The playlist endpoint only exposes `creator.id`, not a name. + author: '', + thumbnail: this.getPlaylistCoverUrl(playlistData), selectedTrack: 0 }, tracks, @@ -568,6 +570,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. @@ -593,9 +619,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/src/sources/tidal.ts b/src/sources/tidal.ts index 5dd3e22cb..12377e984 100644 --- a/src/sources/tidal.ts +++ b/src/sources/tidal.ts @@ -647,7 +647,9 @@ export default class TidalSource { data: { info: { name: this.asString(playlistData.title) || 'Unknown Playlist', - author: '', thumbnail: '', + // The playlist endpoint only exposes `creator.id`, not a name. + author: '', + thumbnail: this.getPlaylistCoverUrl(playlistData), selectedTrack: 0 }, tracks, @@ -766,6 +768,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. @@ -798,9 +826,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' } From fdefd72c089042ba7804fe82ff4e6bd56e816060 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 16:03:41 +0800 Subject: [PATCH 09/12] fix: qobuz playlist support (author + thumb) --- dist/src/sources/qobuz.js | 21 ++++++++++++++++++++- src/sources/qobuz.ts | 22 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/dist/src/sources/qobuz.js b/dist/src/sources/qobuz.js index c5499cb75..30c5efc2a 100644 --- a/dist/src/sources/qobuz.js +++ b/dist/src/sources/qobuz.js @@ -309,7 +309,8 @@ export default class QobuzSource { data: { info: { name: this.asString(data.name) || 'Unknown Playlist', - author: '', thumbnail: '', + author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '', + thumbnail: this.getPlaylistCoverUrl(data), selectedTrack: 0 }, tracks, @@ -518,6 +519,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/src/sources/qobuz.ts b/src/sources/qobuz.ts index 7e07679c5..e7e515567 100644 --- a/src/sources/qobuz.ts +++ b/src/sources/qobuz.ts @@ -407,7 +407,8 @@ export default class QobuzSource { data: { info: { name: this.asString(data.name) || 'Unknown Playlist', - author: '', thumbnail: '', + author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '', + thumbnail: this.getPlaylistCoverUrl(data), selectedTrack: 0 }, tracks, @@ -684,6 +685,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. From 5d612ee145718c8e2fc0e297f8fe625d9490c832 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Wed, 21 Oct 2026 16:16:29 +0800 Subject: [PATCH 10/12] fix: soundcloud playlist support (author + thumb) --- dist/src/sources/soundcloud.js | 15 ++++++++++++++- src/sources/soundcloud.ts | 20 +++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js index b53bad9e0..6431d7732 100644 --- a/dist/src/sources/soundcloud.js +++ b/dist/src/sources/soundcloud.js @@ -480,7 +480,9 @@ export default class SoundCloudSource { data: { info: { name: body.title || 'Untitled playlist', - author: '', thumbnail: '', + author: body.user?.username?.trim() || '', + thumbnail: this._upgradeArtworkUrl(body.artwork_url ?? + tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl), selectedTrack: 0 }, pluginInfo: {}, @@ -488,6 +490,17 @@ export default class SoundCloudSource { } }; } + /** + * 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/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index 30bcf8341..2cf3024d4 100644 --- a/src/sources/soundcloud.ts +++ b/src/sources/soundcloud.ts @@ -679,7 +679,11 @@ export default class SoundCloudSource implements SoundCloudSourceState { data: { info: { name: body.title || 'Untitled playlist', - author: '', thumbnail: '', + author: body.user?.username?.trim() || '', + thumbnail: this._upgradeArtworkUrl( + body.artwork_url ?? + tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl + ), selectedTrack: 0 }, pluginInfo: {}, @@ -688,6 +692,20 @@ export default class SoundCloudSource implements SoundCloudSourceState { } } + /** + * 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[] { From cb044dd6b6d1d03eeccdb720c72e70f03b65cb2b Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Sat, 24 Oct 2026 08:25:57 +0800 Subject: [PATCH 11/12] fix: moved to plugin info --- dist/src/managers/sourceManager.js | 1 - dist/src/sources/amazonmusic.js | 2 +- dist/src/sources/anghami.js | 3 +-- dist/src/sources/applemusic.js | 14 ++++++++----- dist/src/sources/audius.js | 3 --- dist/src/sources/bandcamp.js | 1 - dist/src/sources/bilibili.js | 11 +++++----- dist/src/sources/deezer.js | 14 ++++++++----- dist/src/sources/gaana.js | 2 +- dist/src/sources/genius.js | 1 - dist/src/sources/googledrive.js | 4 ++-- dist/src/sources/lastfm.js | 2 +- dist/src/sources/monochrome.js | 2 +- dist/src/sources/pandora.js | 9 ++++---- dist/src/sources/qobuz.js | 18 ++++++++++------ dist/src/sources/rss.js | 2 +- dist/src/sources/soundcloud.js | 13 ++++++++---- dist/src/sources/spotify.js | 26 +++++++++++++++-------- dist/src/sources/telegram.js | 1 - dist/src/sources/tidal.js | 18 +++++++++------- dist/src/sources/vkmusic.js | 6 +++--- dist/src/sources/yandexmusic.js | 8 ++++---- dist/src/sources/youtube/YouTube.js | 31 ++++++++++++++++++---------- dist/src/sources/youtube/common.js | 4 ++-- src/managers/sourceManager.ts | 1 - src/sources/amazonmusic.ts | 2 +- src/sources/anghami.ts | 5 ++--- src/sources/applemusic.ts | 16 ++++++++++----- src/sources/audius.ts | 3 --- src/sources/bandcamp.ts | 1 - src/sources/bilibili.ts | 11 +++++----- src/sources/deezer.ts | 24 ++++++++-------------- src/sources/gaana.ts | 2 +- src/sources/genius.ts | 11 ---------- src/sources/googledrive.ts | 4 ++-- src/sources/lastfm.ts | 2 +- src/sources/monochrome.ts | 2 +- src/sources/pandora.ts | 9 ++++---- src/sources/qobuz.ts | 19 +++++++++++------ src/sources/rss.ts | 12 +---------- src/sources/soundcloud.ts | 13 ++++++++---- src/sources/spotify.ts | 27 ++++++++++++++++-------- src/sources/telegram.ts | 11 ---------- src/sources/tidal.ts | 18 +++++++++------- src/sources/vkmusic.ts | 6 +++--- src/sources/yandexmusic.ts | 8 ++++---- src/sources/youtube/YouTube.ts | 32 +++++++++++++++++++++-------- src/sources/youtube/common.ts | 4 ++-- src/typings/sources/source.types.ts | 31 ++++++++++++++++++++++------ 49 files changed, 261 insertions(+), 209 deletions(-) diff --git a/dist/src/managers/sourceManager.js b/dist/src/managers/sourceManager.js index c5f97a429..c37d17248 100644 --- a/dist/src/managers/sourceManager.js +++ b/dist/src/managers/sourceManager.js @@ -294,7 +294,6 @@ export default class SourcesManager { const playlistData = { info: { name: `Search results for: ${query}`, - author: '', thumbnail: '', selectedTrack: -1 }, pluginInfo: {}, diff --git a/dist/src/sources/amazonmusic.js b/dist/src/sources/amazonmusic.js index 4d8a5677d..d3d47f3af 100644 --- a/dist/src/sources/amazonmusic.js +++ b/dist/src/sources/amazonmusic.js @@ -587,7 +587,7 @@ export default class AmazonMusicSource { return { loadType: collectionType, data: { - info: { name: collectionName, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: collectionName, selectedTrack: 0 }, tracks: tracks.map((t) => ({ encoded: encodeTrack({ ...t, details: [] }), info: t, diff --git a/dist/src/sources/anghami.js b/dist/src/sources/anghami.js index 22ddc1f1a..bfbcef9e8 100644 --- a/dist/src/sources/anghami.js +++ b/dist/src/sources/anghami.js @@ -437,7 +437,7 @@ export default class AnghamiSource { 'Unknown Playlist'; return { loadType: 'playlist', - data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } }; } extractCollectionTracks(body) { @@ -588,7 +588,6 @@ export default class AnghamiSource { name: this.asString(artistBody.name) ?? this.asString(artistBody.title) ?? 'Artist Top Tracks', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js index deacfa8af..b0a7023be 100644 --- a/dist/src/sources/applemusic.js +++ b/dist/src/sources/applemusic.js @@ -332,7 +332,6 @@ export default class AppleMusicSource { data: { info: { name: album.attributes?.name || 'Unknown Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -358,12 +357,18 @@ export default class AppleMusicSource { data: { info: { name: playlist.attributes?.name || 'Unknown Playlist', - author: playlist.attributes?.curatorName?.trim() || '', - thumbnail: this.parseArtwork(playlist.attributes?.artwork) || '', 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: playlist.relationships?.tracks?.meta?.total ?? tracks.length + } } }; } @@ -388,7 +393,6 @@ export default class AppleMusicSource { data: { info: { name: `${artist.attributes?.name}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/dist/src/sources/audius.js b/dist/src/sources/audius.js index 9833b060e..49f108c37 100644 --- a/dist/src/sources/audius.js +++ b/dist/src/sources/audius.js @@ -346,7 +346,6 @@ export default class AudiusSource { data: { info: { name: playlistData.playlist_name || 'Audius Playlist', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -393,7 +392,6 @@ export default class AudiusSource { data: { info: { name: albumData.playlist_name || 'Audius Album', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -459,7 +457,6 @@ export default class AudiusSource { data: { info: { name: `${user.name || artist}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { diff --git a/dist/src/sources/bandcamp.js b/dist/src/sources/bandcamp.js index 24ef9ddcb..e1f215271 100644 --- a/dist/src/sources/bandcamp.js +++ b/dist/src/sources/bandcamp.js @@ -175,7 +175,6 @@ export default class BandcampSource { const playlist = { info: { name: this.normalizeText(tralbum.current?.title) ?? 'BandCamp Playlist', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/bilibili.js b/dist/src/sources/bilibili.js index 767da3be9..6dcdc9d0c 100644 --- a/dist/src/sources/bilibili.js +++ b/dist/src/sources/bilibili.js @@ -454,7 +454,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: videoData.title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: videoData.title, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -583,7 +583,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: seasonTitle, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: seasonTitle, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -645,7 +645,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -750,7 +750,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: result.season_title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: result.season_title, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -842,7 +842,6 @@ export default class BilibiliSource { data: { info: { name: infoRes?.data?.title || 'Bilibili Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -962,7 +961,7 @@ export default class BilibiliSource { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index f8a9b2ed2..9210e0606 100644 --- a/dist/src/sources/deezer.js +++ b/dist/src/sources/deezer.js @@ -219,7 +219,7 @@ export default class DeezerSource { if (tracks.length === 0) return { loadType: 'empty', data: {} }; const playlist = { - info: { name: 'Deezer Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'Deezer Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks }; @@ -312,11 +312,16 @@ export default class DeezerSource { data: { info: { name: entity.title ?? 'Unknown Deezer Collection', - author, - thumbnail, selectedTrack: 0 }, - pluginInfo: {}, + pluginInfo: { + type, + identifier: String(id), + url: entity.link ?? `https://www.deezer.com/${type}/${id}`, + artworkUrl: thumbnail, + author, + totalTracks: entity.nb_tracks ?? tracks.length + }, tracks } }; @@ -339,7 +344,6 @@ export default class DeezerSource { data: { info: { name: `${entity.name ?? 'Unknown Artist'}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/gaana.js b/dist/src/sources/gaana.js index 99f5ae7f3..9085eff0e 100644 --- a/dist/src/sources/gaana.js +++ b/dist/src/sources/gaana.js @@ -433,7 +433,7 @@ export default class GaanaSource { return { loadType: 'playlist', data: { - info: { name: infoName, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: infoName, selectedTrack: 0 }, pluginInfo: { type, url, artwork, author: author || undefined }, tracks } diff --git a/dist/src/sources/genius.js b/dist/src/sources/genius.js index 4a6aa0b30..41325a666 100644 --- a/dist/src/sources/genius.js +++ b/dist/src/sources/genius.js @@ -140,7 +140,6 @@ export default class GeniusSource { const playlist = { info: { name: `${title} - ${artist} (Genius)`, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/googledrive.js b/dist/src/sources/googledrive.js index c0fb0d5b7..037fee7fe 100644 --- a/dist/src/sources/googledrive.js +++ b/dist/src/sources/googledrive.js @@ -296,7 +296,7 @@ export default class GoogleDriveSource { return { loadType: 'playlist', data: { - info: { name: embeddedResult.title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: embeddedResult.title, selectedTrack: 0 }, tracks: embeddedResult.tracks, pluginInfo: {} } @@ -391,7 +391,7 @@ export default class GoogleDriveSource { return { loadType: 'playlist', data: { - info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: title, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/lastfm.js b/dist/src/sources/lastfm.js index aac169b0c..f1dc72994 100644 --- a/dist/src/sources/lastfm.js +++ b/dist/src/sources/lastfm.js @@ -208,7 +208,7 @@ export default class LastFMSource { return { loadType: 'playlist', data: { - info: { name: `${collectionTitle} - ${artist}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `${collectionTitle} - ${artist}`, selectedTrack: 0 }, pluginInfo: {}, tracks } diff --git a/dist/src/sources/monochrome.js b/dist/src/sources/monochrome.js index b7b218f10..7ddcc9105 100644 --- a/dist/src/sources/monochrome.js +++ b/dist/src/sources/monochrome.js @@ -345,7 +345,7 @@ class MonochromeSource { return tracks.length > 0 ? { loadType: 'playlist', - data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } } : { loadType: 'empty', data: {} }; } diff --git a/dist/src/sources/pandora.js b/dist/src/sources/pandora.js index 8c3069a95..f120f7495 100644 --- a/dist/src/sources/pandora.js +++ b/dist/src/sources/pandora.js @@ -491,7 +491,7 @@ export default class PandoraSource { return { loadType: 'album', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -570,7 +570,6 @@ export default class PandoraSource { data: { info: { name: `${artistName}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -616,7 +615,7 @@ export default class PandoraSource { return { loadType: 'playlist', data: { - info: { name: data?.name ?? 'Playlist', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: data?.name ?? 'Playlist', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -643,7 +642,7 @@ export default class PandoraSource { return { loadType: 'station', data: { - info: { name: stationData?.name ?? 'Station', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: stationData?.name ?? 'Station', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -775,7 +774,7 @@ export default class PandoraSource { return { loadType: 'podcast', data: { - info: { name: programName ?? 'Podcast', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: programName ?? 'Podcast', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/qobuz.js b/dist/src/sources/qobuz.js index 30c5efc2a..0193d0d40 100644 --- a/dist/src/sources/qobuz.js +++ b/dist/src/sources/qobuz.js @@ -167,7 +167,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: 'Qobuz Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'Qobuz Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -279,7 +279,6 @@ export default class QobuzSource { data: { info: { name: this.asString(data.title) || 'Unknown Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -309,12 +308,19 @@ export default class QobuzSource { data: { info: { name: this.asString(data.name) || 'Unknown Playlist', - author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '', - thumbnail: this.getPlaylistCoverUrl(data), 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: this.asNumber(data.tracks_count) ?? + this.asNumber(tracksBlock.total) ?? + tracks.length + } } }; } @@ -339,7 +345,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: `${artistName}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `${artistName}'s Top Tracks`, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/rss.js b/dist/src/sources/rss.js index 1f4253171..30d8e7bf6 100644 --- a/dist/src/sources/rss.js +++ b/dist/src/sources/rss.js @@ -108,7 +108,7 @@ export default class RssSource { return { loadType: 'empty', data: {} }; } const playlistData = { - info: { name: channelTitle, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: channelTitle, selectedTrack: 0 }, pluginInfo: { feedUrl: url }, tracks }; diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js index 6431d7732..21d2b2cfd 100644 --- a/dist/src/sources/soundcloud.js +++ b/dist/src/sources/soundcloud.js @@ -480,12 +480,17 @@ export default class SoundCloudSource { data: { info: { name: body.title || 'Untitled playlist', - author: body.user?.username?.trim() || '', - thumbnail: this._upgradeArtworkUrl(body.artwork_url ?? - tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl), 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: body.track_count ?? body.tracks?.length ?? tracks.length + }, tracks } }; diff --git a/dist/src/sources/spotify.js b/dist/src/sources/spotify.js index 0a5aabf88..0ce6162b5 100644 --- a/dist/src/sources/spotify.js +++ b/dist/src/sources/spotify.js @@ -962,7 +962,7 @@ export default class SpotifySource { return { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -994,7 +994,7 @@ export default class SpotifySource { ? { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1035,6 +1035,15 @@ export default class SpotifySource { 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: totalTracks || tracks.length + }); if (this.anonymousToken || id.startsWith('37i9dQZ')) { let offset = 0; const limit = 100; @@ -1053,6 +1062,7 @@ export default class SpotifySource { 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) @@ -1077,9 +1087,9 @@ export default class SpotifySource { return { loadType: 'playlist', data: { - info: { name, author, thumbnail, selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, - pluginInfo: {} + pluginInfo: buildPluginInfo() } }; } @@ -1095,6 +1105,8 @@ export default class SpotifySource { 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) @@ -1113,9 +1125,9 @@ export default class SpotifySource { ? { loadType: 'playlist', data: { - info: { name, author, thumbnail, selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, - pluginInfo: {} + pluginInfo: buildPluginInfo() } } : { loadType: 'empty', data: {} }; @@ -1144,7 +1156,6 @@ export default class SpotifySource { data: { info: { name: `${data.artistUnion.profile.name}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -1281,7 +1292,6 @@ export default class SpotifySource { data: { info: { name: `Spotify Recommendations for ${id}`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/dist/src/sources/telegram.js b/dist/src/sources/telegram.js index 4a3486545..8ea03e823 100644 --- a/dist/src/sources/telegram.js +++ b/dist/src/sources/telegram.js @@ -135,7 +135,6 @@ export default class TelegramSource { const playlistData = { info: { name: title, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/dist/src/sources/tidal.js b/dist/src/sources/tidal.js index d2507862c..2ca8a7771 100644 --- a/dist/src/sources/tidal.js +++ b/dist/src/sources/tidal.js @@ -255,7 +255,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name: `Mix: ${mixId}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `Mix: ${mixId}`, selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -426,7 +426,6 @@ export default class TidalSource { data: { info: { name: this.asString(albumData.title) || 'Unknown Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -487,13 +486,18 @@ export default class TidalSource { data: { info: { name: this.asString(playlistData.title) || 'Unknown Playlist', - // The playlist endpoint only exposes `creator.id`, not a name. - author: '', - thumbnail: this.getPlaylistCoverUrl(playlistData), 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 + } } }; } @@ -533,7 +537,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/vkmusic.js b/dist/src/sources/vkmusic.js index 83f0831ab..44e3d8fda 100644 --- a/dist/src/sources/vkmusic.js +++ b/dist/src/sources/vkmusic.js @@ -253,7 +253,7 @@ export default class VKMusicSource { return { loadType: 'playlist', data: { - info: { name: 'VK Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'VK Recommendations', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -323,7 +323,7 @@ export default class VKMusicSource { return { loadType: 'playlist', data: { - info: { name: 'VK Playlist', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'VK Playlist', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -363,7 +363,7 @@ export default class VKMusicSource { return { loadType: 'playlist', data: { - info: { name: 'VK Scraped Playlist', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'VK Scraped Playlist', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/dist/src/sources/yandexmusic.js b/dist/src/sources/yandexmusic.js index 130ce6e98..684686ead 100644 --- a/dist/src/sources/yandexmusic.js +++ b/dist/src/sources/yandexmusic.js @@ -334,7 +334,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: 'Yandex Music Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'Yandex Music Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -596,7 +596,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: album.title || 'Yandex Music Album', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: album.title || 'Yandex Music Album', selectedTrack: 0 }, pluginInfo: { type: 'album' }, tracks } @@ -628,7 +628,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: `${name}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `${name}'s Top Tracks`, selectedTrack: 0 }, pluginInfo: { type: 'artist' }, tracks } @@ -696,7 +696,7 @@ export default class YandexMusicSource { return { loadType: 'playlist', data: { - info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: title, selectedTrack: 0 }, pluginInfo: { type: 'playlist' }, tracks } diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index 399a67f82..b2277c06b 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -548,7 +548,7 @@ export default class YouTubeSource { return { loadType: 'playlist', data: { - info: { name: 'YouTube Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'YouTube Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -600,25 +600,34 @@ export default class YouTubeSource { return result; } /** - * Fills in `info.author` and `info.thumbnail` on a resolved playlist. + * 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 they are read from the public playlist page - * instead. Best-effort: any failure leaves the existing values untouched. + * 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) { - const info = playlist?.info; - if (!info || (info.author && info.thumbnail)) + if (!playlist?.info) return; const playlistId = url.match(/[?&]list=([\w-]+)/)?.[1]; if (!playlistId) return; - const meta = await this._fetchPlaylistMeta(playlistId); - if (!info.author && meta.author) - info.author = meta.author; - if (!info.thumbnail && meta.thumbnail) - info.thumbnail = meta.thumbnail; + 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: current.totalTracks ?? playlist.tracks.length + }; } /** * Scrapes the playlist page for its owner and thumbnail. diff --git a/dist/src/sources/youtube/common.js b/dist/src/sources/youtube/common.js index baac84982..c59c6a4ff 100644 --- a/dist/src/sources/youtube/common.js +++ b/dist/src/sources/youtube/common.js @@ -1889,7 +1889,7 @@ export class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack }, + info: { name: playlistTitle, selectedTrack }, pluginInfo: {}, tracks } @@ -2009,7 +2009,7 @@ export class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: playlistTitle, selectedTrack: 0 }, pluginInfo: {}, tracks } diff --git a/src/managers/sourceManager.ts b/src/managers/sourceManager.ts index d43c25553..2282823cd 100644 --- a/src/managers/sourceManager.ts +++ b/src/managers/sourceManager.ts @@ -466,7 +466,6 @@ export default class SourcesManager implements SourceManagerLike { const playlistData: PlaylistData = { info: { name: `Search results for: ${query}`, - author: '', thumbnail: '', selectedTrack: -1 }, pluginInfo: {}, diff --git a/src/sources/amazonmusic.ts b/src/sources/amazonmusic.ts index 3d335d164..1627467a3 100644 --- a/src/sources/amazonmusic.ts +++ b/src/sources/amazonmusic.ts @@ -771,7 +771,7 @@ export default class AmazonMusicSource implements SourceInstance { return { loadType: collectionType as 'playlist' | 'album' | 'artist', data: { - info: { name: collectionName, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: collectionName, selectedTrack: 0 }, tracks: tracks.map((t) => ({ encoded: encodeTrack({ ...t, details: [] }), info: t, diff --git a/src/sources/anghami.ts b/src/sources/anghami.ts index b882e2dda..66513a318 100644 --- a/src/sources/anghami.ts +++ b/src/sources/anghami.ts @@ -504,7 +504,7 @@ export default class AnghamiSource { ): Promise<{ loadType: 'playlist' data: { - info: { name: string; author: string; thumbnail: string; selectedTrack: number } + info: { name: string; selectedTrack: number } pluginInfo: Record tracks: AnghamiTrackData[] } @@ -543,7 +543,7 @@ export default class AnghamiSource { return { loadType: 'playlist', - data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } } } @@ -719,7 +719,6 @@ export default class AnghamiSource { this.asString(artistBody.name) ?? this.asString(artistBody.title) ?? 'Artist Top Tracks', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, diff --git a/src/sources/applemusic.ts b/src/sources/applemusic.ts index fa289bbd1..f5ffe2e9a 100644 --- a/src/sources/applemusic.ts +++ b/src/sources/applemusic.ts @@ -493,7 +493,6 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: album.attributes?.name || 'Unknown Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -528,12 +527,20 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: playlist.attributes?.name || 'Unknown Playlist', - author: playlist.attributes?.curatorName?.trim() || '', - thumbnail: this.parseArtwork(playlist.attributes?.artwork) || '', 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: + playlist.relationships?.tracks?.meta?.total ?? tracks.length + } } } } @@ -567,7 +574,6 @@ export default class AppleMusicSource implements SourceInstance { data: { info: { name: `${artist.attributes?.name}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/src/sources/audius.ts b/src/sources/audius.ts index 966834fd3..0b1219b63 100644 --- a/src/sources/audius.ts +++ b/src/sources/audius.ts @@ -420,7 +420,6 @@ export default class AudiusSource implements SourceInstance { data: { info: { name: playlistData.playlist_name || 'Audius Playlist', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -480,7 +479,6 @@ export default class AudiusSource implements SourceInstance { data: { info: { name: albumData.playlist_name || 'Audius Album', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { @@ -562,7 +560,6 @@ export default class AudiusSource implements SourceInstance { data: { info: { name: `${user.name || artist}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: { diff --git a/src/sources/bandcamp.ts b/src/sources/bandcamp.ts index d67f0e234..5c9b220b8 100644 --- a/src/sources/bandcamp.ts +++ b/src/sources/bandcamp.ts @@ -250,7 +250,6 @@ export default class BandcampSource { info: { name: this.normalizeText(tralbum.current?.title) ?? 'BandCamp Playlist', - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/bilibili.ts b/src/sources/bilibili.ts index e02b65341..c33fe1154 100644 --- a/src/sources/bilibili.ts +++ b/src/sources/bilibili.ts @@ -561,7 +561,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: videoData.title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: videoData.title, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -730,7 +730,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: seasonTitle, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: seasonTitle, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -808,7 +808,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -929,7 +929,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: result.season_title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: result.season_title, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1046,7 +1046,6 @@ export default class BilibiliSource implements SourceInstance { data: { info: { name: infoRes?.data?.title || 'Bilibili Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -1191,7 +1190,7 @@ export default class BilibiliSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: `Uploads by ${firstAuthor}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `Uploads by ${firstAuthor}`, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts index 14eaa7dcd..472755ac3 100644 --- a/src/sources/deezer.ts +++ b/src/sources/deezer.ts @@ -562,16 +562,6 @@ interface DeezerPlaylistData { */ name: string - /** - * Playlist author. - */ - author: string - - /** - * Playlist thumbnail. - */ - thumbnail: string - /** * Default selected track index. */ @@ -875,7 +865,7 @@ export default class DeezerSource { if (tracks.length === 0) return { loadType: 'empty', data: {} } const playlist: DeezerPlaylistData = { - info: { name: 'Deezer Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'Deezer Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -1000,11 +990,16 @@ export default class DeezerSource { data: { info: { name: entity.title ?? 'Unknown Deezer Collection', - author, - thumbnail, selectedTrack: 0 }, - pluginInfo: {} as Record, + pluginInfo: { + type, + identifier: String(id), + url: entity.link ?? `https://www.deezer.com/${type}/${id}`, + artworkUrl: thumbnail, + author, + totalTracks: entity.nb_tracks ?? tracks.length + }, tracks } satisfies DeezerPlaylistData } @@ -1038,7 +1033,6 @@ export default class DeezerSource { data: { info: { name: `${entity.name ?? 'Unknown Artist'}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/gaana.ts b/src/sources/gaana.ts index 8439cccd9..3ea855b04 100644 --- a/src/sources/gaana.ts +++ b/src/sources/gaana.ts @@ -584,7 +584,7 @@ export default class GaanaSource { return { loadType: 'playlist', data: { - info: { name: infoName, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: infoName, selectedTrack: 0 }, pluginInfo: { type, url, artwork, author: author || undefined }, tracks } diff --git a/src/sources/genius.ts b/src/sources/genius.ts index 574916ac4..99d9ed29e 100644 --- a/src/sources/genius.ts +++ b/src/sources/genius.ts @@ -192,16 +192,6 @@ interface GeniusPlaylistData { */ name: string - /** - * Playlist author. - */ - author: string - - /** - * Playlist thumbnail. - */ - thumbnail: string - /** * Default selected track index. */ @@ -428,7 +418,6 @@ export default class GeniusSource { const playlist: GeniusPlaylistData = { info: { name: `${title} - ${artist} (Genius)`, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/googledrive.ts b/src/sources/googledrive.ts index 2704af38c..31407e36f 100644 --- a/src/sources/googledrive.ts +++ b/src/sources/googledrive.ts @@ -393,7 +393,7 @@ export default class GoogleDriveSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: embeddedResult.title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: embeddedResult.title, selectedTrack: 0 }, tracks: embeddedResult.tracks, pluginInfo: {} } @@ -511,7 +511,7 @@ export default class GoogleDriveSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: title, selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/lastfm.ts b/src/sources/lastfm.ts index 23f44c680..0714c06b0 100644 --- a/src/sources/lastfm.ts +++ b/src/sources/lastfm.ts @@ -305,7 +305,7 @@ export default class LastFMSource { return { loadType: 'playlist', data: { - info: { name: `${collectionTitle} - ${artist}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `${collectionTitle} - ${artist}`, selectedTrack: 0 }, pluginInfo: {} as Record, tracks } diff --git a/src/sources/monochrome.ts b/src/sources/monochrome.ts index a40a5c09f..c366cb0f1 100644 --- a/src/sources/monochrome.ts +++ b/src/sources/monochrome.ts @@ -474,7 +474,7 @@ class MonochromeSource implements SourceInstance { return tracks.length > 0 ? { loadType: 'playlist', - data: { info: { name, author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {}, tracks } + data: { info: { name, selectedTrack: 0 }, pluginInfo: {}, tracks } } : { loadType: 'empty', data: {} } } diff --git a/src/sources/pandora.ts b/src/sources/pandora.ts index 228b31955..72ef69f62 100644 --- a/src/sources/pandora.ts +++ b/src/sources/pandora.ts @@ -663,7 +663,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'album', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -756,7 +756,6 @@ export default class PandoraSource implements SourceInstance { data: { info: { name: `${artistName}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -811,7 +810,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: data?.name ?? 'Playlist', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: data?.name ?? 'Playlist', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -846,7 +845,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'station', data: { - info: { name: stationData?.name ?? 'Station', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: stationData?.name ?? 'Station', selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1033,7 +1032,7 @@ export default class PandoraSource implements SourceInstance { return { loadType: 'podcast', data: { - info: { name: programName ?? 'Podcast', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: programName ?? 'Podcast', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/qobuz.ts b/src/sources/qobuz.ts index e7e515567..195bde14a 100644 --- a/src/sources/qobuz.ts +++ b/src/sources/qobuz.ts @@ -243,7 +243,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: 'Qobuz Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'Qobuz Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -369,7 +369,6 @@ export default class QobuzSource { data: { info: { name: this.asString(data.title) || 'Unknown Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -407,12 +406,20 @@ export default class QobuzSource { data: { info: { name: this.asString(data.name) || 'Unknown Playlist', - author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '', - thumbnail: this.getPlaylistCoverUrl(data), 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: + this.asNumber(data.tracks_count) ?? + this.asNumber(tracksBlock.total) ?? + tracks.length + } } } } @@ -445,7 +452,7 @@ export default class QobuzSource { return { loadType: 'playlist', data: { - info: { name: `${artistName}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `${artistName}'s Top Tracks`, selectedTrack: 0 }, tracks, pluginInfo: {} as Record } diff --git a/src/sources/rss.ts b/src/sources/rss.ts index 46aa5bf55..9a64d9249 100644 --- a/src/sources/rss.ts +++ b/src/sources/rss.ts @@ -94,16 +94,6 @@ interface RssPlaylistData { */ name: string - /** - * Playlist author. - */ - author: string - - /** - * Playlist thumbnail. - */ - thumbnail: string - /** * Default selected track index. */ @@ -256,7 +246,7 @@ export default class RssSource { } const playlistData: RssPlaylistData = { - info: { name: channelTitle, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: channelTitle, selectedTrack: 0 }, pluginInfo: { feedUrl: url }, tracks } diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index 2cf3024d4..6710a656f 100644 --- a/src/sources/soundcloud.ts +++ b/src/sources/soundcloud.ts @@ -679,14 +679,19 @@ export default class SoundCloudSource implements SoundCloudSourceState { data: { info: { name: body.title || 'Untitled playlist', - author: body.user?.username?.trim() || '', - thumbnail: this._upgradeArtworkUrl( + selectedTrack: 0 + }, + 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 ), - selectedTrack: 0 + author: body.user?.username?.trim() || '', + totalTracks: body.track_count ?? body.tracks?.length ?? tracks.length }, - pluginInfo: {}, tracks } } diff --git a/src/sources/spotify.ts b/src/sources/spotify.ts index 50dfde305..d318467af 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, @@ -1168,7 +1169,7 @@ export default class SpotifySource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1205,7 +1206,7 @@ export default class SpotifySource implements SourceInstance { ? { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} } @@ -1251,6 +1252,16 @@ export default class SpotifySource implements SourceInstance { 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: totalTracks || tracks.length + }) if (this.anonymousToken || id.startsWith('37i9dQZ')) { let offset = 0 @@ -1279,6 +1290,7 @@ export default class SpotifySource implements SourceInstance { data.playlistV2.images?.items?.[0]?.sources ) total = data.playlistV2.content?.totalCount || 0 + totalTracks = total } const items = data.playlistV2.content?.items || [] @@ -1302,9 +1314,9 @@ export default class SpotifySource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name, author, thumbnail, selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, - pluginInfo: {} + pluginInfo: buildPluginInfo() } } } @@ -1326,6 +1338,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 @@ -1344,9 +1357,9 @@ export default class SpotifySource implements SourceInstance { ? { loadType: 'playlist', data: { - info: { name, author, thumbnail, selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, - pluginInfo: {} + pluginInfo: buildPluginInfo() } } : { loadType: 'empty', data: {} } @@ -1380,7 +1393,6 @@ export default class SpotifySource implements SourceInstance { data: { info: { name: `${data.artistUnion.profile.name}'s Top Tracks`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -1532,7 +1544,6 @@ export default class SpotifySource implements SourceInstance { data: { info: { name: `Spotify Recommendations for ${id}`, - author: '', thumbnail: '', selectedTrack: 0 }, tracks, diff --git a/src/sources/telegram.ts b/src/sources/telegram.ts index a5328c001..48deb90e8 100644 --- a/src/sources/telegram.ts +++ b/src/sources/telegram.ts @@ -79,16 +79,6 @@ interface TelegramPlaylistData { */ name: string - /** - * Playlist author. - */ - author: string - - /** - * Playlist thumbnail. - */ - thumbnail: string - /** * Default selected track index. */ @@ -272,7 +262,6 @@ export default class TelegramSource { const playlistData: TelegramPlaylistData = { info: { name: title, - author: '', thumbnail: '', selectedTrack: 0 }, pluginInfo: {} as Record, diff --git a/src/sources/tidal.ts b/src/sources/tidal.ts index 12377e984..42aa43e54 100644 --- a/src/sources/tidal.ts +++ b/src/sources/tidal.ts @@ -327,7 +327,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name: `Mix: ${mixId}`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `Mix: ${mixId}`, selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -561,7 +561,6 @@ export default class TidalSource { data: { info: { name: this.asString(albumData.title) || 'Unknown Album', - author: '', thumbnail: '', selectedTrack: 0 }, tracks, @@ -647,13 +646,18 @@ export default class TidalSource { data: { info: { name: this.asString(playlistData.title) || 'Unknown Playlist', - // The playlist endpoint only exposes `creator.id`, not a name. - author: '', - thumbnail: this.getPlaylistCoverUrl(playlistData), 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 + } } } } @@ -720,7 +724,7 @@ export default class TidalSource { return { loadType: 'playlist', data: { - info: { name, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name, selectedTrack: 0 }, tracks, pluginInfo: {} as Record } diff --git a/src/sources/vkmusic.ts b/src/sources/vkmusic.ts index 1ead8cd45..f7451a4ae 100644 --- a/src/sources/vkmusic.ts +++ b/src/sources/vkmusic.ts @@ -334,7 +334,7 @@ export default class VKMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'VK Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'VK Recommendations', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -418,7 +418,7 @@ export default class VKMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'VK Playlist', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'VK Playlist', selectedTrack: 0 }, tracks: res.items.map((item) => this.buildTrack(item)), pluginInfo: {} } @@ -465,7 +465,7 @@ export default class VKMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'VK Scraped Playlist', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'VK Scraped Playlist', selectedTrack: 0 }, tracks, pluginInfo: {} } diff --git a/src/sources/yandexmusic.ts b/src/sources/yandexmusic.ts index 0df7573f0..674841047 100644 --- a/src/sources/yandexmusic.ts +++ b/src/sources/yandexmusic.ts @@ -423,7 +423,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: 'Yandex Music Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'Yandex Music Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -775,7 +775,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: album.title || 'Yandex Music Album', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: album.title || 'Yandex Music Album', selectedTrack: 0 }, pluginInfo: { type: 'album' }, tracks } @@ -821,7 +821,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: `${name}'s Top Tracks`, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: `${name}'s Top Tracks`, selectedTrack: 0 }, pluginInfo: { type: 'artist' }, tracks } @@ -928,7 +928,7 @@ export default class YandexMusicSource implements SourceInstance { return { loadType: 'playlist', data: { - info: { name: title, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: title, selectedTrack: 0 }, pluginInfo: { type: 'playlist' }, tracks } diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index fb7a49cf5..0f712b178 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -769,7 +769,7 @@ export default class YouTubeSource { return { loadType: 'playlist', data: { - info: { name: 'YouTube Recommendations', author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: 'YouTube Recommendations', selectedTrack: 0 }, pluginInfo: { type: 'recommendations' }, tracks } @@ -833,26 +833,40 @@ export default class YouTubeSource { } /** - * Fills in `info.author` and `info.thumbnail` on a resolved playlist. + * 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 they are read from the public playlist page - * instead. Best-effort: any failure leaves the existing values untouched. + * 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 { - const info = playlist?.info - if (!info || (info.author && info.thumbnail)) return + if (!playlist?.info) return const playlistId = url.match(/[?&]list=([\w-]+)/)?.[1] if (!playlistId) return - const meta = await this._fetchPlaylistMeta(playlistId) - if (!info.author && meta.author) info.author = meta.author - if (!info.thumbnail && meta.thumbnail) info.thumbnail = meta.thumbnail + 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: current.totalTracks ?? playlist.tracks.length + } } /** diff --git a/src/sources/youtube/common.ts b/src/sources/youtube/common.ts index ca93636f7..90bdd8924 100644 --- a/src/sources/youtube/common.ts +++ b/src/sources/youtube/common.ts @@ -2678,7 +2678,7 @@ export abstract class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack }, + info: { name: playlistTitle, selectedTrack }, pluginInfo: {}, tracks } @@ -2877,7 +2877,7 @@ export abstract class BaseClient { return { loadType: 'playlist', data: { - info: { name: playlistTitle, author: '', thumbnail: '', selectedTrack: 0 }, + info: { name: playlistTitle, selectedTrack: 0 }, pluginInfo: {}, tracks } diff --git a/src/typings/sources/source.types.ts b/src/typings/sources/source.types.ts index d3d71a0f2..01193fc9e 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 @@ -433,15 +452,15 @@ export interface PlaylistData { info: { /** Playlist name. */ name: string - /** Playlist author. */ - author: string - /** Playlist thumbnail. */ - thumbnail: string /** 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[] } From 161021ed8570c8dabe242c328ca4a3e3cd470192 Mon Sep 17 00:00:00 2001 From: GDjkhp Date: Sun, 25 Oct 2026 06:56:08 +0800 Subject: [PATCH 12/12] improve: cap total tracks --- dist/src/sources/applemusic.js | 4 ++-- dist/src/sources/deezer.js | 4 ++-- dist/src/sources/qobuz.js | 6 +++--- dist/src/sources/soundcloud.js | 4 ++-- dist/src/sources/spotify.js | 4 ++-- dist/src/sources/tidal.js | 4 ++-- dist/src/sources/youtube/YouTube.js | 4 ++-- dist/src/utils.js | 19 ++++++++++++++++++- src/sources/applemusic.ts | 13 ++++++++++--- src/sources/deezer.ts | 6 +++++- src/sources/qobuz.ts | 9 ++++++--- src/sources/soundcloud.ts | 13 +++++++++++-- src/sources/spotify.ts | 3 ++- src/sources/tidal.ts | 6 +++++- src/sources/youtube/YouTube.ts | 6 +++++- src/utils.ts | 19 +++++++++++++++++++ 16 files changed, 96 insertions(+), 28 deletions(-) diff --git a/dist/src/sources/applemusic.js b/dist/src/sources/applemusic.js index b0a7023be..239f5305d 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. @@ -367,7 +367,7 @@ export default class AppleMusicSource { `https://music.apple.com/${country}/playlist/${id}`, artworkUrl: this.parseArtwork(playlist.attributes?.artwork) || '', author: playlist.attributes?.curatorName?.trim() || '', - totalTracks: playlist.relationships?.tracks?.meta?.total ?? tracks.length + totalTracks: capTotalTracks(playlist.relationships?.tracks?.meta?.total ?? tracks.length, this.nodelink.options.playback?.maxPlaylistLength) } } }; diff --git a/dist/src/sources/deezer.js b/dist/src/sources/deezer.js index 9210e0606..9caea0b78 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. */ @@ -320,7 +320,7 @@ export default class DeezerSource { url: entity.link ?? `https://www.deezer.com/${type}/${id}`, artworkUrl: thumbnail, author, - totalTracks: entity.nb_tracks ?? tracks.length + 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 0193d0d40..83c442f66 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'; /** @@ -317,9 +317,9 @@ export default class QobuzSource { url: `https://open.qobuz.com/playlist/${id}`, artworkUrl: this.getPlaylistCoverUrl(data), author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '', - totalTracks: this.asNumber(data.tracks_count) ?? + totalTracks: capTotalTracks(this.asNumber(data.tracks_count) ?? this.asNumber(tracksBlock.total) ?? - tracks.length + tracks.length, max) } } }; diff --git a/dist/src/sources/soundcloud.js b/dist/src/sources/soundcloud.js index 21d2b2cfd..ca170ecec 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; @@ -489,7 +489,7 @@ export default class SoundCloudSource { artworkUrl: this._upgradeArtworkUrl(body.artwork_url ?? tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl), author: body.user?.username?.trim() || '', - totalTracks: body.track_count ?? body.tracks?.length ?? tracks.length + totalTracks: capTotalTracks(body.track_count ?? body.tracks?.length ?? tracks.length, limit) }, tracks } diff --git a/dist/src/sources/spotify.js b/dist/src/sources/spotify.js index 0ce6162b5..f18517b3e 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. @@ -1042,7 +1042,7 @@ export default class SpotifySource { url: `https://open.spotify.com/playlist/${id}`, artworkUrl: thumbnail, author, - totalTracks: totalTracks || tracks.length + totalTracks: capTotalTracks(totalTracks || tracks.length, maxTracks) }); if (this.anonymousToken || id.startsWith('37i9dQZ')) { let offset = 0; diff --git a/dist/src/sources/tidal.js b/dist/src/sources/tidal.js index 2ca8a7771..5ef7ee8d2 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'; @@ -496,7 +496,7 @@ export default class TidalSource { artworkUrl: this.getPlaylistCoverUrl(playlistData), // The playlist endpoint only exposes `creator.id`, not a name. author: '', - totalTracks + totalTracks: capTotalTracks(totalTracks, this.nodelink.options.playback?.maxPlaylistLength) } } }; diff --git a/dist/src/sources/youtube/YouTube.js b/dist/src/sources/youtube/YouTube.js index b2277c06b..bb08a2020 100644 --- a/dist/src/sources/youtube/YouTube.js +++ b/dist/src/sources/youtube/YouTube.js @@ -1,6 +1,6 @@ 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'; @@ -626,7 +626,7 @@ export default class YouTubeSource { url: current.url ?? `https://www.youtube.com/playlist?list=${playlistId}`, artworkUrl: current.artworkUrl || meta.thumbnail, author: current.author || meta.author, - totalTracks: current.totalTracks ?? playlist.tracks.length + totalTracks: capTotalTracks(current.totalTracks ?? playlist.tracks.length, this.nodelink.options.playback?.maxPlaylistLength) }; } /** diff --git a/dist/src/utils.js b/dist/src/utils.js index b3b5ab784..1a14417f7 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 f5ffe2e9a..b36715c62 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. @@ -538,8 +543,10 @@ export default class AppleMusicSource implements SourceInstance { `https://music.apple.com/${country}/playlist/${id}`, artworkUrl: this.parseArtwork(playlist.attributes?.artwork) || '', author: playlist.attributes?.curatorName?.trim() || '', - totalTracks: - playlist.relationships?.tracks?.meta?.total ?? tracks.length + totalTracks: capTotalTracks( + playlist.relationships?.tracks?.meta?.total ?? tracks.length, + this.nodelink.options.playback?.maxPlaylistLength + ) } } } diff --git a/src/sources/deezer.ts b/src/sources/deezer.ts index 472755ac3..c5181c0f0 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, @@ -998,7 +999,10 @@ export default class DeezerSource { url: entity.link ?? `https://www.deezer.com/${type}/${id}`, artworkUrl: thumbnail, author, - totalTracks: entity.nb_tracks ?? tracks.length + 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 195bde14a..84cfc6b0e 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, @@ -415,10 +416,12 @@ export default class QobuzSource { url: `https://open.qobuz.com/playlist/${id}`, artworkUrl: this.getPlaylistCoverUrl(data), author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '', - totalTracks: + totalTracks: capTotalTracks( this.asNumber(data.tracks_count) ?? - this.asNumber(tracksBlock.total) ?? - tracks.length + this.asNumber(tracksBlock.total) ?? + tracks.length, + max + ) } } } diff --git a/src/sources/soundcloud.ts b/src/sources/soundcloud.ts index 6710a656f..dea263f03 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' @@ -690,7 +696,10 @@ export default class SoundCloudSource implements SoundCloudSourceState { tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl ), author: body.user?.username?.trim() || '', - totalTracks: body.track_count ?? body.tracks?.length ?? tracks.length + totalTracks: capTotalTracks( + body.track_count ?? body.tracks?.length ?? tracks.length, + limit + ) }, tracks } diff --git a/src/sources/spotify.ts b/src/sources/spotify.ts index d318467af..7ddd1ce78 100644 --- a/src/sources/spotify.ts +++ b/src/sources/spotify.ts @@ -27,6 +27,7 @@ import type { SpotifyTrack } from '../typings/sources/spotify.types.ts' import { + capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, @@ -1260,7 +1261,7 @@ export default class SpotifySource implements SourceInstance { url: `https://open.spotify.com/playlist/${id}`, artworkUrl: thumbnail, author, - totalTracks: totalTracks || tracks.length + totalTracks: capTotalTracks(totalTracks || tracks.length, maxTracks) }) if (this.anonymousToken || id.startsWith('37i9dQZ')) { diff --git a/src/sources/tidal.ts b/src/sources/tidal.ts index 42aa43e54..db5fbc665 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, @@ -656,7 +657,10 @@ export default class TidalSource { artworkUrl: this.getPlaylistCoverUrl(playlistData), // The playlist endpoint only exposes `creator.id`, not a name. author: '', - totalTracks + totalTracks: capTotalTracks( + totalTracks, + this.nodelink.options.playback?.maxPlaylistLength + ) } } } diff --git a/src/sources/youtube/YouTube.ts b/src/sources/youtube/YouTube.ts index 0f712b178..fb05b62d9 100644 --- a/src/sources/youtube/YouTube.ts +++ b/src/sources/youtube/YouTube.ts @@ -25,6 +25,7 @@ import type { TrackEncodeInput } from '../../typings/utils.types.ts' import { + capTotalTracks, encodeTrack, getBestMatch, http1makeRequest, @@ -865,7 +866,10 @@ export default class YouTubeSource { current.url ?? `https://www.youtube.com/playlist?list=${playlistId}`, artworkUrl: current.artworkUrl || meta.thumbnail, author: current.author || meta.author, - totalTracks: current.totalTracks ?? playlist.tracks.length + totalTracks: capTotalTracks( + current.totalTracks ?? playlist.tracks.length, + this.nodelink.options.playback?.maxPlaylistLength + ) } } diff --git a/src/utils.ts b/src/utils.ts index f0a1ebacc..e46987134 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,