import { EventEmitter } from 'events'; const API_VERSION = '2.7'; const BROWSER_CLIENT_ID = 'txNoH4kkV41MfH25'; const BROWSER_CLIENT_SECRET = 'dQjy0MinCEvxi1O4UmxvxWnDjt4cgHBPw8ll6nYBk98='; type Params = Record; class ResponseError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } export class TidalResponse extends Response { 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 enum HiFiClientEvents { TokenUpdate, TokenExpiryUpdate, RefreshTokenUpdate, } class HiFiClient { 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 #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; on(event: HiFiClientEvents, listener: (...args: any[]) => void) { this.#emitter.addListener(HiFiClientEvents[event], listener); } off(event: HiFiClientEvents, listener: (...args: any[]) => 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: any) { 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: any) { return new Response(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(); } setToken({ token, tokenExpiry, refreshToken }: HiFiClient.TokenOptions & HiFiClient.RefreshTokenOptions) { this.token = token; this.appTokenExpiry = this.appTokenExpiry; this.refreshToken = refreshToken; } static #basicAuth(username: string, password: string) { return 'Basic ' + btoa(`${username}:${password}`); } async #fetchAppToken({ clientId = BROWSER_CLIENT_ID, clientSecret = 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; }) { if (!force && this.token && (this.appTokenExpiry < 0 || Date.now() < this.appTokenExpiry)) return this.token; return await (this.#tokenPromise ??= (async () => { 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(); const token = json.access_token; const expires_in = json.expires_in ?? 3600; this.token = token; this.appTokenExpiry = Date.now() + (expires_in - 60) * 1000; return token; } finally { this.#tokenPromise = null; } })()); } static #getOptions({ countryCode = 'US', baseUrl = null, clientId = BROWSER_CLIENT_ID, clientSecret = BROWSER_CLIENT_SECRET, token, tokenExpiry, refreshToken: tokenRefresh, storage = [], }: HiFiClient.ConstructorOptions = {}) { return { countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, tokenRefresh, storage }; } 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 await this.#fetchAppToken({ clientId: this.#clientId, clientSecret: this.#clientSecret, signal, refreshToken: this.refreshToken || undefined, force: unauthorized, }); res = await fetch(final, { headers: { authorization: `Bearer ${token}`, }, signal, }); 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(); } constructor(options: HiFiClient.ConstructorOptions = {}) { const { countryCode, baseUrl, clientId, clientSecret, token, tokenExpiry, tokenRefresh, storage } = HiFiClient.#getOptions(options); this.#countryCode = countryCode; this.#baseUrl = baseUrl; this.#clientId = clientId; this.#clientSecret = clientSecret; this.token = token; this.appTokenExpiry = tokenExpiry; this.refreshToken = tokenRefresh; 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'); } 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(); } } async getInfo(id: number, signal?: AbortSignal) { const url = `https://api.tidal.com/v1/tracks/${id}/`; const data = await this.#fetchJson(url, { countryCode: this.#countryCode }, signal); return HiFiClient.#jsonResponse({ version: API_VERSION, data }); } async getTrack(id: number, quality = 'HI_RES_LOSSLESS', immersiveAudio: boolean = false, signal?: AbortSignal) { 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: API_VERSION, data }); } 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 ) { 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: API_VERSION, data: res }); } async getWidevine() { return await this.#fetchAuthenticated('https://api.tidal.com/v2/widevine'); } async getRecommendations(id: number, signal?: AbortSignal) { const url = `https://api.tidal.com/v1/tracks/${id}/recommendations`; const data = await this.#fetchJson(url, { limit: '20', countryCode: this.#countryCode }, signal); return HiFiClient.#jsonResponse({ version: API_VERSION, data }); } async getSimilarArtists(id: number, cursor?: string | number | null, signal?: AbortSignal) { 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 = 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: any) => { const aid = entry.id; const inc = artists_map[aid] || {}; const attr = inc.attributes || {}; 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: String(aid).match(/^\d+$/) ? Number(aid) : aid, picture: pic_id || attr.selectedAlbumCoverFallback, url: `http://www.tidal.com/artist/${aid}`, relationType: 'SIMILAR_ARTIST', }; }; return HiFiClient.#jsonResponse({ version: API_VERSION, artists: (payload?.data || []).map(resolveArtist) }); } async getSimilarAlbums(id: number, cursor?: string | number | null, signal?: AbortSignal) { 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 = 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: any) => { const aid = entry.id; const inc = albums_map[aid] || {}; const attr = inc.attributes || {}; 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: any[] = []; 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) { const a_id = a_obj.id; artist_list.push({ id: String(a_id).match(/^\d+$/) ? Number(a_id) : a_id, name: a_obj.attributes?.name, }); } } } return { ...attr, id: String(aid).match(/^\d+$/) ? Number(aid) : aid, cover: cover_id, artists: artist_list, url: `http://www.tidal.com/album/${aid}`, }; }; return HiFiClient.#jsonResponse({ version: API_VERSION, albums: (payload?.data || []).map(resolveAlbum) }); } async getArtist( id?: number | null, f?: number | null, skip_tracks = false, signal?: AbortSignal, options?: { offset?: number; limit?: number } ) { if (!id && !f) throw new ResponseError(400, 'Provide id or f query param'); if (id) { const artist_url = `https://api.tidal.com/v1/artists/${id}`; const artist_data = await this.#fetchJson(artist_url, { countryCode: this.#countryCode }, signal); let picture = artist_data.picture; const fallback = artist_data.selectedAlbumCoverFallback; if (!picture && fallback) { artist_data.picture = fallback; picture = fallback; } let cover = 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`, }; } return HiFiClient.#jsonResponse({ version: API_VERSION, artist: artist_data, cover }); } // f provided -> gather albums and optionally tracks const albums_url = `https://api.tidal.com/v1/artists/${f}/albums`; const common_params: Params = { countryCode: this.#countryCode, limit: 50 }; const tasks: Promise[] = [ 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) => e))); const unique_releases: any[] = []; const seen_ids = new Set(); for (const res of results.slice(0, 2)) { if (res && !(res instanceof Error)) { const data = res; const items = Array.isArray(data?.items) ? data.items : data || []; if (Array.isArray(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: any[] = []; if (results.length > 2) { const res = results[2]; if (res && !(res instanceof Error)) { top_tracks = Array.isArray(res.items) ? res.items : res || []; } } return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks: top_tracks }); } if (!album_ids.length) return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks: [] }); const fetchAlbumTracks = async (album_id: number) => { 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 || {}; const items = paged_list.items || []; const tracks = items.map((t: any) => (t.item ? t.item : t)); return tracks; }); }; const trackResults = await Promise.all(album_ids.map((aid) => fetchAlbumTracks(aid).catch(() => []))); const tracks: any[] = []; for (const t of trackResults) { if (Array.isArray(t)) tracks.push(...t); } return HiFiClient.#jsonResponse({ version: API_VERSION, albums: page_data, tracks }); } #buildCoverEntry(cover_slug: string, name?: string | null, track_id?: number | null) { const slug = cover_slug.replace(/-/g, '/'); return { id: track_id, 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) { 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 || {}; 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: API_VERSION, covers: [entry] }); } const search_data = await this.#fetchJson( '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: any[] = []; for (const track of items) { const album = track.album || {}; 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: 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 ) { 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: API_VERSION, data: res }); } catch (err: any) { if (err.status && ![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: 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: API_VERSION, data }); } } throw new Error('Provide one of s, a, al, v, p, or i'); } async getAlbum(id: number, limit = 100, offset = 0, signal?: AbortSignal) { const albumUrl = `https://api.tidal.com/v1/albums/${id}`; const itemsUrl = `https://api.tidal.com/v1/albums/${id}/items`; const tasks: Promise[] = [this.#fetchJson(albumUrl, { countryCode: this.#countryCode }, signal)]; let remaining = limit; let currentOffset = offset; const maxChunk = 100; while (remaining > 0) { const chunk = Math.min(remaining, maxChunk); tasks.push( this.#fetchJson( itemsUrl, { countryCode: this.#countryCode, limit: chunk, offset: currentOffset }, signal ) ); currentOffset += chunk; remaining -= chunk; } const results = await Promise.all(tasks); const albumData = results[0]; const pages = results.slice(1); const allItems: any[] = []; for (const p of pages) { const pageItems = (p && p.items) || p; if (Array.isArray(pageItems)) allItems.push(...pageItems); } albumData.items = allItems; return HiFiClient.#jsonResponse({ version: API_VERSION, data: albumData }); } async getMix(id: string, signal?: AbortSignal) { 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 = {}, items: any[] = []; 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 || []; } } return HiFiClient.#jsonResponse({ version: API_VERSION, mix: header, items: items.map((it: any) => (it.item ? it.item : it)), }); } async getPlaylist(id: string, limit = 100, offset = 0, signal?: AbortSignal) { 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(itemsUrl, { countryCode: this.#countryCode, limit, offset }, signal), ]); const items = (itemsData && itemsData.items) || itemsData; return HiFiClient.#jsonResponse({ version: API_VERSION, playlist: playlistData, items }); } // simplified artist/cover/lyrics/video/topvideos/similar methods (same pattern) async getLyrics(id: number, signal?: AbortSignal) { 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: any = new Error('Lyrics not found'); err.status = 404; throw err; } return HiFiClient.#jsonResponse({ version: API_VERSION, lyrics: data }); } async getVideo(id: number, quality = 'HIGH', mode = 'STREAM', presentation = 'FULL', signal?: AbortSignal) { 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: API_VERSION, video: data }); } async getTopVideos( { countryCode = 'US', locale = 'en_US', deviceType = 'BROWSER', limit = 25, offset = 0 } = {}, signal?: AbortSignal ) { 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: any[] = []; 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) videos.push(item.item || item); } else if (mt === 'VIDEO' || (mt && mt.toLowerCase().includes('video'))) { const it = module.item || module; if (typeof it === 'object') videos.push(it); } } } return HiFiClient.#jsonResponse({ version: API_VERSION, videos: videos.slice(offset, offset + limit), total: videos.length, }); } // generic helper that accepts local route strings like "/info/?id=123" or full URLs async query(pathOrUrl: string, signal?: AbortSignal) { // 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: 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': 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) { console.error(err?.message || err, err?.message ? err : undefined); 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 ConstructorOptions extends ClientOptions, TokenOptions, RefreshTokenOptions { countryCode?: string; baseUrl?: string; storage?: Pick[] | Pick; } export interface GetTrackManifestOptions { formats?: string[]; adaptive?: boolean; manifestType?: string; uriScheme?: 'HTTPS' | 'HTTP'; usage?: string; } } export { HiFiClient };