kv-music/js/HiFi.ts

1216 lines
39 KiB
TypeScript

import type { PlaybackInfo } from './container-classes';
type Params = Record<string, string | number | undefined | null>;
class ResponseError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export interface TypedResponse<T> extends Response {
json(): Promise<T>;
}
export class TidalResponse<T = unknown> extends Response implements TypedResponse<T> {
declare json: () => Promise<T>;
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<string, string>;
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<string, string>;
}
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<T> {
limit: number;
offset: number;
totalNumberOfItems: number;
items: T[];
}
export interface SearchResponse extends VersionedResponse {
data: {
artists?: TidalSearchBucket<TidalArtistProfile>;
albums?: TidalSearchBucket<TidalAlbum>;
tracks?: TidalSearchBucket<TidalTrack>;
videos?: TidalSearchBucket<TidalVideoItem>;
playlists?: TidalSearchBucket<TidalPlaylist>;
genres?: TidalSearchBucket<TidalGenre>;
topHit?: { value: TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem; type: string };
topHits?: Array<TidalTrack | TidalArtistProfile | TidalAlbum | TidalVideoItem>;
};
}
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<string, { data?: JsonApiRef[] }>;
}
interface JsonApiListResponse {
data?: JsonApiRef[];
included?: JsonApiInclude[];
}
interface TidalListResponse<T> {
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<Storage, 'setItem' | 'removeItem'>) {
this.#publicToken && storage.setItem('tidal_web_token', this.#publicToken);
}
static #jsonResponse<T>(data: T): TidalResponse<T> {
return new TidalResponse<T>(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<T = unknown>(
url: string,
params?: Params | URLSearchParams,
signal: AbortSignal = new AbortController().signal
): Promise<T> {
const final = HiFiClient.#buildUrl(url, params);
const headers: Record<string, string> = {
'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<T>;
}
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<TidalResponse<InfoResponse>> {
const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}`;
const data = await this.#fetchJson<TidalTrack>(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<TidalResponse<TrackResponse>> {
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<PlaybackInfo>(url, params, signal);
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
}
async getRecommendations(id: number, signal?: AbortSignal): Promise<TidalResponse<RecommendationsResponse>> {
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<TidalResponse<SimilarArtistsResponse>> {
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<TidalResponse<SimilarAlbumsResponse>> {
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<TidalResponse<ArtistResponse>> {
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<TidalArtistProfile>(
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<TidalListResponse<TidalAlbum>>(
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<TidalListResponse<TidalTrack>>(
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<Promise<TidalListResponse<TidalAlbum> | TidalListResponse<TidalTrack>>> = [
this.#fetchJson<TidalListResponse<TidalAlbum>>(albums_url, common_params, signal),
this.#fetchJson<TidalListResponse<TidalAlbum>>(
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<TidalListResponse<TidalTrack>>(
`${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<number>();
for (const res of results.slice(0, 2)) {
if (res && !(res instanceof Error)) {
const data = res as TidalListResponse<TidalAlbum>;
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<TidalTrack>;
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<TidalResponse<ArtistBioResponse>> {
const url = `${HiFiClient.TIDAL_BASE_URL}/artists/${artistId}/bio`;
const data = await this.#fetchJson<ArtistBiography>(
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<TidalResponse<CoverResponse>> {
if (!id && !q) throw new ResponseError(400, 'Provide id or q query param');
if (id) {
const track_data = await this.#fetchJson<TidalTrack>(
`${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<TidalResponse<SearchResponse>> {
const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options;
if (i) {
try {
const res = await this.#fetchJson<SearchResponse['data']>(
`${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<any>(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<TidalResponse<AlbumResponse>> {
const albumUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}`;
const itemsUrl = `${HiFiClient.TIDAL_BASE_URL}/albums/${id}/items`;
const albumRaw = await this.#fetchJson<TidalAlbum>(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<TidalResponse<MixResponse>> {
const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mix`;
const data = await this.#fetchJson<TidalPagesApiResponse>(
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<TidalResponse<PlaylistResponse>> {
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<TidalPlaylist>(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<TidalResponse<LyricsResponse>> {
const url = `${HiFiClient.TIDAL_BASE_URL}/tracks/${id}/lyrics`;
const data = await this.#fetchJson<Lyrics>(
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<TidalResponse<VideoResponse>> {
const url = `${HiFiClient.TIDAL_BASE_URL}/videos/${id}/playbackinfo`;
const data = await this.#fetchJson<VideoPlaybackInfo>(
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<TidalResponse<TopVideosResponse>> {
const url = `${HiFiClient.TIDAL_BASE_URL}/pages/mymusic_recommended_videos`;
const data = await this.#fetchJson<TidalPagesApiResponse>(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<TidalResponse> {
try {
const u = new URL(pathOrUrl, 'http://localhost');
const pathname = u.pathname.replace(/\/+$/, '') || '/';
const qp: Record<string, string> = {};
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<Storage, 'setItem' | 'removeItem'>[] | Pick<Storage, 'setItem' | 'removeItem'>;
}
}
export { HiFiClient };