Skip to content
19 changes: 16 additions & 3 deletions dist/src/sources/applemusic.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -360,7 +360,15 @@ export default class AppleMusicSource {
selectedTrack: 0
},
tracks,
pluginInfo: {}
pluginInfo: {
type: 'playlist',
identifier: id,
url: playlist.attributes?.url ??
`https://music.apple.com/${country}/playlist/${id}`,
artworkUrl: this.parseArtwork(playlist.attributes?.artwork) || '',
author: playlist.attributes?.curatorName?.trim() || '',
totalTracks: capTotalTracks(playlist.relationships?.tracks?.meta?.total ?? tracks.length, this.nodelink.options.playback?.maxPlaylistLength)
}
}
};
}
Expand Down Expand Up @@ -406,7 +414,12 @@ export default class AppleMusicSource {
const pages = Math.ceil(total / limit);
const maxPages = parent.type === 'albums' ? this.albumPageLimit : this.playlistPageLimit;
const allowed = maxPages > 0 ? Math.min(pages, maxPages) : pages;
const artwork = this.parseArtwork(parent.attributes?.artwork);
// Album tracks share the album's cover. Playlist tracks come from many
// albums and carry their own artwork, so the playlist cover must not
// replace it.
const artwork = parent.type === 'albums'
? this.parseArtwork(parent.attributes?.artwork)
: null;
const editorialVideo = this.extractVideoUrl(parent.attributes);
const results = baseTracks
.map((t) => this.buildTrack(t, artwork, editorialVideo))
Expand Down
34 changes: 30 additions & 4 deletions dist/src/sources/deezer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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: {} };
Expand All @@ -295,7 +314,14 @@ export default class DeezerSource {
name: entity.title ?? 'Unknown Deezer Collection',
selectedTrack: 0
},
pluginInfo: {},
pluginInfo: {
type,
identifier: String(id),
url: entity.link ?? `https://www.deezer.com/${type}/${id}`,
artworkUrl: thumbnail,
author,
totalTracks: capTotalTracks(entity.nb_tracks ?? tracks.length, this.config.playback?.maxPlaylistLength)
},
tracks
}
};
Expand Down
31 changes: 29 additions & 2 deletions dist/src/sources/qobuz.js
Original file line number Diff line number Diff line change
@@ -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';
/**
Expand Down Expand Up @@ -311,7 +311,16 @@ export default class QobuzSource {
selectedTrack: 0
},
tracks,
pluginInfo: {}
pluginInfo: {
type: 'playlist',
identifier: id,
url: `https://open.qobuz.com/playlist/${id}`,
artworkUrl: this.getPlaylistCoverUrl(data),
author: this.asString(this.asRecord(data.owner)?.name)?.trim() || '',
totalTracks: capTotalTracks(this.asNumber(data.tracks_count) ??
this.asNumber(tracksBlock.total) ??
tracks.length, max)
}
}
};
}
Expand Down Expand Up @@ -516,6 +525,24 @@ export default class QobuzSource {
const encoded = (seed + info + extras).slice(0, -44);
return Buffer.from(encoded, 'base64').toString();
}
/**
* Picks the cover image for a playlist payload.
* Qobuz returns up to four track covers per playlist at several sizes; the
* first one at the largest size is used as the thumbnail.
* @param playlist - Raw `/playlist/get` payload.
* @returns The cover URL, or an empty string when none is present.
*/
getPlaylistCoverUrl(playlist) {
for (const key of ['images300', 'images150', 'images']) {
const images = playlist[key];
if (!Array.isArray(images))
continue;
const first = images.find((url) => typeof url === 'string' && url.length > 0);
if (first)
return first;
}
return '';
}
/**
* Builds encoded track payload.
* @param item - Qobuz track object.
Expand Down
23 changes: 21 additions & 2 deletions dist/src/sources/soundcloud.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -482,11 +482,30 @@ export default class SoundCloudSource {
name: body.title || 'Untitled playlist',
selectedTrack: 0
},
pluginInfo: {},
pluginInfo: {
type: 'playlist',
identifier: String(body.id ?? ''),
url: body.permalink_url ?? '',
artworkUrl: this._upgradeArtworkUrl(body.artwork_url ??
tracks.find((track) => track.info.artworkUrl)?.info.artworkUrl),
author: body.user?.username?.trim() || '',
totalTracks: capTotalTracks(body.track_count ?? body.tracks?.length ?? tracks.length, limit)
},
tracks
}
};
}
/**
* Upgrades a SoundCloud artwork URL to the 500x500 variant.
* The API returns the 100x100 `-large` size; `original` is left untouched.
* @param url - Artwork URL from the API.
* @returns The upgraded URL, or an empty string when there is none.
*/
_upgradeArtworkUrl(url) {
if (!url)
return '';
return url.replace(/-(?:large|small|tiny|badge|mini|crop|t\d+x\d+)(\.[a-z0-9]+)(?=$|\?)/i, '-t500x500$1');
}
_processTracks(collection) {
const max = this.nodelink.options.search.maxResults ?? 50;
const tracks = [];
Expand Down
48 changes: 44 additions & 4 deletions dist/src/sources/spotify.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
*
Expand All @@ -1012,6 +1033,17 @@ export default class SpotifySource {
const maxTracks = this.nodelink.options.playback.maxPlaylistLength || 1000;
const tracks = [];
let name = 'Unknown Playlist';
let author = '';
let thumbnail = '';
let totalTracks = 0;
const buildPluginInfo = () => ({
type: 'playlist',
identifier: id,
url: `https://open.spotify.com/playlist/${id}`,
artworkUrl: thumbnail,
author,
totalTracks: capTotalTracks(totalTracks || tracks.length, maxTracks)
});
if (this.anonymousToken || id.startsWith('37i9dQZ')) {
let offset = 0;
const limit = 100;
Expand All @@ -1027,7 +1059,10 @@ export default class SpotifySource {
break;
if (offset === 0) {
name = data.playlistV2.name;
author = data.playlistV2.ownerV2?.data?.name?.trim() || '';
thumbnail = this._pickLargestImage(data.playlistV2.images?.items?.[0]?.sources);
total = data.playlistV2.content?.totalCount || 0;
totalTracks = total;
}
const items = data.playlistV2.content?.items || [];
if (items.length === 0)
Expand All @@ -1054,19 +1089,24 @@ export default class SpotifySource {
data: {
info: { name, selectedTrack: 0 },
tracks,
pluginInfo: {}
pluginInfo: buildPluginInfo()
}
};
}
}
const metaRes = await this._apiRequest(`/playlists/${id}?market=${this.market}`);
if (metaRes)
if (metaRes) {
name = metaRes.name;
author = metaRes.owner?.display_name?.trim() || author;
thumbnail = metaRes.images?.[0]?.url || thumbnail;
}
let nextUrl = `/playlists/${id}/items?market=${this.market}`;
while (nextUrl && tracks.length < maxTracks) {
const itemsRes = await this._apiRequest(nextUrl);
if (!itemsRes?.items || itemsRes.items.length === 0)
break;
if (!totalTracks)
totalTracks = itemsRes.total;
for (const it of itemsRes.items) {
const node = it.item || it.track;
if (!node)
Expand All @@ -1087,7 +1127,7 @@ export default class SpotifySource {
data: {
info: { name, selectedTrack: 0 },
tracks,
pluginInfo: {}
pluginInfo: buildPluginInfo()
}
}
: { loadType: 'empty', data: {} };
Expand Down
40 changes: 35 additions & 5 deletions dist/src/sources/tidal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -489,7 +489,15 @@ export default class TidalSource {
selectedTrack: 0
},
tracks,
pluginInfo: {}
pluginInfo: {
type: 'playlist',
identifier: id,
url: `https://tidal.com/playlist/${id}`,
artworkUrl: this.getPlaylistCoverUrl(playlistData),
// The playlist endpoint only exposes `creator.id`, not a name.
author: '',
totalTracks: capTotalTracks(totalTracks, this.nodelink.options.playback?.maxPlaylistLength)
}
}
};
}
Expand Down Expand Up @@ -566,6 +574,30 @@ export default class TidalSource {
}
return this.asRecord(body);
}
/**
* Builds a Tidal CDN image URL from an image UUID.
* @param uuid - Image UUID, dash-separated (e.g. `a178483d-e537-...`).
* @param size - Dimensions such as `1080x1080`.
* @returns The resource URL.
*/
buildImageUrl(uuid, size) {
return `https://resources.tidal.com/images/${uuid.replace(/-/g, '/')}/${size}.jpg`;
}
/**
* Picks the cover image for a playlist payload.
* Prefers the square cover, falling back to the 3:2 `image`.
* @param playlist - Raw playlist payload.
* @returns The cover URL, or an empty string when none is present.
*/
getPlaylistCoverUrl(playlist) {
const square = this.asString(playlist.squareImage);
if (square)
return this.buildImageUrl(square, '1080x1080');
const image = this.asString(playlist.image);
if (image)
return this.buildImageUrl(image, '1080x720');
return '';
}
/**
* Creates encoded track payload from raw Tidal item.
* @param item - Raw Tidal track object.
Expand All @@ -591,9 +623,7 @@ export default class TidalSource {
title: this.asString(item.title) || 'Unknown Title',
uri: this.asString(item.url) ||
`https://tidal.com/browse/track/${identifier}`,
artworkUrl: cover
? `https://resources.tidal.com/images/${cover.replace(/-/g, '/')}/1280x1280.jpg`
: null,
artworkUrl: cover ? this.buildImageUrl(cover, '1280x1280') : null,
isrc: this.asString(item.isrc) || null,
sourceName: 'tidal'
};
Expand Down
Loading
Loading