import { EventEmitter } from 'events'; 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; } } /** * A generic response interface that types the return value of `.json()` as `T`. * * Extends the standard `Response` interface with a typed `.json()` method so callers * can receive properly-typed data without manual casting. * * @typeParam T - The expected shape of the parsed JSON body. */ export interface TypedResponse extends Response { /** Returns a promise that resolves with the response body parsed as JSON, typed as `T`. */ json(): Promise; } /** * A typed extension of the standard `Response` class returned by all TIDAL API methods. * * The generic type parameter `T` controls the type returned by `.json()`, enabling * full type safety on API responses without manual casts. * * @typeParam T - The expected JSON body type. Defaults to `unknown`. */ export class TidalResponse extends Response implements TypedResponse { /** Returns a promise that resolves with the response body parsed as JSON, typed as `T`. */ 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); } } } // ─── Route response interfaces ─────────────────────────────────────────────── /** * Base interface shared by all versioned TIDAL API responses. * Every response envelope includes a `version` string matching {@link HiFiClient.API_VERSION}. */ export interface VersionedResponse { /** The API version string, e.g. `"2.7"`. */ version: string; } // ─── Shared sub-types (derived from live API samples) ──────────────────────── /** * Minimal artist reference as it appears inside track and album objects. * For the full artist profile returned by the `/artist` route, see {@link TidalArtistProfile}. */ export interface TidalArtistRef { /** Numeric TIDAL artist ID. */ id: number; /** Artist display name. */ name: string; /** TIDAL handle, or `null` if not set. */ handle: string | null; /** Artist role in this context, e.g. `"MAIN"`. */ type: string; /** Picture UUID, or `null` if no image is available. */ picture: string | null; } /** * A single artist-role entry as returned inside {@link TidalArtistProfile}. */ export interface TidalArtistRole { /** Internal category identifier (`-1` for the primary artist role). */ categoryId: number; /** Human-readable role label, e.g. `"Artist"`, `"Producer"`. */ category: string; } /** * Full artist profile as returned by the `/artist/?id=` route. * Contains fields not present on the minimal {@link TidalArtistRef} seen inside tracks/albums. */ export interface TidalArtistProfile { /** Numeric TIDAL artist ID. */ id: number; /** Artist display name. */ name: string; /** Roles this artist holds on TIDAL, e.g. `["ARTIST", "CONTRIBUTOR"]`. */ artistTypes: string[]; /** Canonical TIDAL artist URL. */ url: string; /** Picture UUID, or `null` if no image is available. */ picture: string | null; /** Fallback album cover UUID used when no artist picture exists, or `null`. */ selectedAlbumCoverFallback: string | null; /** Popularity score (0-100). */ popularity: number; /** List of credited roles for this artist. */ artistRoles: TidalArtistRole[]; /** Map of mix type → mix ID, e.g. `{ "ARTIST_MIX": "000ff..." }`. */ mixes: Record; /** TIDAL handle, or `null` if not set. */ handle: string | null; /** Associated TIDAL user ID, or `null`. */ userId: number | null; /** Whether the artist is currently spotlighted. */ spotlighted: boolean; } /** * Media metadata object attached to tracks and albums. */ export interface TidalMediaMetadata { /** Quality tags, e.g. `["LOSSLESS"]`, `["HIRES_LOSSLESS"]`. */ tags: string[]; } /** * Slim album reference embedded inside a track object. */ export interface TidalTrackAlbumRef { /** Numeric TIDAL album ID. */ id: number; /** Album title. */ title: string; /** Cover image UUID. */ cover: string; /** Vibrant accent colour hex string derived from the cover art. */ vibrantColor: string; /** Video cover UUID, or `null`. */ videoCover: string | null; } /** * Full track object as returned by the `/info` route and embedded in albums, playlists, and mixes. * * @remarks * Fields `bpm`, `key`, and `keyScale` are nullable - they are absent for some tracks. * `version` is present in the payload but may be `null`. */ export interface TidalTrack { /** Numeric TIDAL track ID. */ id: number; /** Track title. */ title: string; /** Duration in seconds. */ duration: number; /** Track replay-gain value in dB. */ replayGain: number; /** Track peak amplitude (0-1). */ peak: number; /** Whether the track is available for streaming. */ allowStreaming: boolean; /** Whether the stream is ready. */ streamReady: boolean; /** Whether the track requires payment to stream. */ payToStream: boolean; /** Whether the track is available for ad-supported streaming. */ adSupportedStreamReady: boolean; /** Whether the track is available for DJ use. */ djReady: boolean; /** Whether stem files are available. */ stemReady: boolean; /** ISO-8601 timestamp from which the stream became available. */ streamStartDate: string; /** Whether a premium subscription is required. */ premiumStreamingOnly: boolean; /** Track number within its volume. */ trackNumber: number; /** Disc/volume number. */ volumeNumber: number; /** Version suffix (e.g. `"Remastered"`), or `null`. */ version: string | null; /** Popularity score (0-100). */ popularity: number; /** Copyright notice. */ copyright: string; /** Beats per minute, or `null` if unavailable. */ bpm: number | null; /** Musical key (e.g. `"Bb"`), or `null` if unavailable. */ key: string | null; /** Key scale (`"MAJOR"` / `"MINOR"`), or `null` if unavailable. */ keyScale: string | null; /** Canonical TIDAL track URL. */ url: string; /** International Standard Recording Code. */ isrc: string; /** Whether the track metadata can be edited. */ editable: boolean; /** Whether the track contains explicit content. */ explicit: boolean; /** Highest available audio quality, e.g. `"LOSSLESS"`, `"HI_RES_LOSSLESS"`. */ audioQuality: string; /** Available audio modes, e.g. `["STEREO"]`. */ audioModes: string[]; /** Media metadata including quality tags. */ mediaMetadata: TidalMediaMetadata; /** Whether this is a user-uploaded track. */ upload: boolean; /** Access type, e.g. `"PUBLIC"`. */ accessType: string; /** Whether the track is currently spotlighted. */ spotlighted: boolean; /** Primary artist. */ artist: TidalArtistRef; /** All credited artists. */ artists: TidalArtistRef[]; /** Album this track belongs to. */ album: TidalTrackAlbumRef; /** Map of mix type → mix ID, e.g. `{ "TRACK_MIX": "001e91..." }`. */ mixes: Record; } /** * A track as it appears inside a playlist, extending {@link TidalTrack} with * playlist-specific fields added by the TIDAL API. */ export interface TidalPlaylistTrack extends TidalTrack { /** Track description text, or `null`. */ description: string | null; /** ISO-8601 timestamp when this track was added to the playlist. */ dateAdded: string; /** Position index within the playlist. */ index: number; /** Unique item UUID within the playlist. */ itemUuid: string; } /** * Full album object as returned by the `/artist/?f=` discography route and * embedded in search results. * * @remarks * `artist` is present in discography responses but absent in some search results; * it is therefore typed as optional. * `version` and `videoCover` are present in the payload but may be `null`. */ export interface TidalAlbum { /** Numeric TIDAL album ID. */ id: number; /** Album title. */ title: string; /** Total duration in seconds. */ duration: number; /** Whether the stream is ready. */ streamReady: boolean; /** Whether the album requires payment to stream. */ payToStream: boolean; /** Whether the album is available for ad-supported streaming. */ adSupportedStreamReady: boolean; /** Whether the album is available for DJ use. */ djReady: boolean; /** Whether stem files are available. */ stemReady: boolean; /** ISO-8601 timestamp from which the stream became available. */ streamStartDate: string; /** Whether streaming is allowed. */ allowStreaming: boolean; /** Whether a premium subscription is required. */ premiumStreamingOnly: boolean; /** Number of tracks on the album. */ numberOfTracks: number; /** Number of videos on the album. */ numberOfVideos: number; /** Number of discs/volumes. */ numberOfVolumes: number; /** Release date string, e.g. `"2025-02-14"`. */ releaseDate: string; /** Copyright notice. */ copyright: string; /** Release type, e.g. `"ALBUM"`, `"EP"`, `"SINGLE"`. */ type: string; /** Version suffix, or `null`. */ version: string | null; /** Canonical TIDAL album URL. */ url: string; /** Cover image UUID. */ cover: string; /** Vibrant accent colour hex string. */ vibrantColor: string; /** Video cover UUID, or `null`. */ videoCover: string | null; /** Whether the album contains explicit content. */ explicit: boolean; /** UPC barcode. */ upc: string; /** Popularity score (0-100). */ popularity: number; /** Highest available audio quality. */ audioQuality: string; /** Available audio modes. */ audioModes: string[]; /** Media metadata including quality tags. */ mediaMetadata: TidalMediaMetadata; /** Whether this is a user-uploaded album. */ upload: boolean; /** Primary artist (present in discography responses; absent in some search results). */ artist?: TidalArtistRef; /** All credited artists. */ artists: TidalArtistRef[]; } /** * A video item as returned inside search results and the topvideos page modules. */ export interface TidalVideoItem { /** Numeric TIDAL video ID. */ id: number; /** Video title. */ title: string; /** Duration in seconds. */ duration: number; /** Version suffix, or `null`. */ version: string | null; /** Canonical TIDAL video URL. */ url: string; /** All credited artists. */ artists: TidalArtistRef[]; /** Associated album, or `null`. */ album: TidalTrackAlbumRef | null; /** Whether the video contains explicit content. */ explicit: boolean; /** Disc/volume number. */ volumeNumber: number; /** Track number on the disc. */ trackNumber: number; /** Popularity score (0-100). */ popularity: number; /** Double-precision popularity score (present in topvideos). */ doublePopularity?: number; /** Whether streaming is allowed. */ allowStreaming: boolean; /** Whether the stream is ready. */ streamReady: boolean; /** ISO-8601 timestamp from which streaming became available. */ streamStartDate: string; /** Whether the video is available for ad-supported streaming. */ adSupportedStreamReady: boolean; /** Whether the video is available for DJ use. */ djReady: boolean; /** Whether stem files are available. */ stemReady: boolean; /** Thumbnail image UUID. */ imageId: string; /** Image path (present in some search results), or `null`. */ imagePath?: string | null; /** Vibrant accent colour hex string. */ vibrantColor: string; /** Release date string. */ releaseDate: string; /** Content type, e.g. `"Music Video"`. */ type: string; /** Ad tag URL, or `null`. */ adsUrl: string | null; /** Whether ads are pre-paywall only. */ adsPrePaywallOnly: boolean; /** Playback quality label (present in some search results), e.g. `"MP4_1080P"`. */ quality?: string; } /** * A page module object as returned inside {@link TopVideosResponse.videos}. * * @remarks * The `/topvideos` route processes a TIDAL pages API response. When the page * contains `VIDEO_LIST`-type modules (which do not match the `VIDEO_PLAYLIST` / * `VIDEO_ROW` / `PAGED_LIST` extraction path) the entire module object is pushed * into the output array. The actual video items are nested inside `pagedList.items`. */ export interface TidalVideoPageModule { /** Base-64 encoded module identifier. */ id: string; /** Module type, e.g. `"VIDEO_LIST"`. */ type: string; /** Column width percentage. */ width: number; /** Scroll direction, e.g. `"VERTICAL"`. */ scroll: string; /** Module title. */ title: string; /** Module description. */ description: string; /** "Show more" link data, or `null`. */ showMore: string | null; /** Paged list of video items. */ pagedList: { /** Internal API path for paged data. */ dataApiPath: string; /** Page size. */ limit: number; /** Current offset. */ offset: number; /** Total number of items available. */ totalNumberOfItems: number; /** Video items on this page. */ items: TidalVideoItem[]; }; /** Whether the module supports paging. */ supportsPaging: boolean; /** Whether to show table headers. */ showTableHeaders: boolean; /** List display format. */ listFormat: string; /** Layout hint, or `null`. */ layout: string | null; /** Whether quick-play is enabled. */ quickPlay: boolean; /** Pre-title label, or `null`. */ preTitle: string | null; } /** * A similar album entry as returned by the TIDAL OpenAPI `/album/similar` endpoint. * * @remarks * This shape differs substantially from a standard {@link TidalAlbum}: `duration` is an * ISO 8601 duration string (e.g. `"PT1H14M30S"`), `copyright` is an object, and several * fields (`barcodeId`, `mediaTags`, `availability`, `albumType`) have no equivalent in the * standard album object. */ export interface TidalSimilarAlbum { /** Numeric TIDAL album ID. */ id: number; /** Album title. */ title: string; /** UPC/EAN barcode identifier. */ barcodeId: string; /** Number of discs/volumes. */ numberOfVolumes: number; /** Total number of tracks (called `numberOfItems` in this endpoint). */ numberOfItems: number; /** ISO 8601 duration string, e.g. `"PT1H14M30S"`. */ duration: string; /** Whether the album contains explicit content. */ explicit: boolean; /** Release date string, e.g. `"2015-10-09"`. */ releaseDate: string; /** Copyright information. */ copyright: { text: string }; /** Popularity score (0-1 float). */ popularity: number; /** Access type, e.g. `"PUBLIC"`. */ accessType: string; /** Availability modes, e.g. `["STREAM", "DJ"]`. */ availability: string[]; /** Quality tags, e.g. `["LOSSLESS", "HIRES_LOSSLESS"]`. */ mediaTags: string[]; /** External link entries (e.g. TIDAL sharing URL). */ externalLinks: Array<{ href: string; meta: { type: string } }>; /** Release type, e.g. `"ALBUM"`. */ type: string; /** Album type classification, e.g. `"ALBUM"`. */ albumType: string; /** ISO-8601 creation timestamp (present for some albums). */ createdAt?: string; /** Cover image UUID. */ cover: string; /** Abbreviated artist list for this album. */ artists: Array<{ id: number; name: string }>; /** Canonical TIDAL album URL. */ url: string; } // ─── Response interfaces ────────────────────────────────────────────────────── /** * Response returned by the root `/` route. * Contains a link to the upstream HiFi API repository. */ export interface RootResponse extends VersionedResponse { /** URL of the upstream HiFi API repository. */ Repo: string; } /** * Response returned by the `/info` route. * Contains full TIDAL track metadata. */ export interface InfoResponse extends VersionedResponse { /** Full metadata for the requested track. */ data: TidalTrack; } /** * Response returned by the `/track` route. * Contains playback/stream information for a track. * * @remarks `data` is typed as {@link PlaybackInfo} from `container-classes`, whose * fields match the live API sample exactly. */ export interface TrackResponse extends VersionedResponse { /** Playback info including manifest, quality, and replay-gain data. */ data: PlaybackInfo; } /** * Response returned by the `/recommendations` route. * Contains a paginated list of recommended tracks for a given track ID. * * @remarks No live API sample is available for this route. */ export interface RecommendationsResponse extends VersionedResponse { /** Raw TIDAL v1 recommendations payload. */ data: unknown; } /** * A similar-artist entry as returned by the TIDAL OpenAPI `/artist/similar` endpoint. */ export interface SimilarArtist { /** Numeric TIDAL artist ID. */ id: number; /** Artist display name. */ name: string; /** Picture UUID, or `null` if no image is available. */ picture: string | null; /** Canonical TIDAL artist URL. */ url: string; /** Relation type, e.g. `"SIMILAR_ARTIST"`. */ relationType: string; /** Popularity score (0-1 float). */ popularity: number; /** External link entries (e.g. TIDAL sharing URL). */ externalLinks: Array<{ href: string; meta: { type: string } }>; /** Whether the artist is spotlighted. */ spotlighted: boolean; /** Whether artist contributions are enabled. */ contributionsEnabled: boolean; } /** * Response returned by the `/artist/similar` route. * Contains a list of artists similar to the requested artist. */ export interface SimilarArtistsResponse extends VersionedResponse { /** List of similar artists. */ artists: SimilarArtist[]; } /** * Response returned by the `/album/similar` route. * Contains a list of albums similar to the requested album. */ export interface SimilarAlbumsResponse extends VersionedResponse { /** List of similar albums. */ albums: TidalSimilarAlbum[]; } /** * Artist cover image URL at 750 px resolution. * Returned inside {@link ArtistByIdResponse}. */ export interface ArtistCover { /** The TIDAL artist ID. */ id: number; /** The artist display name. */ name: string; /** 750×750 JPEG cover URL. */ '750': string; } /** * Response returned by the `/artist` route when an `id` query parameter is supplied. * Contains the artist's full profile and optional cover image URL. */ export interface ArtistByIdResponse extends VersionedResponse { /** Full TIDAL artist profile data. */ artist: TidalArtistProfile; /** Cover image URL at 750 px, or `null` if no picture is available. */ cover: ArtistCover | null; } /** * Response returned by the `/artist` route when an `f` query parameter is supplied. * Contains the artist's discography and, when `skip_tracks` is false, their top tracks. */ export interface ArtistDiscographyResponse extends VersionedResponse { /** Paginated album list for the artist. */ albums: { items: TidalAlbum[] }; /** * Top tracks for the artist across all albums. * Absent when the request includes `skip_tracks=true`. */ tracks?: TidalTrack[]; } /** * Union of the two possible response shapes from the `/artist` route. * Use {@link ArtistByIdResponse} (has `artist`) when querying by `id`, * or {@link ArtistDiscographyResponse} (has `albums`) when querying by `f`. */ export type ArtistResponse = ArtistByIdResponse | ArtistDiscographyResponse; /** * Artist biography as returned by the TIDAL API. */ export interface ArtistBiography { /** Provider or publication source of the biography text, e.g. `"TiVo"`. */ source: string; /** ISO-8601 timestamp of the last biography update. */ lastUpdated: string; /** Full biography text. */ text: string; /** Short biography summary (may be an empty string). */ summary: string; } /** * Response returned by the `/artist/bio` route. * Contains the biography text for the requested artist. */ export interface ArtistBioResponse extends VersionedResponse { /** Biography data for the requested artist. */ data: ArtistBiography; } /** * A single cover-image entry with pre-built URLs at multiple resolutions. * Returned inside {@link CoverResponse}. */ export interface CoverEntry { /** The TIDAL track or album ID associated with this cover. */ id: number; /** The track or album title associated with this cover. */ name: string; /** 1280×1280 JPEG cover URL. */ '1280': string; /** 640×640 JPEG cover URL. */ '640': string; /** 80×80 JPEG cover URL. */ '80': string; } /** * Response returned by the `/cover` route. * Contains one or more cover-image entries matching the query. */ export interface CoverResponse extends VersionedResponse { /** Resolved cover image entries. */ covers: CoverEntry[]; } /** * A paginated result bucket as returned inside {@link SearchResponse.data}. */ export interface TidalSearchBucket { /** Maximum number of items per page. */ limit: number; /** Current page offset. */ offset: number; /** Total number of matching items. */ totalNumberOfItems: number; /** Items on this page. */ items: T[]; } /** * Response returned by the `/search` route. * * @remarks * Two distinct query formats exist: * - `?q=` (general search): returns `topHit` and combined buckets for artists, albums, tracks, videos, playlists. * - `?v=` (video-focused search): returns `topHits` (plural), `genres`, and the same buckets. */ export interface SearchResponse extends VersionedResponse { /** Combined search result buckets. */ data: { /** Matching artist results. */ artists?: TidalSearchBucket; /** Matching album results. */ albums?: TidalSearchBucket; /** Matching track results. */ tracks?: TidalSearchBucket; /** Matching video results. */ videos?: TidalSearchBucket; /** Matching playlist results. */ playlists?: TidalSearchBucket; /** Genre results (present for `?v=` video searches). */ genres?: TidalSearchBucket; /** Single top-hit result (present for `?q=` general searches). */ topHit?: { value: TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem; type: string }; /** Multiple top-hit results (present for `?v=` video searches). */ topHits?: Array; }; } /** * An album with its full track listing, as returned by the `/album` route. * * @remarks * Each element of `items` wraps the track in an envelope `{ item, type }`. */ export interface TidalAlbumWithTracks extends TidalAlbum { /** Ordered list of track envelopes. */ items: Array<{ item: TidalTrack; type: string }>; } /** * Response returned by the `/album` route. * Contains album metadata together with its full track listing. */ export interface AlbumResponse extends VersionedResponse { /** Album data including all tracks as `items`. */ data: TidalAlbumWithTracks; } /** * A promoted artist entry as it appears inside {@link TidalPlaylist}. */ export interface TidalPromotedArtist { /** Numeric TIDAL artist ID. */ id: number; /** Artist display name. */ name: string; /** TIDAL handle, or `null`. */ handle: string | null; /** Artist role, e.g. `"MAIN"`. */ type: string; /** Picture UUID, or `null`. */ picture: string | null; } /** * A TIDAL playlist as returned by the `/playlist` route. */ export interface TidalPlaylist { /** Unique playlist UUID. */ uuid: string; /** Playlist display title. */ title: string; /** Total number of tracks in the playlist. */ numberOfTracks: number; /** Total number of videos in the playlist. */ numberOfVideos: number; /** Playlist creator. */ creator: { id: number }; /** Playlist description text. */ description: string; /** Total playlist duration in seconds. */ duration: number; /** ISO-8601 timestamp of the last update. */ lastUpdated: string; /** ISO-8601 creation timestamp. */ created: string; /** Playlist type, e.g. `"EDITORIAL"`. */ type: string; /** Whether the playlist is publicly accessible. */ publicPlaylist: boolean; /** Canonical TIDAL URL for this playlist. */ url: string; /** Rectangular cover image UUID. */ image: string; /** Playlist popularity score. */ popularity: number; /** Square cover image UUID, or `undefined` if not set. */ squareImage?: string; /** Custom image URL override, or `null`. */ customImageUrl: string | null; /** Artists featured/promoted in the playlist header. */ promotedArtists: TidalPromotedArtist[]; /** ISO-8601 timestamp when the most recent item was added. */ lastItemAddedAt: string; } /** * A single item in a TIDAL playlist. */ export interface PlaylistItem { /** The track object, augmented with playlist-specific fields. */ item: TidalPlaylistTrack; /** Item type string, e.g. `"track"`. */ type: string; /** Cut data associated with the item, or `null`. */ cut: string | null; } /** * Response returned by the `/playlist` route. * Contains the playlist metadata and its item list. */ export interface PlaylistResponse extends VersionedResponse { /** Playlist metadata. */ playlist: TidalPlaylist; /** Ordered list of playlist items. */ items: PlaylistItem[]; } /** * A TIDAL mix header as parsed from a `/pages/mix` response. * * @remarks No live API sample is available for this route. */ export interface Mix { /** Mix identifier. */ id: string; /** Mix display title. */ title: string; /** Optional mix subtitle. */ subTitle?: string; } /** * Response returned by the `/mix` route. * Contains the mix header and its constituent tracks. * * @remarks No live API sample is available for this route; the shape is inferred * from the TIDAL pages API structure. */ export interface MixResponse extends VersionedResponse { /** Mix metadata. */ mix: unknown; /** Ordered list of tracks in this mix. */ items: TidalTrack[]; } /** * Lyrics data as returned by the TIDAL API. * * @remarks No live API sample is available for this route. */ export interface Lyrics { /** The TIDAL track ID these lyrics belong to. */ trackId: number; /** Name of the lyrics provider. */ lyricsProvider: string; /** Provider's common-track identifier. */ providerCommontrackId: string; /** Provider's lyrics-specific identifier. */ providerLyricsId: string; /** Full unsynced lyrics text. */ lyrics: string; /** Time-synced subtitle text (LRC format). */ subtitles: string; /** Whether the lyrics text reads right-to-left. */ isRightToLeft: boolean; } /** * Response returned by the `/lyrics` route. * Contains the lyrics data for the requested track. * * @remarks No live API sample is available for this route. */ export interface LyricsResponse extends VersionedResponse { /** Lyrics data for the requested track. */ lyrics: unknown; } /** * Video playback info as returned by the TIDAL API for the `/video` route. */ export interface VideoPlaybackInfo { /** The TIDAL video ID. */ videoId: number; /** Stream type, e.g. `"ON_DEMAND"`. */ streamType: string; /** Asset presentation type, e.g. `"FULL"`. */ assetPresentation: string; /** Requested video quality, e.g. `"HIGH"`. */ videoQuality: string; /** MIME type of the manifest, e.g. `"application/vnd.tidal.emu"`. */ manifestMimeType: string; /** Hash of the manifest content. */ manifestHash: string; /** Base-64 encoded manifest. */ manifest: string; } /** * Response returned by the `/video` route. * Contains playback information for the requested video. */ export interface VideoResponse extends VersionedResponse { /** Video playback info. */ video: VideoPlaybackInfo; } /** * Response returned by the `/topvideos` route. * * @remarks * The `videos` array contains the video items extracted from the page modules' * `pagedList.items` arrays. Individual video objects matching {@link TidalVideoItem} * are pushed into this array at runtime. */ export interface TopVideosResponse extends VersionedResponse { /** Video items extracted from the page modules. */ videos: TidalVideoItem[]; /** Total number of video items before pagination. */ total: number; } /** * Audio normalisation data embedded in a track manifest attributes object. */ export interface TidalAudioNormData { /** Replay gain value in dB. */ replayGain: number; /** Peak amplitude (0-1). */ peakAmplitude: number; } /** * DRM licence data embedded in a track manifest attributes object. */ export interface DrmData { /** DRM system identifier, e.g. `"WIDEVINE"`. */ drmSystem: string; /** Licence acquisition URL. */ licenseUrl: string; /** Certificate URL. */ certificateUrl: string; /** DRM initialisation data, or `null`. */ initData: string | null; } /** * Attributes of a single track-manifest resource from the TIDAL OpenAPI. * * @remarks * The `uri` field contains the signed manifest URL (rather than an inline Base-64 * `manifest` string). `previewReason` is only present for preview-only tracks. */ export interface TrackManifestAttributes { /** Presentation tier, e.g. `"PREVIEW"` or `"FULL"`. */ trackPresentation: string; /** Reason the track is restricted to a preview (only present when `trackPresentation` is `"PREVIEW"`). */ previewReason?: string; /** Signed manifest URI. */ uri: string; /** Hash of the manifest content. */ hash: string; /** Playback formats included in this manifest, e.g. `["HEAACV1", "AACLC", "FLAC"]`. */ formats: string[]; /** Album-level audio normalisation data. */ albumAudioNormalizationData: TidalAudioNormData; /** Track-level audio normalisation data. */ trackAudioNormalizationData: TidalAudioNormData; /** DRM data (only present when the track is DRM-protected). */ drmData?: DrmData; } /** * A single `trackManifests` resource object from the TIDAL OpenAPI (JSON:API format). */ export interface TrackManifestResource { /** Resource identifier (track ID as a string). */ id: string; /** JSON:API resource type - always `"trackManifests"`. */ type: string; /** Manifest attributes. */ attributes: TrackManifestAttributes; } /** * The raw JSON:API response envelope returned by the TIDAL OpenAPI track-manifest endpoint. */ export interface TrackManifestApiResponse { /** The primary track-manifest resource. */ data: TrackManifestResource; /** JSON:API links object. */ links: { self: string }; } /** * Response returned by the `/trackManifests` route. * Wraps the raw JSON:API track-manifest response from the TIDAL OpenAPI. */ export interface TrackManifestResponse extends VersionedResponse { /** The full JSON:API response object from the TIDAL OpenAPI. */ data: TrackManifestApiResponse; } // ───────────────────────────────────────────────────────────────────────────── /** * A genre entry as returned inside `?v=` video search results. */ export interface TidalGenre { /** Genre identifier. */ id: string; /** Genre display name. */ name: string; } // ─── Private implementation types ──────────────────────────────────────────── /** A JSON:API reference item (id + type only), used in similar-artist/album payloads. */ type JsonApiRef = { id: string; type: string }; /** Attribute fields present in TIDAL OpenAPI included resources for similar-artist/album endpoints. */ 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; } /** An included resource node from a TIDAL OpenAPI JSON:API response. */ interface JsonApiInclude { id: string; type: string; attributes: JsonApiIncludeAttributes; relationships?: Record; } /** A TIDAL OpenAPI JSON:API list response (similar-artists/albums). */ interface JsonApiListResponse { data?: JsonApiRef[]; included?: JsonApiInclude[]; } /** A generic paginated list response from TIDAL v1 endpoints. */ interface TidalListResponse { items?: T[]; totalNumberOfItems?: number; } /** A module within a TIDAL pages API row. */ interface TidalPageModule { type: string; mix?: Mix; item?: TidalVideoItem; pagedList?: { items: Array<{ item?: TidalTrack | TidalVideoItem }> }; } /** A row within a TIDAL pages API response. */ interface TidalPageRow { modules?: TidalPageModule[]; } /** Response shape from TIDAL v1 pages endpoints (mix, top videos, album pages). */ interface TidalPagesApiResponse { rows?: TidalPageRow[]; } /** Type guard: returns true if the given page-module item is a {@link TidalTrack}. */ function isTidalTrack(v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem }): v is TidalTrack { return 'trackNumber' in v; } /** Type guard: returns true if the given page-module item is a {@link TidalVideoItem}. */ function isTidalVideoItem( v: TidalTrack | TidalVideoItem | { item?: TidalTrack | TidalVideoItem } ): v is TidalVideoItem { return 'imageId' in v; } export enum HiFiClientEvents { TokenUpdate, TokenExpiryUpdate, RefreshTokenUpdate, } class HiFiClient { static readonly API_VERSION = '2.7'; static readonly BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25'; static readonly BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98='; 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; } /** * The base URL to use for adjusting widevine license URLs. */ #baseUrl: string | null = null; #token: string | null = null; #refreshToken: string | null = null; #appTokenExpiry = 0; #tokenPromise: Promise | null = null; #albumTracksActive = 0; readonly #albumTracksMax = 20; readonly #albumTracksQueue: Array<() => void> = []; readonly #countryCode: string; readonly #locale: string; readonly #clientId: string; readonly #clientSecret: string; readonly #emitter = new EventEmitter(); on(event: HiFiClientEvents.TokenUpdate, listener: (token: string | null) => void): void; on(event: HiFiClientEvents.TokenExpiryUpdate, listener: (expiry: number) => void): void; on(event: HiFiClientEvents.RefreshTokenUpdate, listener: (refreshToken: string | null) => void): void; // eslint-disable-next-line @typescript-eslint/no-explicit-any on(event: HiFiClientEvents, listener: (...args: any[]) => void) { this.#emitter.addListener(HiFiClientEvents[event], listener); } off(event: HiFiClientEvents, listener: (...args: (string | number | null)[]) => void) { this.#emitter.removeListener(HiFiClientEvents[event], listener); } #emit(event: HiFiClientEvents.TokenUpdate, token: string | null): void; #emit(event: HiFiClientEvents.TokenExpiryUpdate, expiry: number): void; #emit(event: HiFiClientEvents.RefreshTokenUpdate, refreshToken: string | null): void; #emit(event: HiFiClientEvents, data: string | number | null) { this.#emitter.emit(HiFiClientEvents[event], data); } get token(): string | null { return this.#token; } private set token(value: string | null) { this.#emit(HiFiClientEvents.TokenUpdate, (this.#token = value || null)); } get refreshToken(): string | null { return this.#refreshToken || null; } private set refreshToken(value: string | null) { this.#emit(HiFiClientEvents.RefreshTokenUpdate, (this.#refreshToken = value || null)); } get appTokenExpiry() { return this.#appTokenExpiry; } private set appTokenExpiry(value: number) { this.#emit(HiFiClientEvents.TokenExpiryUpdate, (this.#appTokenExpiry = value)); if (value >= 0 && value < Date.now()) { this.token = null; } } #useStorage(storage: Pick) { this.on(HiFiClientEvents.TokenUpdate, (token) => { if (token) { storage.setItem('hifi_token', token); } else { storage.removeItem('hifi_token'); } }); this.on(HiFiClientEvents.TokenExpiryUpdate, (expiry) => { if (expiry) { storage.setItem('hifi_token_expiry', String(expiry)); } else { storage.removeItem('hifi_token_expiry'); } }); } 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(); } /** * Manually sets the access token, expiry, and optional refresh token on this client. * * Useful when tokens have been obtained externally (e.g. from a server-side OAuth flow) * and need to be injected into the client. * * @param options - Token values to apply. */ setToken({ token, tokenExpiry, refreshToken }: HiFiClient.TokenOptions & HiFiClient.RefreshTokenOptions) { this.token = token || null; this.appTokenExpiry = tokenExpiry || 0; this.refreshToken = refreshToken || null; } static #basicAuth(username: string, password: string) { return 'Basic ' + btoa(`${username}:${password}`); } async #fetchAppToken({ clientId = HiFiClient.BROWSER_CLIENT_ID, clientSecret = HiFiClient.BROWSER_CLIENT_SECRET, refreshToken, scope = 'r_usr+w_usr+w_sub', signal = new AbortController().signal, force = false, }: HiFiClient.ClientOptions & HiFiClient.RefreshTokenOptions & { scope?: string; signal?: AbortSignal; force?: boolean; }): Promise { if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token; return await (this.#tokenPromise ??= (async (): Promise => { try { const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, }); if (refreshToken) { params.set('refresh_token', refreshToken); params.set('grant_type', 'refresh_token'); params.set('scope', scope); } else { params.set('grant_type', 'client_credentials'); } const res = await fetch('https://auth.tidal.com/v1/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: HiFiClient.#basicAuth(clientId, clientSecret), }, body: params, signal, }); if (!res.ok) { const txt = await res.text().catch(() => ''); throw new Error(`Failed to obtain app token: ${res.status} ${txt}`); } const json = (await res.json()) as { access_token?: string; expires_in?: number }; const token = json.access_token; const expires_in = json.expires_in ?? 3600; this.token = token || null; this.appTokenExpiry = Date.now() + (expires_in - 60) * 1000; return token || null; } finally { this.#tokenPromise = null; } })()); } static #getOptions({ locale = 'en_US', countryCode = 'US', baseUrl = '', clientId = HiFiClient.BROWSER_CLIENT_ID, clientSecret = HiFiClient.BROWSER_CLIENT_SECRET, token = '', tokenExpiry = 0, refreshToken = '', storage = [], }: HiFiClient.ConstructorOptions = {}): WithRequiredKeys { return { locale, countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, refreshToken, storage, }; } /** * Obtains (or refreshes) the TIDAL application access token. * * If a non-expired token is already held and `force` is `false`, the cached * token is returned immediately without a network request. * * @param force - When `true`, forces a token refresh even if the current token is still valid. * @param signal - Optional {@link AbortSignal} to cancel the token request. * @returns The access token string, or `null` if one could not be obtained. */ async fetchToken(force: boolean = false, signal: AbortSignal | undefined = undefined) { return await this.#fetchAppToken({ clientId: this.#clientId, clientSecret: this.#clientSecret, signal, refreshToken: this.refreshToken || undefined, force: !!force, }); } async #fetchAuthenticated( url: string, params?: Params | URLSearchParams, signal: AbortSignal = new AbortController().signal ): Promise { const final = HiFiClient.#buildUrl(url, params); let res: Response | undefined; while (true) { const unauthorized = res?.status === 401; const previousResponse = res; const token = await this.#fetchAppToken({ clientId: this.#clientId, clientSecret: this.#clientSecret, signal, refreshToken: this.refreshToken || undefined, force: unauthorized, }); try { res = await fetch(final, { headers: { authorization: `Bearer ${token}`, }, signal, }); } catch (err: unknown) { throw new ResponseError(0, err instanceof Error ? err.message : String(err)); } if (previousResponse && unauthorized && res.status === 401) { throw new ResponseError(401, 'Unauthorized: Invalid or expired token'); } if (res.status !== 401) break; } if (!res.ok) { throw new ResponseError(res.status, res.statusText); } return res; } async #fetchJson( url: string, params?: Params | URLSearchParams, signal: AbortSignal = new AbortController().signal ): Promise { const res = await this.#fetchAuthenticated(url, params, signal); return res.json() as Promise; } constructor(options: HiFiClient.ConstructorOptions = {}) { const { locale, countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, refreshToken, storage } = HiFiClient.#getOptions(options); this.#locale = locale; this.#countryCode = countryCode; this.#baseUrl = baseUrl || null; this.#clientId = clientId; this.#clientSecret = clientSecret; this.token = token || null; this.appTokenExpiry = tokenExpiry || 0; this.refreshToken = refreshToken || null; for (const store of !Array.isArray(storage) ? [storage] : storage) { this.#useStorage(store); } } /** * Creates and initialises the singleton {@link HiFiClient} instance. * * Throws if {@link HiFiClient.initialize} has already been called. After * initialisation the instance can be retrieved via {@link HiFiClient.instance}. * * @param options - Constructor options including optional credentials and locale settings. * @returns The newly created {@link HiFiClient} instance. * @throws If a singleton instance already exists. */ static async initialize(options: HiFiClient.ConstructorOptions & { signal?: AbortSignal } = {}) { if (HiFiClient.#instance) { throw new Error('HiFiClient is already initialized'); } const instance = (HiFiClient.#instance = new HiFiClient(options)); if (!options.token && !options.clientId && !options.clientSecret) { await instance.#fetchAppToken({ ...options, signal: options.signal || new AbortController().signal, }); } return (HiFiClient.#instance = 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 #withAlbumTrackSlot(fn: () => Promise) { if (this.#albumTracksActive >= this.#albumTracksMax) { await new Promise((res) => this.#albumTracksQueue.push(res)); } this.#albumTracksActive++; try { return await fn(); } finally { this.#albumTracksActive--; const next = this.#albumTracksQueue.shift(); if (next) next(); } } /** * Fetches full track metadata for the given track ID. * * @param id - TIDAL track ID. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to an {@link InfoResponse}. */ async getInfo(id: number, signal?: AbortSignal): Promise> { const url = `https://api.tidal.com/v1/tracks/${id}/`; const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } /** * Fetches playback/stream info for the given track ID. * * @param id - TIDAL track ID. * @param quality - Audio quality string, e.g. `"HI_RES_LOSSLESS"` (default). * @param immersiveAudio - Whether to request immersive audio (Dolby Atmos / 360). Defaults to `false`. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link TrackResponse}. */ async getTrack( id: number, quality = 'HI_RES_LOSSLESS', immersiveAudio: boolean = false, signal?: AbortSignal ): Promise> { const url = `https://api.tidal.com/v1/tracks/${id}/playbackinfo`; const params = { audioquality: quality, playbackmode: 'STREAM', assetpresentation: 'FULL', countryCode: this.#countryCode, immersiveAudio: String(immersiveAudio), }; const data = await this.#fetchJson(url, params, signal); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } /** * Fetches the MPEG-DASH (or alternative) track manifest from the TIDAL OpenAPI. * * @param id - TIDAL track ID. * @param options - Optional manifest request options (formats, adaptive, manifestType, etc.). * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link TrackManifestResponse}. */ async getTrackManifest( id: number, { formats = ['HEAACV1', 'AACLC', 'FLAC', 'FLAC_HIRES', 'EAC3_JOC'], adaptive = true, manifestType = 'MPEG_DASH', uriScheme = 'HTTPS', usage = 'PLAYBACK', }: HiFiClient.GetTrackManifestOptions = {}, signal?: AbortSignal ): Promise> { const url = `https://openapi.tidal.com/v2/trackManifests/${id}`; const params = new URLSearchParams({ adaptive: String(adaptive), manifestType, uriScheme, usage, }); for (const format of formats) { params.append('formats', format); } const res = await this.#fetchJson(url, params, signal); const drmData = res.data.attributes.drmData; if (drmData && this.#baseUrl) { const url = `${this.#baseUrl.replace(/\/+$/g, '')}/widevine`; drmData.licenseUrl = url; drmData.certificateUrl = url; } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: res }); } /** * Fetches a raw Widevine licence response from the TIDAL API. * * @returns The raw {@link Response} from the Widevine endpoint. */ async getWidevine(): Promise { return await this.#fetchAuthenticated('https://api.tidal.com/v2/widevine'); } /** * Fetches track recommendations for the given track ID. * * @param id - TIDAL track ID. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link RecommendationsResponse}. */ async getRecommendations(id: number, signal?: AbortSignal): Promise> { const url = `https://api.tidal.com/v1/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 }); } /** * Fetches artists similar to the given artist ID. * * @param id - TIDAL artist ID. * @param cursor - Optional pagination cursor from a previous response. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link SimilarArtistsResponse}. */ async getSimilarArtists( id: number, cursor?: string | number | null, signal?: AbortSignal ): Promise> { const url = `https://openapi.tidal.com/v2/artists/${id}/relationships/similarArtists`; const params: Params = { 'page[cursor]': cursor ?? undefined, countryCode: this.#countryCode, include: 'similarArtists,similarArtists.profileArt', }; const payload = await this.#fetchJson(url, params, signal); const included: JsonApiInclude[] = Array.isArray(payload?.included) ? payload.included : []; const artists_map: Record = {}; const artworks_map: Record = {}; for (const i of included) { if (i.type === 'artists') artists_map[i.id] = i; if (i.type === 'artworks') artworks_map[i.id] = i; } const resolveArtist = (entry: JsonApiRef): SimilarArtist => { const aid = entry.id; const inc = artists_map[aid] ?? ({} as JsonApiInclude); const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes); let pic_id: string | null = null; const art_data = inc.relationships?.profileArt?.data; if (Array.isArray(art_data) && art_data.length > 0) { const artwork = artworks_map[art_data[0].id]; const files = artwork?.attributes?.files; if (Array.isArray(files) && files[0]?.href) { pic_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href); } } return { ...attr, id: Number(aid), name: attr.name ?? '', picture: pic_id ?? attr.selectedAlbumCoverFallback ?? null, url: `http://www.tidal.com/artist/${aid}`, relationType: 'SIMILAR_ARTIST', popularity: attr.popularity ?? 0, externalLinks: attr.externalLinks ?? [], spotlighted: attr.spotlighted ?? false, contributionsEnabled: attr.contributionsEnabled ?? false, }; }; return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artists: (payload?.data ?? []).map(resolveArtist), }); } /** * Fetches albums similar to the given album ID. * * @param id - TIDAL album ID. * @param cursor - Optional pagination cursor from a previous response. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link SimilarAlbumsResponse}. */ async getSimilarAlbums( id: number, cursor?: string | number | null, signal?: AbortSignal ): Promise> { const url = `https://openapi.tidal.com/v2/albums/${id}/relationships/similarAlbums`; const params: Params = { 'page[cursor]': cursor ?? undefined, countryCode: this.#countryCode, include: 'similarAlbums,similarAlbums.coverArt,similarAlbums.artists', }; const payload = await this.#fetchJson(url, params, signal); const included: JsonApiInclude[] = Array.isArray(payload?.included) ? payload.included : []; const albums_map: Record = {}; const artworks_map: Record = {}; const artists_map: Record = {}; for (const i of included) { if (i.type === 'albums') albums_map[i.id] = i; if (i.type === 'artworks') artworks_map[i.id] = i; if (i.type === 'artists') artists_map[i.id] = i; } const resolveAlbum = (entry: JsonApiRef): TidalSimilarAlbum => { const aid = entry.id; const inc = albums_map[aid] ?? ({} as JsonApiInclude); const attr = inc.attributes ?? ({} as JsonApiIncludeAttributes); let cover_id: string | null = null; const art_data = inc.relationships?.coverArt?.data; if (Array.isArray(art_data) && art_data.length > 0) { const artwork = artworks_map[art_data[0].id]; const files = artwork?.attributes?.files; if (Array.isArray(files) && files[0]?.href) { cover_id = HiFiClient.#extractUuidFromTidalUrl(files[0].href); } } const artist_list: Array<{ id: number; name: string }> = []; const artists_data = inc.relationships?.artists?.data; if (Array.isArray(artists_data)) { for (const a_entry of artists_data) { const a_obj = artists_map[a_entry.id]; if (a_obj) { artist_list.push({ id: Number(a_obj.id), name: a_obj.attributes?.name ?? '', }); } } } return { ...attr, id: Number(aid), title: attr.title ?? '', cover: cover_id ?? '', artists: artist_list, url: `http://www.tidal.com/album/${aid}`, } as TidalSimilarAlbum; }; return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: (payload?.data ?? []).map(resolveAlbum), }); } /** * Fetches artist data including profile information, discography, and/or top tracks. * * When `id` is supplied, returns the artist's profile and a cover-image entry * ({@link ArtistByIdResponse}). * * When `f` is supplied, returns the artist's full discography and, if `skip_tracks` * is `false`, the tracks from all albums ({@link ArtistDiscographyResponse}). * * @param id - TIDAL artist ID for profile lookup. * @param f - TIDAL artist ID for discography/tracks lookup. * @param skip_tracks - When `true`, fetches only top tracks instead of all album tracks. * @param signal - Optional {@link AbortSignal} to cancel the request. * @param options - Optional pagination options (`offset`, `limit`) for track fetching. * @returns A {@link TidalResponse} whose `.json()` resolves to an {@link ArtistResponse}. */ 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 = `https://openapi.tidal.com/v2/artists/${id}`; const payload = await this.#fetchJson( artist_url, { countryCode: this.#countryCode, include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', collapseBy: 'FINGERPRINT' }, signal ); const includedMap = new Map(); if (Array.isArray(payload?.included)) { for (const item of payload.included) { includedMap.set(`${item.type}:${item.id}`, item); } } const getPic = (item: any, relName: string) => { if (item?.relationships?.[relName]?.data?.[0]) { const picRef = item.relationships[relName].data[0]; const pic = includedMap.get(`artworks:${picRef.id}`); return pic?.attributes?.files?.[0]?.href ? HiFiClient.#extractUuidFromTidalUrl(pic.attributes.files[0].href) : null; } return null; }; const data = payload?.data; const artist_data: any = { id: Number(data?.id || id), name: data?.attributes?.name || '', picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null, }; let picture = artist_data.picture; let cover: ArtistCover | null = null; if (picture) { const slug = picture.replace(/-/g, '/'); cover = { id: artist_data.id, name: artist_data.name, '750': `https://resources.tidal.com/images/${slug}/750x750.jpg`, }; } const albums: any[] = []; const tracks: any[] = []; if (data?.relationships?.albums?.data) { for (const ref of data.relationships.albums.data) { const al = includedMap.get(`albums:${ref.id}`); if (al) { albums.push({ id: Number(al.id), title: al.attributes?.title, duration: al.attributes?.duration ? 100 : undefined, numberOfTracks: al.attributes?.numberOfItems, releaseDate: al.attributes?.releaseDate, type: al.attributes?.albumType, cover: getPic(al, 'coverArt'), artist: { id: artist_data.id, name: artist_data.name } }); } } } if (data?.relationships?.tracks?.data) { for (const ref of data.relationships.tracks.data) { const tr = includedMap.get(`tracks:${ref.id}`); if (tr) { let albumInfo = undefined; if (tr.relationships?.albums?.data?.[0]) { const aRef = tr.relationships.albums.data[0]; const aItem = includedMap.get(`albums:${aRef.id}`); if (aItem) { albumInfo = { id: Number(aItem.id), title: aItem.attributes?.title, cover: getPic(aItem, 'coverArt') }; } } tracks.push({ id: Number(tr.id), title: tr.attributes?.title, duration: tr.attributes?.duration ? 100 : undefined, album: albumInfo, artist: { id: artist_data.id, name: artist_data.name } }); } } } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover, albums: { items: albums }, tracks }); } // fallback to original f logic const albums_url = `https://api.tidal.com/v1/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>( `https://api.tidal.com/v1/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 album_ids: number[] = unique_releases.map((i) => i.id).filter(Boolean); 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 }); } if (!album_ids.length) return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks: [] }); const fetchAlbumTracks = async (album_id: number): Promise => { return await this.#withAlbumTrackSlot(async () => { const album_data = await this.#fetchJson( 'https://api.tidal.com/v1/pages/album', { albumId: album_id, countryCode: this.#countryCode, deviceType: 'BROWSER' }, signal ); const rows = Array.isArray(album_data?.rows) ? album_data.rows : []; if (rows.length < 2) return []; const modules = rows[1].modules ?? []; if (!modules || modules.length === 0) return []; const paged_list = modules[0].pagedList ?? { items: [] }; const items = paged_list.items ?? []; return items.map((t) => t.item ?? t).filter((t): t is TidalTrack => isTidalTrack(t)); }); }; const trackResults = await Promise.all( album_ids.map((aid) => fetchAlbumTracks(aid).catch((): TidalTrack[] => [])) ); const tracks: TidalTrack[] = []; for (const t of trackResults) { if (Array.isArray(t)) tracks.push(...t); } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, albums: page_data, tracks }); } async getArtistBiography(artistId: number, signal?: AbortSignal): Promise> { const url = `https://api.tidal.com/v1/artists/${artistId}/bio`; const params = { locale: this.#locale, countryCode: this.#countryCode, }; const data = await this.#fetchJson(url, params, signal); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: 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`, }; } /** * Fetches cover-image URLs for a track (by `id`) or a search query (`q`). * * @param id - TIDAL track ID; if provided, returns the cover for that track's album. * @param q - Free-text search query; returns covers for matching tracks. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link CoverResponse}. * @throws {@link ResponseError} with status 400 if neither `id` nor `q` is provided. * @throws {@link ResponseError} with status 404 if no cover could be found. */ 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( `https://api.tidal.com/v1/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[] }>( 'https://api.tidal.com/v1/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 }); } /** * Performs a TIDAL search. Exactly one search option must be provided. * * | Option | Description | * |--------|-------------| * | `q` | General search across artists, albums, tracks, videos, and playlists. | * | `s` | Track-specific text search. | * | `a` | Top-hits search scoped to artists and tracks. | * | `al` | Top-hits search scoped to albums. | * | `v` | Top-hits search scoped to videos. | * | `p` | Top-hits search scoped to playlists. | * | `i` | ISRC-based track lookup (falls back to text search). | * * @param options - Search parameters; at least one of `q`, `s`, `a`, `al`, `v`, `p`, or `i` is required. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link SearchResponse}. */ async search( options: { /** General search query (artists, albums, tracks, videos, playlists). */ q?: string; /** Track text search query. */ s?: string; /** Artist/track top-hits query. */ a?: string; /** Album top-hits query. */ al?: string; /** Video top-hits query. */ v?: string; /** Playlist top-hits query. */ p?: string; /** ISRC code for exact track lookup. */ i?: string; /** Result offset for pagination. */ offset?: number; /** Maximum number of results to return. */ limit?: number; }, signal?: AbortSignal ): Promise> { const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options; if (i) { // try filtered track search first try { const res = await this.#fetchJson( 'https://api.tidal.com/v1/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; // fallback to text search } const fallback = await this.#fetchJson( 'https://api.tidal.com/v1/search/tracks', { query: i, limit, offset, countryCode: this.#countryCode, }, signal ); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: fallback }); } const mapping: Array<[string | undefined, string, Params]> = [ [ q, 'https://api.tidal.com/v1/search', { query: q, limit, offset, types: 'ARTISTS,ALBUMS,TRACKS,VIDEOS,PLAYLISTS', countryCode: this.#countryCode, }, ], [s, 'https://api.tidal.com/v1/search/tracks', { query: s, limit, offset, countryCode: this.#countryCode }], [ a, 'https://api.tidal.com/v1/search/top-hits', { query: a, limit, offset, types: 'ARTISTS,TRACKS', countryCode: this.#countryCode }, ], [ al, 'https://api.tidal.com/v1/search/top-hits', { query: al, limit, offset, types: 'ALBUMS', countryCode: this.#countryCode }, ], [ v, 'https://api.tidal.com/v1/search/top-hits', { query: v, limit, offset, types: 'VIDEOS', countryCode: this.#countryCode }, ], [ p, 'https://api.tidal.com/v1/search/top-hits', { query: p, limit, offset, types: 'PLAYLISTS', countryCode: this.#countryCode }, ], ]; for (const [val, url, params] of mapping) { if (val) { const data = await this.#fetchJson(url, params, signal); return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); } } throw new Error('Provide one of s, a, al, v, p, or i'); } /** * Fetches album metadata together with its full track listing. * * @param id - TIDAL album ID. * @param limit - Maximum number of tracks to fetch. Defaults to `100`. * @param offset - Track list offset for pagination. Defaults to `0`. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to an {@link AlbumResponse}. */ async getAlbum(id: number, limit = 100, offset = 0, signal?: AbortSignal): Promise> { const albumUrl = `https://api.tidal.com/v1/albums/${id}`; const itemsUrl = `https://api.tidal.com/v1/albums/${id}/items`; type ItemsPage = { items?: Array<{ item: TidalTrack; type: string }> }; const albumTask = this.#fetchJson(albumUrl, { countryCode: this.#countryCode }, signal); const itemsTasks: Array> = []; let remaining = limit; let currentOffset = offset; const maxChunk = 100; while (remaining > 0) { const chunk = Math.min(remaining, maxChunk); itemsTasks.push( this.#fetchJson( itemsUrl, { countryCode: this.#countryCode, limit: chunk, offset: currentOffset }, signal ) ); currentOffset += chunk; remaining -= chunk; } const [albumRaw, ...pages] = await Promise.all([albumTask, ...itemsTasks]); const allItems: Array<{ item: TidalTrack; type: string }> = []; for (const p of pages) { const pageItems = p?.items ?? []; if (Array.isArray(pageItems)) allItems.push(...pageItems); } const albumData: TidalAlbumWithTracks = { ...albumRaw, items: allItems }; return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: albumData }); } /** * Fetches the header and track list for a TIDAL mix. * * @param id - TIDAL mix ID string. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link MixResponse}. */ async getMix(id: string, signal?: AbortSignal): Promise> { const url = 'https://api.tidal.com/v1/pages/mix'; const data = await this.#fetchJson( url, { mixId: id, countryCode: this.#countryCode, deviceType: 'BROWSER' }, 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, }); } /** * Fetches playlist metadata together with its item list. * * @param id - TIDAL playlist UUID string. * @param limit - Maximum number of items to fetch. Defaults to `100`. * @param offset - Item list offset for pagination. Defaults to `0`. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link PlaylistResponse}. */ async getPlaylist( id: string, limit = 100, offset = 0, signal?: AbortSignal ): Promise> { const playlistUrl = `https://api.tidal.com/v1/playlists/${id}`; const itemsUrl = `https://api.tidal.com/v1/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 }); } // simplified artist/cover/lyrics/video/topvideos/similar methods (same pattern) /** * Fetches the lyrics for the given track ID. * * @param id - TIDAL track ID. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link LyricsResponse}. * @throws An error with `status = 404` if lyrics are unavailable for the track. */ async getLyrics(id: number, signal?: AbortSignal): Promise> { const url = `https://api.tidal.com/v1/tracks/${id}/lyrics`; const data = await this.#fetchJson( url, { countryCode: this.#countryCode, locale: 'en_US', deviceType: 'BROWSER' }, 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 }); } /** * Fetches video playback info for the given video ID. * * @param id - TIDAL video ID. * @param quality - Video quality string, e.g. `"HIGH"` (default). * @param mode - Playback mode, e.g. `"STREAM"` (default). * @param presentation - Asset presentation, e.g. `"FULL"` (default). * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link VideoResponse}. */ async getVideo( id: number, quality = 'HIGH', mode = 'STREAM', presentation = 'FULL', signal?: AbortSignal ): Promise> { const url = `https://api.tidal.com/v1/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 }); } /** * Fetches a paginated list of recommended videos from TIDAL. * * @param options - Optional locale, device type, and pagination parameters. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} whose `.json()` resolves to a {@link TopVideosResponse}. */ async getTopVideos( { countryCode = 'US', locale = 'en_US', deviceType = 'BROWSER', limit = 25, offset = 0 } = {}, signal?: AbortSignal ): Promise> { const url = 'https://api.tidal.com/v1/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'))) { const it = module.item; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion videos.push(it as TidalVideoItem); } } } return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, videos: videos.slice(offset, offset + limit), total: videos.length, }); } /** * Dispatches a local route string (e.g. `"/info/?id=123"`) to the appropriate * {@link HiFiClient} method and returns a {@link TidalResponse}. * * This is a convenience method that mirrors an HTTP-router interface so external * code can call the API with path-style strings. Because the route is resolved * at runtime the response is untyped (`TidalResponse`); prefer calling * the individual typed methods directly when the route is known at compile time. * * @param pathOrUrl - A local route path such as `"/track/?id=123"`, or a full URL. * @param signal - Optional {@link AbortSignal} to cancel the request. * @returns A {@link TidalResponse} wrapping the route handler's response. * @throws An error if the pathname does not match any known route. */ async query(pathOrUrl: string, signal?: AbortSignal): Promise { // normalize: if starts with http use as-is, else treat as local route try { const u = new URL(pathOrUrl, 'http://localhost'); const pathname = u.pathname.replace(/\/+$/, '') || '/'; const qp: Record = {}; u.searchParams.forEach((v, k) => (qp[k] = v)); const formats = u.searchParams.getAll('formats'); 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, }) ); case '/trackManifests': return new TidalResponse( await this.getTrackManifest(Number(qp.id), { ...qp, formats: formats.length > 0 ? formats : undefined, adaptive: Boolean(qp.adaptive?.toLowerCase()) || undefined, }) ); case '/widevine': return new TidalResponse(await this.getWidevine()); 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 RefreshTokenOptions { refreshToken?: string; } export interface TokenOptions { token?: string; tokenExpiry?: number; } export interface ClientOptions { clientId?: string; clientSecret?: string; } export interface LocaleOptions { locale?: string; countryCode?: string; } export interface ConstructorOptions extends LocaleOptions, RefreshTokenOptions, ClientOptions, TokenOptions, RefreshTokenOptions { baseUrl?: string; storage?: Pick[] | Pick; } export interface GetTrackManifestOptions { formats?: string[]; adaptive?: boolean; manifestType?: string; uriScheme?: 'HTTPS' | 'HTTP'; usage?: string; } } export { HiFiClient };