import type { PlaybackInfo } from './container-classes'; type Params = Record; class ResponseError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } export interface TypedResponse extends Response { json(): Promise; } export class TidalResponse extends Response implements TypedResponse { declare json: () => Promise; constructor(response: Response); constructor(body: BodyInit, init?: ResponseInit); constructor(body: BodyInit | Response, init?: ResponseInit) { if (body instanceof Response) { super(body.body, { headers: body.headers, status: body.status, statusText: body.statusText, }); } else { super(body, init); } } } export interface VersionedResponse { version: string; } export interface TidalArtistRef { id: number; name: string; handle: string | null; type: string; picture: string | null; } export interface TidalArtistRole { categoryId: number; category: string; } export interface TidalArtistProfile { id: number; name: string; artistTypes: string[]; url: string; picture: string | null; selectedAlbumCoverFallback: string | null; popularity: number; artistRoles: TidalArtistRole[]; mixes: Record; handle: string | null; userId: number | null; spotlighted: boolean; } export interface TidalMediaMetadata { tags: string[]; } export interface TidalTrackAlbumRef { id: number; title: string; cover: string; vibrantColor: string; videoCover: string | null; } export interface TidalTrack { id: number; title: string; duration: number; replayGain: number; peak: number; allowStreaming: boolean; streamReady: boolean; payToStream: boolean; adSupportedStreamReady: boolean; djReady: boolean; stemReady: boolean; streamStartDate: string; premiumStreamingOnly: boolean; trackNumber: number; volumeNumber: number; version: string | null; popularity: number; copyright: string; bpm: number | null; key: string | null; keyScale: string | null; url: string; isrc: string; editable: boolean; explicit: boolean; audioQuality: string; audioModes: string[]; mediaMetadata: TidalMediaMetadata; upload: boolean; accessType: string; spotlighted: boolean; artist: TidalArtistRef; artists: TidalArtistRef[]; album: TidalTrackAlbumRef; mixes: Record; } export interface TidalPlaylistTrack extends TidalTrack { description: string | null; dateAdded: string; index: number; itemUuid: string; } export interface TidalAlbum { id: number; title: string; duration: number; streamReady: boolean; payToStream: boolean; adSupportedStreamReady: boolean; djReady: boolean; stemReady: boolean; streamStartDate: string; allowStreaming: boolean; premiumStreamingOnly: boolean; numberOfTracks: number; numberOfVideos: number; numberOfVolumes: number; releaseDate: string; copyright: string; type: string; version: string | null; url: string; cover: string; vibrantColor: string; videoCover: string | null; explicit: boolean; upc: string; popularity: number; audioQuality: string; audioModes: string[]; mediaMetadata: TidalMediaMetadata; upload: boolean; artist?: TidalArtistRef; artists: TidalArtistRef[]; } export interface TidalVideoItem { id: number; title: string; duration: number; version: string | null; url: string; artists: TidalArtistRef[]; album: TidalTrackAlbumRef | null; explicit: boolean; volumeNumber: number; trackNumber: number; popularity: number; doublePopularity?: number; allowStreaming: boolean; streamReady: boolean; streamStartDate: string; adSupportedStreamReady: boolean; djReady: boolean; stemReady: boolean; imageId: string; imagePath?: string | null; vibrantColor: string; releaseDate: string; type: string; adsUrl: string | null; adsPrePaywallOnly: boolean; quality?: string; } export interface TidalVideoPageModule { id: string; type: string; width: number; scroll: string; title: string; description: string; showMore: string | null; pagedList: { dataApiPath: string; limit: number; offset: number; totalNumberOfItems: number; items: TidalVideoItem[]; }; supportsPaging: boolean; showTableHeaders: boolean; listFormat: string; layout: string | null; quickPlay: boolean; preTitle: string | null; } export interface TidalSimilarAlbum { id: number; title: string; barcodeId: string; numberOfVolumes: number; numberOfItems: number; duration: string; explicit: boolean; releaseDate: string; copyright: { text: string }; popularity: number; accessType: string; availability: string[]; mediaTags: string[]; externalLinks: Array<{ href: string; meta: { type: string } }>; type: string; albumType: string; createdAt?: string; cover: string; artists: Array<{ id: number; name: string }>; url: string; } export interface RootResponse extends VersionedResponse { Repo: string; } export interface InfoResponse extends VersionedResponse { data: TidalTrack; } export interface TrackResponse extends VersionedResponse { data: PlaybackInfo; } export interface RecommendationsResponse extends VersionedResponse { data: unknown; } export interface SimilarArtist { id: number; name: string; picture: string | null; url: string; relationType: string; popularity: number; externalLinks: Array<{ href: string; meta: { type: string } }>; spotlighted: boolean; contributionsEnabled: boolean; } export interface SimilarArtistsResponse extends VersionedResponse { artists: SimilarArtist[]; } export interface SimilarAlbumsResponse extends VersionedResponse { albums: TidalSimilarAlbum[]; } export interface ArtistCover { id: number; name: string; '750': string; } export interface ArtistByIdResponse extends VersionedResponse { artist: TidalArtistProfile; cover: ArtistCover | null; } export interface ArtistDiscographyResponse extends VersionedResponse { albums: { items: TidalAlbum[] }; tracks?: TidalTrack[]; } export type ArtistResponse = ArtistByIdResponse | ArtistDiscographyResponse; export interface ArtistBiography { source: string; lastUpdated: string; text: string; summary: string; } export interface ArtistBioResponse extends VersionedResponse { data: ArtistBiography; } export interface CoverEntry { id: number; name: string; '1280': string; '640': string; '80': string; } export interface CoverResponse extends VersionedResponse { covers: CoverEntry[]; } export interface TidalSearchBucket { limit: number; offset: number; totalNumberOfItems: number; items: T[]; } export interface SearchResponse extends VersionedResponse { data: { artists?: TidalSearchBucket; albums?: TidalSearchBucket; tracks?: TidalSearchBucket; videos?: TidalSearchBucket; playlists?: TidalSearchBucket; genres?: TidalSearchBucket; topHit?: { value: TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem; type: string }; topHits?: Array; }; } export interface TidalAlbumWithTracks extends TidalAlbum { items: Array<{ item: TidalTrack; type: string }>; } export interface AlbumResponse extends VersionedResponse { data: TidalAlbumWithTracks; } export interface TidalPromotedArtist { id: number; name: string; handle: string | null; type: string; picture: string | null; } export interface TidalPlaylist { uuid: string; title: string; numberOfTracks: number; numberOfVideos: number; creator: { id: number }; description: string; duration: number; lastUpdated: string; created: string; type: string; publicPlaylist: boolean; url: string; image: string; popularity: number; squareImage?: string; customImageUrl: string | null; promotedArtists: TidalPromotedArtist[]; lastItemAddedAt: string; } export interface PlaylistItem { item: TidalPlaylistTrack; type: string; cut: string | null; } export interface PlaylistResponse extends VersionedResponse { playlist: TidalPlaylist; items: PlaylistItem[]; } export interface Mix { id: string; title: string; subTitle?: string; } export interface MixResponse extends VersionedResponse { mix: unknown; items: TidalTrack[]; } export interface Lyrics { trackId: number; lyricsProvider: string; providerCommontrackId: string; providerLyricsId: string; lyrics: string; subtitles: string; isRightToLeft: boolean; } export interface LyricsResponse extends VersionedResponse { lyrics: unknown; } export interface VideoPlaybackInfo { videoId: number; streamType: string; assetPresentation: string; videoQuality: string; manifestMimeType: string; manifestHash: string; manifest: string; } export interface VideoResponse extends VersionedResponse { video: VideoPlaybackInfo; } export interface TopVideosResponse extends VersionedResponse { videos: TidalVideoItem[]; total: number; } export interface TidalAudioNormData { replayGain: number; peakAmplitude: number; } export interface DrmData { drmSystem: string; licenseUrl: string; certificateUrl: string; initData: string | null; } export interface TrackManifestAttributes { trackPresentation: string; previewReason?: string; uri: string; hash: string; formats: string[]; albumAudioNormalizationData: TidalAudioNormData; trackAudioNormalizationData: TidalAudioNormData; drmData?: DrmData; } export interface TrackManifestResource { id: string; type: string; attributes: TrackManifestAttributes; } export interface TrackManifestApiResponse { data: TrackManifestResource; links: { self: string }; } export interface TrackManifestResponse extends VersionedResponse { data: TrackManifestApiResponse; } export interface TidalGenre { id: string; name: string; } type JsonApiRef = { id: string; type: string }; interface JsonApiIncludeAttributes { name?: string; popularity?: number; externalLinks?: Array<{ href: string; meta: { type: string } }>; spotlighted?: boolean; contributionsEnabled?: boolean; selectedAlbumCoverFallback?: string | null; files?: Array<{ href: string }>; title?: string; barcodeId?: string; numberOfVolumes?: number; numberOfItems?: number; duration?: string; explicit?: boolean; releaseDate?: string; copyright?: { text: string }; accessType?: string; availability?: string[]; mediaTags?: string[]; albumType?: string; createdAt?: string; type?: string; text?: string; source?: string; } interface JsonApiInclude { id: string; type: string; attributes: JsonApiIncludeAttributes; relationships?: Record; } interface JsonApiListResponse { data?: JsonApiRef[]; included?: JsonApiInclude[]; } interface TidalListResponse { items?: T[]; totalNumberOfItems?: number; } interface TidalPageModule { type: string; mix?: Mix; item?: TidalVideoItem; pagedList?: { items: Array<{ item?: TidalTrack | TidalVideoItem }> }; } interface TidalPageRow { modules?: TidalPageModule[]; } interface TidalPagesApiResponse { rows?: TidalPageRow[]; } function isTidalTrack(v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem }): v is TidalTrack { return 'trackNumber' in v; } function isTidalVideoItem( v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem } ): v is TidalVideoItem { return 'imageId' in v; } class HiFiClient { static readonly API_VERSION = '2.7'; static readonly DEFAULT_PUBLIC_TOKEN = '49YxDN9a2aFV6RTG'; static readonly TIDAL_BASE_URL = 'https://tidal.com/v1'; static #instance: HiFiClient | null = null; static get instance() { if (!HiFiClient.#instance) { throw new Error('HiFiClient is not initialized. Call HiFiClient.initialize(options) first.'); } return HiFiClient.#instance; } readonly #publicToken: string; readonly #countryCode: string; readonly #locale: string; readonly #deviceType: string; readonly #baseUrl: string; #useStorage(storage: Pick) { this.#publicToken && storage.setItem('tidal_web_token', this.#publicToken); } static #jsonResponse(data: T): TidalResponse { return new TidalResponse(JSON.stringify(data), { headers: { 'Content-Type': 'application/json', }, }); } static #buildUrl(base: string, params?: Params | URLSearchParams) { if (!params) return base; if (params instanceof URLSearchParams) { const u = new URL(base); u.search = params.toString(); return u.toString(); } const u = new URL(base); Object.entries(params) .filter(([, v]) => v !== undefined && v !== null && v !== '') .forEach(([k, v]) => u.searchParams.set(k, String(v))); return u.toString(); } async #fetchJson( url: string, params?: Params | URLSearchParams, signal: AbortSignal = new AbortController().signal ): Promise { const final = HiFiClient.#buildUrl(url, params); const headers: Record = { 'x-tidal-token': this.#publicToken, 'Accept': 'application/json', }; const res = await fetch(final, { headers, signal }); if (!res.ok) { throw new ResponseError(res.status, res.statusText); } return res.json() as Promise; } constructor(options: HiFiClient.ConstructorOptions = {}) { const { publicToken = HiFiClient.DEFAULT_PUBLIC_TOKEN, locale = 'en_US', countryCode = 'US', deviceType = 'BROWSER', baseUrl = '', storage, } = options; this.#publicToken = publicToken; this.#locale = locale; this.#countryCode = countryCode; this.#deviceType = deviceType; this.#baseUrl = baseUrl || ''; if (storage) { for (const store of !Array.isArray(storage) ? [storage] : storage) { this.#useStorage(store); } } } static async initialize(options: HiFiClient.ConstructorOptions & { signal?: AbortSignal } = {}) { if (HiFiClient.#instance) { throw new Error('HiFiClient is already initialized'); } HiFiClient.#instance = new HiFiClient(options); return HiFiClient.#instance; } static #extractUuidFromTidalUrl(href?: string | null) { if (!href) return null; const parts = href.split('/'); return parts.length >= 9 ? parts.slice(4, 9).join('-') : null; } async getInfo(id: number, signal?: AbortSignal): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`; const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } async getTrack( id: number, quality = 'HI_RES_LOSSLESS', _immersiveAudio: boolean = false, signal?: AbortSignal ): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/playbackinfo`; const params = { audioquality: quality, playbackmode: 'STREAM', assetpresentation: 'FULL', countryCode: this.#countryCode, }; const data = await this.#fetchJson(url, params, signal); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } async getRecommendations(id: number, signal?: AbortSignal): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/recommendations`; const data = await this.#fetchJson<{ items: TidalTrack[]; totalNumberOfItems: number }>( url, { limit: '20', countryCode: this.#countryCode }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } async getSimilarArtists( id: number, _cursor?: string | number | null, signal?: AbortSignal ): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/similar`; const data = await this.#fetchJson<{ items: SimilarArtist[] }>( url, { countryCode: this.#countryCode }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artists: data.items || [], }); } async getSimilarAlbums( id: number, _cursor?: string | number | null, signal?: AbortSignal ): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/similar`; const data = await this.#fetchJson<{ items: TidalSimilarAlbum[] }>( url, { countryCode: this.#countryCode }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: data.items || [], }); } async getArtist( id?: number | null, f?: number | null, skip_tracks = false, signal?: AbortSignal, options?: { offset?: number; limit?: number } ): Promise> { if (!id && !f) throw new ResponseError(400, 'Provide id or f query param'); if (id) { const artist_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}`; const artist_data = await this.#fetchJson( artist_url, { countryCode: this.#countryCode }, signal ); let cover: ArtistCover | null = null; if (artist_data.picture) { const slug = artist_data.picture.replace(/-/g, '/'); cover = { id: artist_data.id, name: artist_data.name, '750': `https://resources.tidal.com/images/${slug}/750x750.jpg`, }; } const albums_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/albums`; const albums_data = await this.#fetchJson>( albums_url, { countryCode: this.#countryCode, limit: 50 }, signal ); let top_tracks: TidalTrack[] = []; if (skip_tracks) { const toptracks_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${id}/toptracks`; const params: Params = { countryCode: this.#countryCode, limit: options?.limit || 15 }; if (options?.offset !== undefined) params.offset = options.offset; const toptracks_data = await this.#fetchJson>( toptracks_url, params, signal ); top_tracks = toptracks_data.items || []; } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover, albums: { items: albums_data.items || [] }, tracks: top_tracks, }); } const albums_url = `${HiFiClient.TIDAL_BASE_URL}/artists/${f}/albums`; const common_params: Params = { countryCode: this.#countryCode, limit: 50 }; const tasks: Array | TidalListResponse>> = [ this.#fetchJson>(albums_url, common_params, signal), this.#fetchJson>( albums_url, { ...common_params, filter: 'EPSANDSINGLES' }, signal ), ]; if (skip_tracks) { const offset = options?.offset; const limit = options?.limit; const toptracks_params: Params = { countryCode: this.#countryCode, limit: limit || 15 }; if (offset !== undefined) { toptracks_params.offset = offset; } tasks.push( this.#fetchJson>( `${HiFiClient.TIDAL_BASE_URL}/artists/${f}/toptracks`, toptracks_params, signal ) ); } const results = await Promise.all(tasks.map((p) => p.catch((e: Error) => e))); const unique_releases: TidalAlbum[] = []; const seen_ids = new Set(); for (const res of results.slice(0, 2)) { if (res && !(res instanceof Error)) { const data = res as TidalListResponse; const items = data?.items ?? []; for (const item of items) { if (item && item.id && !seen_ids.has(item.id)) { unique_releases.push(item); seen_ids.add(item.id); } } } } const page_data = { items: unique_releases }; if (skip_tracks) { let top_tracks: TidalTrack[] = []; if (results.length > 2) { const res = results[2]; if (res && !(res instanceof Error)) { const data = res as TidalListResponse; top_tracks = data?.items ?? []; } } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: top_tracks }); } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] }); } async getArtistBiography(artistId: number, signal?: AbortSignal): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${artistId}/bio`; const data = await this.#fetchJson( url, { countryCode: this.#countryCode }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } #buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null): CoverEntry { const slug = cover_slug.replace(/-/g, '/'); return { id: track_id ?? 0, name: name ?? '', '1280': `https://resources.tidal.com/images/${slug}/1280x1280.jpg`, '640': `https://resources.tidal.com/images/${slug}/640x640.jpg`, '80': `https://resources.tidal.com/images/${slug}/80x80.jpg`, }; } async getCover(id?: number | null, q?: string | null, signal?: AbortSignal): Promise> { if (!id && !q) throw new ResponseError(400, 'Provide id or q query param'); if (id) { const track_data = await this.#fetchJson( `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`, { countryCode: this.#countryCode }, signal ); const album = track_data.album ?? ({} as TidalTrackAlbumRef); const cover_slug = album.cover; if (!cover_slug) throw new ResponseError(404, 'Cover not found'); const entry = this.#buildCoverEntry(cover_slug, album.title || track_data.title, album.id || id); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers: [entry] }); } const search_data = await this.#fetchJson<{ items: TidalTrack[] }>( `${HiFiClient.TIDAL_BASE_URL}/search/tracks`, { countryCode: this.#countryCode, query: q, limit: 10 }, signal ); const items = Array.isArray(search_data?.items) ? search_data.items.slice(0, 10) : []; if (!items.length) throw new ResponseError(404, 'Cover not found'); const covers: CoverEntry[] = []; for (const track of items) { const album = track.album ?? ({} as TidalTrackAlbumRef); const cover_slug = album.cover; if (!cover_slug) continue; covers.push(this.#buildCoverEntry(cover_slug, track.title, track.id)); } if (!covers.length) throw new ResponseError(404, 'Cover not found'); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, covers }); } async search( options: { q?: string; s?: string; a?: string; al?: string; v?: string; p?: string; i?: string; offset?: number; limit?: number; }, signal?: AbortSignal ): Promise> { const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options; if (i) { try { const res = await this.#fetchJson( `${HiFiClient.TIDAL_BASE_URL}/tracks`, { 'filter[isrc]': i, limit, offset, countryCode: this.#countryCode, }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res }); } catch (err: unknown) { if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err; } } const searchMap: Array<[string | undefined, string]> = [ [q, `${HiFiClient.TIDAL_BASE_URL}/search`], [s, `${HiFiClient.TIDAL_BASE_URL}/search/tracks`], [a, `${HiFiClient.TIDAL_BASE_URL}/search/artists`], [al, `${HiFiClient.TIDAL_BASE_URL}/search/albums`], [v, `${HiFiClient.TIDAL_BASE_URL}/search/videos`], [p, `${HiFiClient.TIDAL_BASE_URL}/search/playlists`], ]; for (const [val, url] of searchMap) { if (val) { const params: Params = { query: val, limit, offset, countryCode: this.#countryCode, }; const data = await this.#fetchJson(url, params, signal); const normalized: SearchResponse['data'] = {}; if (data.items) { const bucketKey = url.includes('/tracks') ? 'tracks' : url.includes('/artists') ? 'artists' : url.includes('/albums') ? 'albums' : url.includes('/videos') ? 'videos' : url.includes('/playlists') ? 'playlists' : null; if (bucketKey) { normalized[bucketKey] = { items: data.items, totalNumberOfItems: data.totalNumberOfItems || data.items.length, limit: data.limit || limit, offset: data.offset || offset, }; } else { normalized.topHit = { value: data.items[0], type: data.items[0]?.type || '' }; } } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: normalized }); } } throw new Error('Provide one of q, s, a, al, v, p, or i'); } async getAlbum(id: number, limit = 100, offset = 0, signal?: AbortSignal): Promise> { const albumUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}`; const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/items`; const albumRaw = await this.#fetchJson(albumUrl, { countryCode: this.#countryCode }, signal); const allItems: Array<{ item: TidalTrack; type: string }> = []; let remaining = limit; let currentOffset = offset; const maxChunk = 100; while (remaining > 0) { const chunk = Math.min(remaining, maxChunk); const page = await this.#fetchJson<{ items?: Array<{ item: TidalTrack; type: string }> }>( itemsUrl, { countryCode: this.#countryCode, limit: chunk, offset: currentOffset }, signal ); const pageItems = page?.items ?? []; if (Array.isArray(pageItems)) allItems.push(...pageItems); currentOffset += chunk; remaining -= chunk; } const albumData: TidalAlbumWithTracks = { ...albumRaw, items: allItems }; return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: albumData }); } async getMix(id: string, signal?: AbortSignal): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mix`; const data = await this.#fetchJson( url, { mixId: id, countryCode: this.#countryCode, deviceType: this.#deviceType }, signal ); let header: unknown = {}; let items: TidalTrack[] = []; const rows = data.rows ?? []; for (const row of rows) { for (const module of row.modules ?? []) { if (module.type === 'MIX_HEADER') header = module.mix ?? {}; if (module.type === 'TRACK_LIST') items = ((module.pagedList || {}).items as TidalTrack[]) ?? []; } } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, mix: header, items, }); } async getPlaylist( id: string, limit = 100, offset = 0, signal?: AbortSignal ): Promise> { const playlistUrl = `${HiFiClient.TIDAL_BASE_URL}/playlists/${id}`; const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/playlists/${id}/items`; const [playlistData, itemsData] = await Promise.all([ this.#fetchJson(playlistUrl, { countryCode: this.#countryCode }, signal), this.#fetchJson<{ items: PlaylistItem[] }>( itemsUrl, { countryCode: this.#countryCode, limit, offset }, signal ), ]); const items = itemsData?.items ?? []; return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, playlist: playlistData, items }); } async getLyrics(id: number, signal?: AbortSignal): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/lyrics`; const data = await this.#fetchJson( url, { countryCode: this.#countryCode, locale: this.#locale, deviceType: this.#deviceType }, signal ); if (!data) { const err = Object.assign(new Error('Lyrics not found'), { status: 404 }); throw err; } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, lyrics: data }); } async getVideo( id: number, quality = 'HIGH', mode = 'STREAM', presentation = 'FULL', signal?: AbortSignal ): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/videos/${id}/playbackinfo`; const data = await this.#fetchJson( url, { videoquality: quality, playbackmode: mode, assetpresentation: presentation }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, video: data }); } async getTopVideos( { countryCode = 'US', locale = 'en_US', deviceType = 'BROWSER', limit = 25, offset = 0 } = {}, signal?: AbortSignal ): Promise> { const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mymusic_recommended_videos`; const data = await this.#fetchJson(url, { countryCode, locale, deviceType }, signal); const rows = data.rows ?? []; const videos: TidalVideoItem[] = []; for (const row of rows) { for (const module of row.modules ?? []) { const mt = module.type; if (['VIDEO_PLAYLIST', 'VIDEO_ROW', 'PAGED_LIST'].includes(mt)) { const items = module.pagedList?.items ?? []; for (const item of items) { const v = item.item ?? item; videos.push(v as TidalVideoItem); } } else if (mt === 'VIDEO' || (mt && mt.toLowerCase().includes('video'))) { videos.push(module.item as TidalVideoItem); } } } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, videos: videos.slice(offset, offset + limit), total: videos.length, }); } async query(pathOrUrl: string, signal?: AbortSignal): Promise { try { const u = new URL(pathOrUrl, 'http://localhost'); const pathname = u.pathname.replace(/\/+$/, '') || '/'; const qp: Record = {}; u.searchParams.forEach((v, k) => (qp[k] = v)); switch (pathname) { case '/': return new TidalResponse( HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, Repo: 'https://github.com/binimum/hifi-api', }) ); case '/info': return new TidalResponse(await this.getInfo(Number(qp.id))); case '/track': return new TidalResponse(await this.getTrack(Number(qp.id), qp.quality || undefined)); case '/recommendations': return new TidalResponse(await this.getRecommendations(Number(qp.id))); case '/artist/similar': return new TidalResponse( await this.getSimilarArtists(Number(qp.id), qp.cursor ?? undefined, signal) ); case '/album/similar': return new TidalResponse( await this.getSimilarAlbums(Number(qp.id), qp.cursor ?? undefined, signal) ); case '/artist/bio': return new TidalResponse(await this.getArtistBiography(Number(qp.id), signal)); case '/artist': return new TidalResponse( await this.getArtist( qp.id ? Number(qp.id) : undefined, qp.f ? Number(qp.f) : undefined, qp.skip_tracks === 'true' || qp.skip_tracks === '1' || qp.skip_tracks === 'True', signal, { offset: qp.offset !== undefined ? Number(qp.offset) : undefined, limit: qp.limit !== undefined ? Number(qp.limit) : undefined, } ) ); case '/cover': return new TidalResponse( await this.getCover(qp.id ? Number(qp.id) : undefined, qp.q ?? undefined, signal) ); case '/search': return new TidalResponse( await this.search({ q: qp.q, s: qp.s, a: qp.a, al: qp.al, v: qp.v, p: qp.p, i: qp.i, offset: qp.offset ? Number(qp.offset) : undefined, limit: qp.limit ? Number(qp.limit) : undefined, }) ); case '/album': return new TidalResponse( await this.getAlbum( Number(qp.id), qp.limit ? Number(qp.limit) : undefined, qp.offset ? Number(qp.offset) : undefined ) ); case '/playlist': return new TidalResponse( await this.getPlaylist( qp.id || '', qp.limit ? Number(qp.limit) : undefined, qp.offset ? Number(qp.offset) : undefined ) ); case '/mix': return new TidalResponse(await this.getMix(qp.id || '')); case '/lyrics': return new TidalResponse(await this.getLyrics(Number(qp.id))); case '/video': return new TidalResponse( await this.getVideo( Number(qp.id), qp.quality || undefined, qp.mode || undefined, qp.presentation || undefined ) ); case '/topvideos': return new TidalResponse( await this.getTopVideos({ countryCode: qp.countryCode || undefined, locale: qp.locale || undefined, deviceType: qp.deviceType || undefined, limit: qp.limit ? Number(qp.limit) : undefined, offset: qp.offset ? Number(qp.offset) : undefined, }) ); default: throw new Error(`Unknown route: ${pathname}`); } } catch (err) { const message = (err as { message?: string }).message ?? String(err); console.error(message, err); throw err; } } } namespace HiFiClient { export interface ConstructorOptions { publicToken?: string; locale?: string; countryCode?: string; deviceType?: string; baseUrl?: string; storage?: Pick[] | Pick; } } export { HiFiClient };