1216 lines
39 KiB
TypeScript
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 };
|