diff --git a/src/streamers/deezer/constants.ts b/src/streamers/deezer/constants.ts index 5f33c00..3867044 100644 --- a/src/streamers/deezer/constants.ts +++ b/src/streamers/deezer/constants.ts @@ -1,6 +1,6 @@ export const GW_LIGHT_URL = 'https://www.deezer.com/ajax/gw-light.php' export const CLIENT_ID = '447462' export const CLIENT_SECRET = 'a83bf7f38ad2f137e444727cfc3775cf' -export const SIZES = [32, 64, 128, 256, 512, 1024] +export const SIZES = [128, 256, 512, 1024] export const BLOWFISH_SECRET = 'g4el58wc0zvf9na1' diff --git a/src/streamers/deezer/main.ts b/src/streamers/deezer/main.ts index 7928c0e..ee15a10 100644 --- a/src/streamers/deezer/main.ts +++ b/src/streamers/deezer/main.ts @@ -5,6 +5,7 @@ import { GetByUrlResponse, GetStreamResponse, ItemType, + PlaylistGetByUrlResponse, SearchResults, StreamerAccount, StreamerWithLogin, @@ -15,6 +16,7 @@ import { createHash } from 'crypto' import { DeezerAlbum, DeezerArtist, + DeezerPlaylistMetadata, DeezerFormat, DeezerLoginResponse, DeezerMediaResponse, @@ -22,7 +24,8 @@ import { DeezerUserData, parseAlbum, parseArtist, - parseTrack + parseTrack, + parsePlaylistMetadata } from './parse.js' import { Readable, Transform } from 'stream' import { Blowfish } from 'blowfish-cbc' @@ -43,9 +46,15 @@ interface APIMethod { DATA: DeezerAlbum SONGS: { data: DeezerTrack[] } } + 'playlist.getSongs': { + data: DeezerTrack[] + } 'deezer.pageTrack': { DATA: DeezerTrack } + 'deezer.pagePlaylist': { + DATA: DeezerPlaylistMetadata + } 'user.getArl': string 'search.music': { data: (DeezerArtist | DeezerAlbum | DeezerTrack)[] } 'song.getListData': { data: DeezerTrack[] } @@ -272,7 +281,7 @@ export default class Deezer implements StreamerWithLogin { async #getInfoFromUrl(url: URL): Promise<{ type: ItemType; id: number }> { if (url.hostname == 'deezer.page.link') url = await this.#unshortenUrl(url) - const match = url.pathname.match(/^\/(?:[a-z]{2}\/)?(track|album|artist)\/(\d+)\/?$/) + const match = url.pathname.match(/^\/(?:[a-z]{2}\/)?(track|album|artist|playlist)\/(\d+)\/?$/) if (!match) throw new Error('URL not supported') const [, type, id] = match @@ -317,6 +326,29 @@ export default class Deezer implements StreamerWithLogin { return data } + // Playlist + + async #getPlaylist(id: number): Promise { + const playlistMetadata = ( + await this.#apiCall('deezer.pagePlaylist', { + playlist_id: id + }) + ).DATA + const playlistTracks = ( + await this.#apiCall('playlist.getSongs', { + playlist_id: id, + nb: 9999, + start: 0 + }) + ).data + + return { + type: 'playlist', + metadata: parsePlaylistMetadata(playlistMetadata), + tracks: playlistTracks.map(parseTrack) + } + } + // Track async #getTrackData(id: number): Promise { @@ -501,6 +533,9 @@ export default class Deezer implements StreamerWithLogin { } } } + case 'playlist': { + return await this.#getPlaylist(id) + } default: throw new Error('URL unrecognised') } diff --git a/src/streamers/deezer/parse.ts b/src/streamers/deezer/parse.ts index 8a41b1c..1acfc2e 100644 --- a/src/streamers/deezer/parse.ts +++ b/src/streamers/deezer/parse.ts @@ -1,4 +1,4 @@ -import { Album, Artist, CoverArtwork, Track } from '../../types.js' +import { Album, Artist, CoverArtwork, Playlist, Track } from '../../types.js' import { SIZES } from './constants.js' export enum DeezerFormat { @@ -185,3 +185,22 @@ export function parseArtwork(picture: string): CoverArtwork[] { width: size })) } + +export interface DeezerPlaylistMetadata { + PLAYLIST_ID: string + TITLE: string + DESCRIPTION: string + PLAYLIST_PICTURE: string + DURATION: number + NB_SONG: number +} + +export function parsePlaylistMetadata(raw: DeezerPlaylistMetadata) { + return { + id: raw.PLAYLIST_ID, + title: raw.TITLE, + url: `https://www.deezer.com/playlist/${raw.PLAYLIST_ID}`, + coverArtwork: parseArtwork(raw.PLAYLIST_PICTURE), + trackCount: raw.NB_SONG + } +} diff --git a/src/streamers/qobuz/main.ts b/src/streamers/qobuz/main.ts index aa54b1f..0670567 100644 --- a/src/streamers/qobuz/main.ts +++ b/src/streamers/qobuz/main.ts @@ -10,7 +10,16 @@ import { StreamerAccount } from '../../types.js' import { DEFAULT_HEADERS } from './constants.js' -import { parseAlbum, parseTrack, parseArtist, RawAlbum, RawArtist, RawTrack } from './parse.js' +import { + parseAlbum, + parseTrack, + parseArtist, + RawAlbum, + RawArtist, + RawTrack, + RawPlaylist, + parsePlaylist +} from './parse.js' import { Readable } from 'stream' function headers(token?: string): HeadersInit { @@ -207,6 +216,7 @@ export default class Qobuz implements StreamerWithLogin { app_id: this.appId }) return { + type: 'album', metadata: { ...parseAlbum(albumResponse), trackCount: albumResponse.tracks?.items.length }, tracks: albumResponse.tracks?.items.map(parseTrack) } @@ -224,7 +234,21 @@ export default class Qobuz implements StreamerWithLogin { ) } - #getUrlParts(url: string): ['artist' | 'album' | 'track', string] { + async #getPlaylist(id: string) { + const init = await this.#get('playlist/get', { + playlist_id: id, + extra: 'tracks,getSimilarPlaylists', + offset: 0, + limit: 1000, + app_id: this.appId + }) + + if (init.tracks_count > 1000) + throw new Error('To be added, playlists with over a thousand tracks.') + else return parsePlaylist(init) + } + + #getUrlParts(url: string): ['artist' | 'album' | 'track' | 'playlist', string] { const urlObj = new URL(url) if (urlObj.hostname == 'www.qobuz.com' || urlObj.hostname == 'qobuz.com') { const urlParts = url @@ -239,6 +263,8 @@ export default class Qobuz implements StreamerWithLogin { case 'album': case 'track': return [type, id] + case 'playlists': + return ['playlist', id] default: throw new Error('URL unrecognised') } @@ -248,7 +274,12 @@ export default class Qobuz implements StreamerWithLogin { ?.slice(1, 3) if (!urlParts) throw new Error('URL not supported') urlParts[1] = urlParts[1].replace(/\?.*?$/, '') - if (urlParts[0] != 'artist' && urlParts[0] != 'album' && urlParts[0] != 'track') { + if ( + urlParts[0] != 'artist' && + urlParts[0] != 'album' && + urlParts[0] != 'track' && + urlParts[0] != 'playlist' + ) { throw new Error('URL unrecognised') } return [urlParts[0], urlParts[1]] @@ -282,6 +313,9 @@ export default class Qobuz implements StreamerWithLogin { metadata: await this.#getArtistMetadata(id) } } + case 'playlist': { + return await this.#getPlaylist(id) + } default: throw new Error('URL unrecognised') } diff --git a/src/streamers/qobuz/parse.ts b/src/streamers/qobuz/parse.ts index 96910ff..7ed4759 100644 --- a/src/streamers/qobuz/parse.ts +++ b/src/streamers/qobuz/parse.ts @@ -1,4 +1,4 @@ -import { Artist, Album, Track } from '../../types.js' +import { Artist, Album, Track, PlaylistGetByUrlResponse } from '../../types.js' export interface RawArtist { id: number @@ -154,3 +154,37 @@ function parsePerformers(performers: string, track: Track) { return track } + +export interface RawPlaylist { + owner: { + id: number + name: string + } + id: number + name: string + images: string[] + is_collaborative: boolean + description: string + created_at: number + duration: number + tracks_count: number + tracks: { + offset?: number + limit?: number + total?: number + items: RawTrack[] + } +} + +export function parsePlaylist(raw: RawPlaylist) { + return { + type: 'playlist', + metadata: { + id: raw.id, + title: raw.name, + trackCount: raw?.tracks_count || raw?.tracks?.total, + url: `https://open.qobuz.com/playlist/${raw.id}` + }, + tracks: raw.tracks?.items.map(parseTrack) + } +} diff --git a/src/streamers/spotify/main.ts b/src/streamers/spotify/main.ts index d9cd8a0..ea69db1 100644 --- a/src/streamers/spotify/main.ts +++ b/src/streamers/spotify/main.ts @@ -1,5 +1,12 @@ import Librespot, { LibrespotOptions } from 'librespot' -import { parseArtist, parseAlbum, parseTrack, parseEpisode, parsePodcast } from './parse.js' +import { + parseArtist, + parseAlbum, + parseTrack, + parseEpisode, + parsePodcast, + parsePlaylist +} from './parse.js' import { GetByUrlResponse, ItemType, @@ -41,7 +48,9 @@ class Spotify implements StreamerWithLogin { if (this.storedCredential) return await this.client.loginWithStoredCreds(username, password) else return await this.client.login(username, password) } - #getUrlParts(url: string): ['artist' | 'album' | 'track' | 'episode' | 'show', string] { + #getUrlParts( + url: string + ): ['artist' | 'album' | 'track' | 'episode' | 'show' | 'playlist', string] { const urlObj = new URL(url) const parts = urlObj.pathname.slice(1).split('/') if (parts.length > 2) throw new Error('Unknown Spotify URL') @@ -50,7 +59,8 @@ class Spotify implements StreamerWithLogin { parts[0] != 'track' && parts[0] != 'album' && parts[0] != 'show' && - parts[0] != 'episode' + parts[0] != 'episode' && + parts[0] != 'playlist' ) { throw new Error(`Spotify type "${parts[0]}" unsupported`) } @@ -132,6 +142,14 @@ class Spotify implements StreamerWithLogin { episodes: metadata.episodes?.map((e) => parseEpisode(e)) ?? [] } } + case 'playlist': { + const metadata = await this.client.get.playlist(id) + return { + type: 'playlist', + metadata: parsePlaylist(metadata), + tracks: metadata.tracks?.map((e) => parseTrack(e)) ?? [] + } + } } } async search(query: string): Promise { diff --git a/src/streamers/spotify/parse.ts b/src/streamers/spotify/parse.ts index f1ab7b0..421ce40 100644 --- a/src/streamers/spotify/parse.ts +++ b/src/streamers/spotify/parse.ts @@ -4,9 +4,10 @@ import type { SpotifyThumbnail, SpotifyTrack, SpotifyEpisode, - SpotifyPodcast + SpotifyPodcast, + SpotifyPlaylist } from 'librespot/types' -import { Album, Artist, Episode, Podcast, Track } from '../../types.js' +import { Album, Artist, Episode, Playlist, Podcast, Track } from '../../types.js' function parseThumbnails(raw: SpotifyThumbnail[]) { return raw @@ -92,3 +93,14 @@ export function parsePodcast(raw: SpotifyPodcast) { return podcast } + +export function parsePlaylist(raw: SpotifyPlaylist) { + const playlist: Playlist = { + id: raw.id, + title: raw.name, + url: raw.externalUrl, + trackCount: raw.totalTracks + } + + return playlist +} diff --git a/src/streamers/tidal/main.ts b/src/streamers/tidal/main.ts index c51db85..9ee4b57 100644 --- a/src/streamers/tidal/main.ts +++ b/src/streamers/tidal/main.ts @@ -16,11 +16,13 @@ import { Contributor, RawAlbum, RawArtist, + RawPlaylist, RawTrack, addCredits, parseAlbum, parseArtist, parseMpd, + parsePlaylist, parseTrack } from './parse.js' import Stream, { Readable } from 'stream' @@ -327,6 +329,20 @@ export default class Tidal implements Streamer { ) return contributorResponse.items.map((item) => parseTrack(addCredits(item.item, item.credits))) } + async #getPlaylist(playlistId: number | string) { + const playlistResponse = await this.#get(`playlists/${playlistId}`) + return parsePlaylist(playlistResponse) + } + async #getPlaylistTracks(playlistId: number | string) { + const tracksResponse = <{ items: RawTrack[] }>await this.#get( + `playlists/${playlistId}/tracks`, + { + offset: 0, + limit: 10000 + } + ) + return tracksResponse.items.map((item) => parseTrack(item)) + } async #getArtist(artistId: number | string) { const [artistResponse, albumsResponse, tracksResponse] = await Promise.all([ >this.#get(`artists/${artistId}`), @@ -479,13 +495,18 @@ export default class Tidal implements Streamer { return ['-hide_banner', '-loglevel', 'error', '-i', '-', '-c:a', 'copy', '-f', 'flac', '-'] } } - #getUrlParts(url: string): ['artist' | 'album' | 'track', string] { + #getUrlParts(url: string): ['artist' | 'album' | 'track' | 'playlist', string] { const urlParts = url .match(/^https?:\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?(.*?)\/(.*?)\/?$/) ?.slice(1, 3) if (!urlParts) throw new Error('URL not supported') urlParts[1] = urlParts[1].replace(/\?.*?$/, '') - if (urlParts[0] != 'artist' && urlParts[0] != 'album' && urlParts[0] != 'track') { + if ( + urlParts[0] != 'artist' && + urlParts[0] != 'album' && + urlParts[0] != 'track' && + urlParts[0] != 'playlist' + ) { throw new Error('URL unrecognised') } return [urlParts[0], urlParts[1]] @@ -517,6 +538,12 @@ export default class Tidal implements Streamer { type, metadata: await this.#getArtist(id) } + case 'playlist': + return { + type, + tracks: await this.#getPlaylistTracks(id), + metadata: await this.#getPlaylist(id) + } } } async getAccountInfo(): Promise { diff --git a/src/streamers/tidal/parse.ts b/src/streamers/tidal/parse.ts index 48afaa9..73b40d9 100644 --- a/src/streamers/tidal/parse.ts +++ b/src/streamers/tidal/parse.ts @@ -1,4 +1,4 @@ -import { Album, Artist, Track } from '../../types.js' +import { Album, Artist, Playlist, Track } from '../../types.js' import { DOMParser } from 'xmldom-qsa' export interface RawArtist { @@ -78,6 +78,49 @@ export function parseAlbum(raw: RawAlbum): Album { return album } +export interface RawPlaylist { + uuid: string + title: string + url: string + numberOfTracks: number + tracks: Track[] + cover: string +} + +export function parsePlaylist(raw: RawPlaylist): Playlist { + let coverPath + + if (raw.cover) coverPath = raw.cover.replace(/-/gm, '/') + else coverPath = null + + const playlist: Playlist = { + id: raw.uuid, + title: raw.title, + url: raw.url, + trackCount: raw.numberOfTracks + } + + playlist.coverArtwork = [ + { + url: `https://resources.tidal.com/images/${coverPath}/160x160.jpg`, + width: 160, + height: 160 + }, + { + url: `https://resources.tidal.com/images/${coverPath}/320x320.jpg`, + width: 320, + height: 320 + }, + { + url: `https://resources.tidal.com/images/${coverPath}/1280x1280.jpg`, + width: 1280, + height: 1280 + } + ] + + return playlist +} + export interface RawTrack { url: string id: number diff --git a/src/types.ts b/src/types.ts index 7e15822..b694ee4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ type Id = string | number -export type ItemType = 'artist' | 'album' | 'track' | 'episode' | 'podcast' +export type ItemType = 'artist' | 'album' | 'track' | 'episode' | 'podcast' | 'playlist' export type Region = string export interface CoverArtwork { @@ -30,6 +30,14 @@ export interface Artist { tracks?: Track[] } +export interface Playlist { + id: Id + title: string + url: string + coverArtwork?: CoverArtwork[] + trackCount?: number +} + export interface Album { title: string id: Id @@ -118,6 +126,7 @@ export type GetByUrlResponse = | AlbumGetByUrlResponse | EpisodeGetByUrlResponse | PodcastGetByUrlResponse + | PlaylistGetByUrlResponse export interface TrackGetByUrlResponse { type: 'track' @@ -138,6 +147,11 @@ export interface AlbumGetByUrlResponse { tracks: Track[] metadata: Album } +export interface PlaylistGetByUrlResponse { + type: 'playlist' + tracks: Track[] + metadata: Playlist +} export interface PodcastGetByUrlResponse { type: 'podcast' episodes: Episode[]