From 8b2768c8d6e74d980a59498dc50e9c711d21680e Mon Sep 17 00:00:00 2001 From: hazycora Date: Fri, 2 Aug 2024 16:56:15 -0500 Subject: [PATCH] [tidal] add playlists support --- src/streamers/tidal/main.ts | 31 ++++++++++++++++++++++-- src/streamers/tidal/parse.ts | 46 +++++++++++++++++++++++++++++++++++- src/types.ts | 17 ++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/streamers/tidal/main.ts b/src/streamers/tidal/main.ts index 53d83a2..2e25808 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' @@ -306,6 +308,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}`), @@ -458,13 +474,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]] @@ -494,6 +515,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..fc94d26 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,50 @@ 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, + tracks: [] + } + + 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 8ea2c9e..8e3547c 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 { @@ -18,6 +18,15 @@ export interface Artist { tracks?: Track[] } +export interface Playlist { + id: Id + title: string + url: string + coverArtwork?: CoverArtwork[] + trackCount?: number + tracks: Track[] +} + export interface Album { title: string id: Id @@ -105,6 +114,7 @@ export type GetByUrlResponse = | AlbumGetByUrlResponse | EpisodeGetByUrlResponse | PodcastGetByUrlResponse + | PlaylistGetByUrlResponse export interface TrackGetByUrlResponse { type: 'track' @@ -125,6 +135,11 @@ export interface AlbumGetByUrlResponse { tracks: Track[] metadata: Album } +export interface PlaylistGetByUrlResponse { + type: 'playlist' + tracks: Track[] + metadata: Playlist +} export interface PodcastGetByUrlResponse { type: 'podcast' episodes: Episode[]