From 0ec81acf40fd5620d724852f2a98a44252550af1 Mon Sep 17 00:00:00 2001 From: aria Date: Thu, 5 Sep 2024 23:17:20 -0400 Subject: [PATCH] add playlists to qobuz and deezer --- src/streamers/deezer/constants.ts | 2 +- src/streamers/deezer/main.ts | 39 ++++++++++++++++++++++++++++-- src/streamers/deezer/parse.ts | 21 +++++++++++++++- src/streamers/qobuz/main.ts | 40 ++++++++++++++++++++++++++++--- src/streamers/qobuz/parse.ts | 36 +++++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 8 deletions(-) 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) + } +}