From 47b8c36594113cfe275860ac4e75907768797e2b Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:26:49 -0500 Subject: [PATCH] fix(api): use direct queries when possible --- js/HiFi.ts | 688 +++++++++++++++++++++++++++++++++++++++++++++++++++++ js/api.js | 16 ++ 2 files changed, 704 insertions(+) create mode 100644 js/HiFi.ts diff --git a/js/HiFi.ts b/js/HiFi.ts new file mode 100644 index 0000000..0cc222a --- /dev/null +++ b/js/HiFi.ts @@ -0,0 +1,688 @@ +const API_VERSION = '2.6'; +const CLIENT_ID = 'txNoH4kkV41MfH25'; +const 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(body: BodyInit, init?: ResponseInit) { + super(body, init); + } +} + +export class HiFiClient { + private static token: string | null; + private countryCode: string; + private static appTokenExpiry = 0; + private static albumTracksMax = 20; + private static albumTracksActive = 0; + private static albumTracksQueue: Array<() => void> = []; + + private static buildUrl(base: string, params?: Params) { + if (!params) return base; + 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(); + } + + private encodeBasic(id: string, secret: string) { + if (typeof window !== 'undefined' && typeof window.btoa === 'function') { + return window.btoa(`${id}:${secret}`); + } + // Node fallback + return Buffer.from(`${id}:${secret}`).toString('base64'); + } + + private async fetchAppToken(signal: AbortSignal = new AbortController().signal): Promise { + const now = Date.now(); + if (HiFiClient.token && now < HiFiClient.appTokenExpiry) return HiFiClient.token; + + const res = await fetch('https://auth.tidal.com/v1/oauth2/token', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + authorization: `Basic ${this.encodeBasic(CLIENT_ID, CLIENT_SECRET)}`, + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + 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; + HiFiClient.token = token; + HiFiClient.appTokenExpiry = Date.now() + expires_in * 1000 - 60_000; + return token; + } + + constructor(countryCode = 'US') { + this.countryCode = countryCode; + } + + private async fetchJson(url: string, params?: Params, signal: AbortSignal = new AbortController().signal) { + const final = HiFiClient.buildUrl(url, params); + const res = await fetch(final, { + headers: { authorization: `Bearer ${await this.fetchAppToken(signal)}` }, + signal, + }); + + if (!res.ok) { + throw new ResponseError(res.status, res.statusText); + } + + return res.json(); + } + + private static extractUuidFromTidalUrl(href?: string | null) { + if (!href) return null; + const parts = href.split('/'); + return parts.length >= 9 ? parts.slice(4, 9).join('-') : null; + } + + private async withAlbumTrackSlot(fn: () => Promise) { + if (HiFiClient.albumTracksActive >= HiFiClient.albumTracksMax) { + await new Promise((res) => HiFiClient.albumTracksQueue.push(res)); + } + HiFiClient.albumTracksActive++; + try { + return await fn(); + } finally { + HiFiClient.albumTracksActive--; + const next = HiFiClient.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 { version: API_VERSION, data }; + } + + async getTrack(id: number, quality = 'HI_RES_LOSSLESS', signal?: AbortSignal) { + const url = `https://api.tidal.com/v1/tracks/${id}/playbackinfo`; + const params = { + audioquality: quality, + playbackmode: 'STREAM', + assetpresentation: 'FULL', + countryCode: this.countryCode, + }; + const data = await this.fetchJson(url, params, signal); + return { version: API_VERSION, data }; + } + + 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 { 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 { 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 { version: API_VERSION, albums: (payload?.data || []).map(resolveAlbum) }; + } + + async getArtist(id?: number | null, f?: number | null, skip_tracks = false, signal?: AbortSignal) { + 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 { 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) { + tasks.push( + this.fetchJson( + `https://api.tidal.com/v1/artists/${f}/toptracks`, + { countryCode: this.countryCode, limit: 15 }, + 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 { version: API_VERSION, albums: page_data, tracks: top_tracks }; + } + + if (!album_ids.length) return { 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 { version: API_VERSION, albums: page_data, tracks }; + } + + private 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 { 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 { version: API_VERSION, covers }; + } + + async search( + options: { + s?: string; + a?: string; + al?: string; + v?: string; + p?: string; + i?: string; + offset?: number; + limit?: number; + }, + signal?: AbortSignal + ) { + const { 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 { 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 { version: API_VERSION, data: fallback }; + } + + const mapping: Array<[string | undefined, string, Params]> = [ + [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 { 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 { 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 { 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 { 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 { 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 { 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 { version: API_VERSION, videos: videos.slice(offset, offset + limit), total: videos.length }; + } + + async queryResponse(pathOrUrl: string, signal?: AbortSignal) { + try { + return new TidalResponse(JSON.stringify(await this.query(pathOrUrl, signal)), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + if (err instanceof ResponseError) { + return new TidalResponse(JSON.stringify({ error: err.message }), { + status: err.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + throw err; + } + } + + // 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)); + + switch (pathname) { + case '/': + return { version: API_VERSION, Repo: 'https://github.com/binimum/hifi-api' }; + case '/info': + return await this.getInfo(Number(qp.id)); + case '/track': + return await this.getTrack(Number(qp.id), qp.quality || undefined); + case '/recommendations': + return await this.getRecommendations(Number(qp.id)); + case '/artist/similar': + return await this.getSimilarArtists(Number(qp.id), qp.cursor ?? undefined, signal); + case '/album/similar': + return await this.getSimilarAlbums(Number(qp.id), qp.cursor ?? undefined, signal); + case '/artist': + return 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 + ); + case '/cover': + return await this.getCover(qp.id ? Number(qp.id) : undefined, qp.q ?? undefined, signal); + case '/search': + return await this.search({ + 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 await this.getAlbum( + Number(qp.id), + qp.limit ? Number(qp.limit) : undefined, + qp.offset ? Number(qp.offset) : undefined + ); + case '/playlist': + return await this.getPlaylist( + qp.id || '', + qp.limit ? Number(qp.limit) : undefined, + qp.offset ? Number(qp.offset) : undefined + ); + case '/mix': + return await this.getMix(qp.id || ''); + case '/lyrics': + return await this.getLyrics(Number(qp.id)); + case '/video': + return await this.getVideo( + Number(qp.id), + qp.quality || undefined, + qp.mode || undefined, + qp.presentation || undefined + ); + case '/topvideos': + return 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: + // unknown local route => treat as raw upstream path (forward) + if (pathOrUrl.startsWith('http')) { + return await this.fetchJson(pathOrUrl); + } + throw new Error(`Unknown route: ${pathname}`); + } + } catch (err) { + throw err; + } + } +} diff --git a/js/api.js b/js/api.js index b61b7b5..383a2e7 100644 --- a/js/api.js +++ b/js/api.js @@ -21,10 +21,12 @@ import { isCustomFormat } from './ffmpegFormats.ts'; import { DownloadProgress } from './progressEvents.js'; import { resolveDownloadTotalBytes } from './downloadProgressUtils.js'; import { readableStreamIterator } from './readableStreamIterator.js'; +import { HiFiClient } from './HiFi.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; export { resolveDownloadTotalBytes }; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; +const client = new HiFiClient(); export class LosslessAPI { constructor(settings) { @@ -54,6 +56,20 @@ export class LosslessAPI { async fetchWithRetry(relativePath, options = {}) { const type = options.type || 'api'; + const instanceRoutes = ['/track', '/album/similar', '/artist/similar', '/video']; + + if (!instanceRoutes.some((route) => relativePath.startsWith(route))) { + try { + console.log(relativePath); + return await client.queryResponse(relativePath); + } catch (err) { + console.warn( + `Direct fetch failed for ${relativePath}. Falling back to configured API instances...`, + err + ); + } + } + let instances = await this.settings.getInstances(type); if (instances.length === 0) { throw new Error(`No API instances configured for type: ${type}`);