From d4f6c5911fbee00b7e8c9dc98a1c851886fdf6e0 Mon Sep 17 00:00:00 2001 From: uimaxbai <61615730+uimaxbai@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:02:34 +0100 Subject: [PATCH 1/4] Revert "style: auto-fix linting issues" This reverts commit 4b6f81b2ec424ceb29db065667180f495af01998. --- js/HiFi.ts | 34 ++++++++++------------------------ styles.css | 4 ++-- test-search.js | 14 ++++---------- 3 files changed, 16 insertions(+), 36 deletions(-) diff --git a/js/HiFi.ts b/js/HiFi.ts index 3ae309a..8278320 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -1717,11 +1717,7 @@ class HiFiClient { const artist_url = `https://openapi.tidal.com/v2/artists/${id}`; const payload = await this.#fetchJson( artist_url, - { - countryCode: this.#countryCode, - include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', - collapseBy: 'FINGERPRINT', - }, + { countryCode: this.#countryCode, include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', collapseBy: 'FINGERPRINT' }, signal ); @@ -1750,7 +1746,7 @@ class HiFiClient { picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null, }; - const picture = artist_data.picture; + let picture = artist_data.picture; let cover: ArtistCover | null = null; if (picture) { const slug = picture.replace(/-/g, '/'); @@ -1776,7 +1772,7 @@ class HiFiClient { releaseDate: al.attributes?.releaseDate, type: al.attributes?.albumType, cover: getPic(al, 'coverArt'), - artist: { id: artist_data.id, name: artist_data.name }, + artist: { id: artist_data.id, name: artist_data.name } }); } } @@ -1788,34 +1784,24 @@ class HiFiClient { if (tr) { let albumInfo = undefined; if (tr.relationships?.albums?.data?.[0]) { - const aRef = tr.relationships.albums.data[0]; - const aItem = includedMap.get(`albums:${aRef.id}`); - if (aItem) { - albumInfo = { - id: Number(aItem.id), - title: aItem.attributes?.title, - cover: getPic(aItem, 'coverArt'), - }; - } + const aRef = tr.relationships.albums.data[0]; + const aItem = includedMap.get(`albums:${aRef.id}`); + if (aItem) { + albumInfo = { id: Number(aItem.id), title: aItem.attributes?.title, cover: getPic(aItem, 'coverArt') }; + } } tracks.push({ id: Number(tr.id), title: tr.attributes?.title, duration: tr.attributes?.duration ? 100 : undefined, album: albumInfo, - artist: { id: artist_data.id, name: artist_data.name }, + artist: { id: artist_data.id, name: artist_data.name } }); } } } - return HiFiClient.#jsonResponse({ - version: HiFiClient.API_VERSION, - artist: artist_data, - cover, - albums: { items: albums }, - tracks, - }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover, albums: { items: albums }, tracks }); } // fallback to original f logic diff --git a/styles.css b/styles.css index 8f2f61e..1bd604e 100644 --- a/styles.css +++ b/styles.css @@ -1049,8 +1049,8 @@ ul { gap: 0.375rem; padding: 0.4rem 0.6rem; margin-bottom: 1rem; - background: rgb(245, 158, 11, 0.12); - border: 1px solid rgb(245, 158, 11, 0.25); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.25); border-radius: var(--radius-md); color: #f59e0b; font-size: 0.7rem; diff --git a/test-search.js b/test-search.js index a4f6345..39c67c4 100644 --- a/test-search.js +++ b/test-search.js @@ -4,13 +4,7 @@ import { LosslessAPI } from './js/api.js'; // mock out modules to make LosslessAPI load in bun import { mock } from 'bun:test'; mock.module('./js/icons.ts', () => ({})); -mock.module('./js/settings.js', () => ({ - devModeSettings: { isEnabled: () => false }, - syncManager: {}, - musicProviderSettings: {}, - audioSettings: {}, - apiSettings: {}, -})); +mock.module('./js/settings.js', () => ({ devModeSettings: { isEnabled: () => false }, syncManager: {}, musicProviderSettings: {}, audioSettings: {}, apiSettings: {} })); globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; globalThis.window = { matchMedia: () => ({ matches: false }) }; @@ -22,12 +16,12 @@ async function test() { // mock cache api.cache = { get: () => null, set: () => {} }; - api.fetchWithRetry = async function (relativePath, options) { - console.log('fetchWithRetry called:', relativePath); + api.fetchWithRetry = async function(relativePath, options) { + console.log("fetchWithRetry called:", relativePath); return HiFiClient.instance.query(relativePath); }; const res = await api.search('coldplay'); - console.log('Returned tracks:', res.tracks?.items?.length); + console.log("Returned tracks:", res.tracks?.items?.length); } test().catch(console.error); From 6ddb411b94d573e39f3287fa2353846369ed960f Mon Sep 17 00:00:00 2001 From: uimaxbai <61615730+uimaxbai@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:04:31 +0100 Subject: [PATCH 2/4] Revert --- functions/proxy-audio.js | 67 ---------------------------------------- js/HiFi.ts | 34 ++++++++++++++------ js/api.js | 10 ------ styles.css | 4 +-- test-search.js | 14 ++++++--- 5 files changed, 36 insertions(+), 93 deletions(-) delete mode 100644 functions/proxy-audio.js diff --git a/functions/proxy-audio.js b/functions/proxy-audio.js deleted file mode 100644 index e05a1e4..0000000 --- a/functions/proxy-audio.js +++ /dev/null @@ -1,67 +0,0 @@ -export async function onRequest(context) { - const { request } = context; - const url = new URL(request.url); - const targetUrl = url.searchParams.get('url'); - - if (!targetUrl) { - return new Response('Missing url parameter', { status: 400 }); - } - - try { - const cacheUrl = new URL(request.url); - try { - const tidalUrl = new URL(targetUrl); - cacheUrl.searchParams.set('cache_key', tidalUrl.pathname); - } catch (e) {} - - const cacheKey = new Request(cacheUrl.toString(), request); - const cache = caches.default; - let response = await cache.match(cacheKey); - - if (!response) { - console.log('Cache Miss. Fetching from Tidal...'); - - const headers = new Headers(request.headers); - headers.delete('host'); - headers.delete('referer'); - headers.set( - 'User-Agent', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ); - - response = await fetch(targetUrl, { - method: request.method, - headers: headers, - redirect: 'follow', - cf: { - cacheTtl: 2592000, - cacheEverything: true, - }, - }); - - if (request.method === 'GET' && response.ok) { - const cacheResponse = new Response(response.body, response); - cacheResponse.headers.set('Access-Control-Allow-Origin', '*'); - cacheResponse.headers.set('Cache-Control', 'public, max-age=2592000'); - - cacheResponse.headers.delete('Set-Cookie'); - - context.waitUntil(cache.put(cacheKey, cacheResponse.clone())); - response = cacheResponse; - } - } else { - console.log('Cache Hit! Serving from Edge.'); - } - - const newResponse = new Response(response.body, response); - newResponse.headers.set('Access-Control-Allow-Origin', '*'); - newResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); - newResponse.headers.set('Access-Control-Expose-Headers', '*'); - newResponse.headers.delete('content-security-policy'); - newResponse.headers.delete('x-frame-options'); - - return newResponse; - } catch (error) { - return new Response('Proxy Error: ' + error.message, { status: 500 }); - } -} diff --git a/js/HiFi.ts b/js/HiFi.ts index 8278320..3ae309a 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -1717,7 +1717,11 @@ class HiFiClient { const artist_url = `https://openapi.tidal.com/v2/artists/${id}`; const payload = await this.#fetchJson( artist_url, - { countryCode: this.#countryCode, include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', collapseBy: 'FINGERPRINT' }, + { + countryCode: this.#countryCode, + include: 'albums,albums.coverArt,tracks,tracks.albums,biography,profileArt', + collapseBy: 'FINGERPRINT', + }, signal ); @@ -1746,7 +1750,7 @@ class HiFiClient { picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null, }; - let picture = artist_data.picture; + const picture = artist_data.picture; let cover: ArtistCover | null = null; if (picture) { const slug = picture.replace(/-/g, '/'); @@ -1772,7 +1776,7 @@ class HiFiClient { releaseDate: al.attributes?.releaseDate, type: al.attributes?.albumType, cover: getPic(al, 'coverArt'), - artist: { id: artist_data.id, name: artist_data.name } + artist: { id: artist_data.id, name: artist_data.name }, }); } } @@ -1784,24 +1788,34 @@ class HiFiClient { if (tr) { let albumInfo = undefined; if (tr.relationships?.albums?.data?.[0]) { - const aRef = tr.relationships.albums.data[0]; - const aItem = includedMap.get(`albums:${aRef.id}`); - if (aItem) { - albumInfo = { id: Number(aItem.id), title: aItem.attributes?.title, cover: getPic(aItem, 'coverArt') }; - } + const aRef = tr.relationships.albums.data[0]; + const aItem = includedMap.get(`albums:${aRef.id}`); + if (aItem) { + albumInfo = { + id: Number(aItem.id), + title: aItem.attributes?.title, + cover: getPic(aItem, 'coverArt'), + }; + } } tracks.push({ id: Number(tr.id), title: tr.attributes?.title, duration: tr.attributes?.duration ? 100 : undefined, album: albumInfo, - artist: { id: artist_data.id, name: artist_data.name } + artist: { id: artist_data.id, name: artist_data.name }, }); } } } - return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, artist: artist_data, cover, albums: { items: albums }, tracks }); + return HiFiClient.#jsonResponse({ + version: HiFiClient.API_VERSION, + artist: artist_data, + cover, + albums: { items: albums }, + tracks, + }); } // fallback to original f logic diff --git a/js/api.js b/js/api.js index 6bfb61c..09390ed 100644 --- a/js/api.js +++ b/js/api.js @@ -1507,11 +1507,6 @@ export class LosslessAPI { }; } - if (streamUrl && streamUrl.includes('tidal.com')) { - const encodedUrl = encodeURIComponent(streamUrl); - streamUrl = `/proxy-audio?url=${encodedUrl}`; - } - const result = { url: streamUrl, rgInfo: manifestRgInfo }; this.streamCache.set(cacheKey, result); @@ -1559,11 +1554,6 @@ export class LosslessAPI { throw new Error(`Could not resolve video stream URL for ID: ${id}`); } - if (streamUrl && streamUrl.includes('tidal.com')) { - const encodedUrl = encodeURIComponent(streamUrl); - streamUrl = `/proxy-audio?url=${encodedUrl}`; - } - if (!(lookup instanceof TidalResponse)) { this.streamCache.set(cacheKey, streamUrl); } diff --git a/styles.css b/styles.css index 1bd604e..8f2f61e 100644 --- a/styles.css +++ b/styles.css @@ -1049,8 +1049,8 @@ ul { gap: 0.375rem; padding: 0.4rem 0.6rem; margin-bottom: 1rem; - background: rgba(245, 158, 11, 0.12); - border: 1px solid rgba(245, 158, 11, 0.25); + background: rgb(245, 158, 11, 0.12); + border: 1px solid rgb(245, 158, 11, 0.25); border-radius: var(--radius-md); color: #f59e0b; font-size: 0.7rem; diff --git a/test-search.js b/test-search.js index 39c67c4..a4f6345 100644 --- a/test-search.js +++ b/test-search.js @@ -4,7 +4,13 @@ import { LosslessAPI } from './js/api.js'; // mock out modules to make LosslessAPI load in bun import { mock } from 'bun:test'; mock.module('./js/icons.ts', () => ({})); -mock.module('./js/settings.js', () => ({ devModeSettings: { isEnabled: () => false }, syncManager: {}, musicProviderSettings: {}, audioSettings: {}, apiSettings: {} })); +mock.module('./js/settings.js', () => ({ + devModeSettings: { isEnabled: () => false }, + syncManager: {}, + musicProviderSettings: {}, + audioSettings: {}, + apiSettings: {}, +})); globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; globalThis.window = { matchMedia: () => ({ matches: false }) }; @@ -16,12 +22,12 @@ async function test() { // mock cache api.cache = { get: () => null, set: () => {} }; - api.fetchWithRetry = async function(relativePath, options) { - console.log("fetchWithRetry called:", relativePath); + api.fetchWithRetry = async function (relativePath, options) { + console.log('fetchWithRetry called:', relativePath); return HiFiClient.instance.query(relativePath); }; const res = await api.search('coldplay'); - console.log("Returned tracks:", res.tracks?.items?.length); + console.log('Returned tracks:', res.tracks?.items?.length); } test().catch(console.error); From c2b3f7312e234fc755ced28f1604acff7d82e809 Mon Sep 17 00:00:00 2001 From: uimaxbai <61615730+uimaxbai@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:16:07 +0100 Subject: [PATCH 3/4] fix streaming for now --- index.html | 4 +- js/HiFi.ts | 174 +++++++++++++++++++++++++++++---- js/api.js | 225 +++++++++++++++++++++++++------------------ js/audio-context.js | 228 +------------------------------------------- js/player.js | 16 +--- 5 files changed, 290 insertions(+), 357 deletions(-) diff --git a/index.html b/index.html index d090bd3..0d46417 100644 --- a/index.html +++ b/index.html @@ -115,8 +115,8 @@ - - + +
  • diff --git a/js/HiFi.ts b/js/HiFi.ts index 3ae309a..09007a5 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -1340,11 +1340,18 @@ class HiFiClient { force: unauthorized, }); + const headers: Record = { + authorization: `Bearer ${token}`, + }; + if (final.includes('openapi.tidal.com')) { + // Prefer JSON:API for OpenAPI endpoints, but do not require it exclusively. + // Some endpoints/proxies can still return compatible JSON. + headers['Accept'] = 'application/vnd.api+json, application/json;q=0.9, */*;q=0.8'; + } + try { res = await fetch(final, { - headers: { - authorization: `Bearer ${token}`, - }, + headers, signal, }); } catch (err: unknown) { @@ -1744,10 +1751,21 @@ class HiFiClient { }; const data = payload?.data; + let biography: any = null; + if (data?.relationships?.biography?.data) { + const bioRef = data.relationships.biography.data; + const bioItem = + includedMap.get(`biographies:${bioRef.id}`) || includedMap.get(`biography:${bioRef.id}`); + if (bioItem) { + biography = { text: bioItem.attributes?.text, source: bioItem.attributes?.source }; + } + } + const artist_data: any = { id: Number(data?.id || id), name: data?.attributes?.name || '', picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null, + biography: biography, }; const picture = artist_data.picture; @@ -2019,6 +2037,110 @@ class HiFiClient { ): Promise> { const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options; + const parseOpenApiSearch = (jsonApi: any): SearchResponse['data'] => { + if (!jsonApi || !jsonApi.data) return {}; + + const includedMap = new Map(); + if (Array.isArray(jsonApi.included)) { + for (const item of jsonApi.included) { + includedMap.set(`${item.type}:${item.id}`, item); + } + } + + const resolveArtworkId = (item: any, relName: string) => { + const ref = item?.relationships?.[relName]?.data?.[0]; + if (!ref) return null; + const artwork = includedMap.get(`artworks:${ref.id}`); + const href = artwork?.attributes?.files?.[0]?.href; + return href ? HiFiClient.#extractUuidFromTidalUrl(href) : null; + }; + + const resolveArtists = (item: any) => { + const refs = item?.relationships?.artists?.data; + if (!Array.isArray(refs)) return []; + return refs.map((art: any) => { + const aItem = includedMap.get(`artists:${art.id}`); + return { + id: Number(art.id), + name: aItem?.attributes?.name ?? '', + }; + }); + }; + + const resolveItem = (ref: { id: string; type: string }) => { + const item = includedMap.get(`${ref.type}:${ref.id}`); + if (!item) return null; + + const attrs = item.attributes || {}; + const mapped: any = { + id: Number(item.id) || item.id, + ...attrs, + }; + + if (item.type === 'artists') { + mapped.type = 'artist'; + mapped.name = attrs.name ?? ''; + mapped.picture = resolveArtworkId(item, 'profileArt'); + } else if (item.type === 'albums') { + const artists = resolveArtists(item); + mapped.type = 'album'; + mapped.title = attrs.title ?? ''; + mapped.cover = resolveArtworkId(item, 'coverArt'); + mapped.artists = artists; + if (artists.length > 0) mapped.artist = artists[0]; + } else if (item.type === 'tracks') { + const artists = resolveArtists(item); + mapped.type = 'track'; + mapped.title = attrs.title ?? ''; + mapped.artists = artists; + if (artists.length > 0) mapped.artist = artists[0]; + const albumRef = item.relationships?.albums?.data?.[0]; + if (albumRef) { + const albumItem = includedMap.get(`albums:${albumRef.id}`); + mapped.album = { + id: Number(albumRef.id), + title: albumItem?.attributes?.title ?? '', + cover: albumItem ? resolveArtworkId(albumItem, 'coverArt') : null, + }; + } + } else if (item.type === 'videos') { + const artists = resolveArtists(item); + mapped.type = 'video'; + mapped.title = attrs.title ?? ''; + mapped.artists = artists; + if (artists.length > 0) mapped.artist = artists[0]; + mapped.imageId = resolveArtworkId(item, 'image'); + } else if (item.type === 'playlists') { + mapped.type = 'playlist'; + mapped.title = attrs.name ?? ''; + mapped.image = resolveArtworkId(item, 'coverArt'); + } + + return mapped; + }; + + const relationships = jsonApi.data.relationships || {}; + const mapBucket = (relName: string) => { + const relData = relationships[relName]?.data; + if (!Array.isArray(relData)) return undefined; + const items = relData.map(resolveItem).filter(Boolean); + return { + items, + totalNumberOfItems: items.length, + limit, + offset, + }; + }; + + return { + artists: mapBucket('artists'), + albums: mapBucket('albums'), + tracks: mapBucket('tracks'), + videos: mapBucket('videos'), + playlists: mapBucket('playlists'), + }; + }; + if (i) { // try filtered track search first try { @@ -2037,58 +2159,68 @@ class HiFiClient { if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err; // fallback to text search } - const fallback = await this.#fetchJson( - 'https://api.tidal.com/v1/search/tracks', + const fallback = await this.#fetchJson( + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(i)}`, { - query: i, limit, offset, + include: 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt', countryCode: this.#countryCode, }, signal ); - return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: fallback }); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(fallback) }); } + const includeQ = 'albums,albums.coverArt,albums.artists,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt,artists,playlists,videos'; + const includeS = 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt'; + const includeA = 'artists,artists.profileArt,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt'; + const includeAl = 'albums,albums.artists,albums.coverArt'; + const includeV = 'videos,videos.artists,videos.image'; + const includeP = 'playlists,playlists.coverArt'; + const mapping: Array<[string | undefined, string, Params]> = [ [ q, - 'https://api.tidal.com/v1/search', + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(q || '')}`, { - query: q, limit, offset, - types: 'ARTISTS,ALBUMS,TRACKS,VIDEOS,PLAYLISTS', + include: includeQ, countryCode: this.#countryCode, }, ], - [s, 'https://api.tidal.com/v1/search/tracks', { query: s, limit, offset, countryCode: this.#countryCode }], + [ + s, + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(s || '')}`, + { limit, offset, include: includeS, countryCode: this.#countryCode }, + ], [ a, - 'https://api.tidal.com/v1/search/top-hits', - { query: a, limit, offset, types: 'ARTISTS,TRACKS', countryCode: this.#countryCode }, + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(a || '')}`, + { limit, offset, include: includeA, countryCode: this.#countryCode }, ], [ al, - 'https://api.tidal.com/v1/search/top-hits', - { query: al, limit, offset, types: 'ALBUMS', countryCode: this.#countryCode }, + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(al || '')}`, + { limit, offset, include: includeAl, countryCode: this.#countryCode }, ], [ v, - 'https://api.tidal.com/v1/search/top-hits', - { query: v, limit, offset, types: 'VIDEOS', countryCode: this.#countryCode }, + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(v || '')}`, + { limit, offset, include: includeV, countryCode: this.#countryCode }, ], [ p, - 'https://api.tidal.com/v1/search/top-hits', - { query: p, limit, offset, types: 'PLAYLISTS', countryCode: this.#countryCode }, + `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(p || '')}`, + { limit, offset, include: includeP, countryCode: this.#countryCode }, ], ]; for (const [val, url, params] of mapping) { if (val) { - const data = await this.#fetchJson(url, params, signal); - return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data }); + const data = await this.#fetchJson(url, params, signal); + return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(data) }); } } diff --git a/js/api.js b/js/api.js index 09390ed..15f2bad 100644 --- a/js/api.js +++ b/js/api.js @@ -62,6 +62,105 @@ export class LosslessAPI { async fetchWithRetry(relativePath, options = {}) { const type = options.type || 'api'; + const isSearchRequest = relativePath.startsWith('/search/'); + const getInstances = async (forceRefresh = false) => { + if (forceRefresh && this.settings && typeof this.settings.refreshInstances === 'function') { + try { + await this.settings.refreshInstances(); + } catch (refreshError) { + console.warn('Failed to refresh API instances from uptime workers:', refreshError); + } + } + + let instances = await this.settings.getInstances(type); + if (options.userInstancesOnly) { + instances = instances.filter((i) => i.isUser); + if (instances.length === 0) { + throw new Error(`No user API instances configured for type: ${type}`); + } + } else if (instances.length === 0) { + throw new Error(`No API instances configured for type: ${type}`); + } + + if (options.minVersion) { + instances = instances.filter((instance) => { + if (!instance.version) return false; + return parseFloat(instance.version) >= parseFloat(options.minVersion); + }); + if (instances.length === 0) { + throw new Error( + `No API instances configured for type: ${type} with minVersion: ${options.minVersion}` + ); + } + } + + if (options.allowedDomains) { + instances = instances.filter((instance) => { + const url = typeof instance === 'string' ? instance : instance.url; + return options.allowedDomains.some((domain) => url.includes(domain)); + }); + if (instances.length === 0) { + throw new Error( + `No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}` + ); + } + } + + return instances; + }; + + const tryInstances = async (instances) => { + const maxTotalAttempts = instances.length * 2; // Allow some retries across instances + let lastError = null; + let instanceIndex = Math.floor(Math.random() * instances.length); + + for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) { + const instance = instances[instanceIndex % instances.length]; + const baseUrl = typeof instance === 'string' ? instance : instance.url; + const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`; + + try { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`); + instanceIndex++; + await delay(500); + continue; + } + + if (response.ok) { + return response; + } + + if (response.status === 401) { + const errorData = await response.clone().json().catch(() => null); + if (errorData?.subStatus === 11002) { + console.warn(`Auth failed on ${baseUrl}. Trying next instance...`); + instanceIndex++; + continue; + } + } + + if (response.status >= 500) { + console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`); + instanceIndex++; + continue; + } + + lastError = new Error(`Request failed with status ${response.status}`); + instanceIndex++; + } catch (error) { + if (error.name === 'AbortError') throw error; + lastError = error; + console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`); + instanceIndex++; + await delay(200); + } + } + + throw lastError || new Error(`All API instances failed for: ${relativePath}`); + }; if (devModeSettings.isEnabled()) { const devBaseUrl = devModeSettings.getUrl().replace(/\/+$/, ''); @@ -78,101 +177,45 @@ export class LosslessAPI { return response; } - if (type !== 'streaming') { + const shouldTryNative = type !== 'streaming'; + + if (shouldTryNative) { try { if (import.meta.env.DEV) { console.log(relativePath); } + // HiFiClient.query fans out across the native TIDAL endpoints used by the route + // implementation, including api.tidal.com and openapi.tidal.com where applicable. return await HiFiClient.instance.query(relativePath); } catch (err) { if (options.directOnly) { throw err; } - console.warn( - `Direct Tidal API fetch failed for ${relativePath}. Falling back to configured API instances...`, - err - ); + + if (import.meta.env.DEV && isSearchRequest) { + console.warn( + `[search] native TIDAL query failed for ${relativePath}, trying HiFi worker instances`, + err + ); + } else { + console.warn( + `Native TIDAL query failed for ${relativePath}. Falling back to configured HiFi API instances...`, + err + ); + } } } - let instances = await this.settings.getInstances(type); - if (instances.length === 0) { - throw new Error(`No API instances configured for type: ${type}`); - } - - if (options.minVersion) { - instances = instances.filter((instance) => { - if (!instance.version) return false; - return parseFloat(instance.version) >= parseFloat(options.minVersion); - }); - if (instances.length === 0) { - throw new Error(`No API instances configured for type: ${type} with minVersion: ${options.minVersion}`); + try { + return await tryInstances(await getInstances(false)); + } catch (error) { + if (type === 'streaming' || options.userInstancesOnly) { + throw error; } } - if (options.allowedDomains) { - instances = instances.filter((instance) => { - const url = typeof instance === 'string' ? instance : instance.url; - return options.allowedDomains.some((domain) => url.includes(domain)); - }); - if (instances.length === 0) { - throw new Error( - `No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}` - ); - } - } - - const maxTotalAttempts = instances.length * 2; // Allow some retries across instances - let lastError = null; - let instanceIndex = Math.floor(Math.random() * instances.length); - - for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) { - const instance = instances[instanceIndex % instances.length]; - const baseUrl = typeof instance === 'string' ? instance : instance.url; - const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`; - - try { - const response = await fetch(url, { signal: options.signal }); - - if (response.status === 429) { - console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`); - instanceIndex++; - await delay(500); // Small delay before trying next instance - continue; - } - - if (response.ok) { - return response; - } - - if (response.status === 401) { - let errorData = await response.clone().json(); - if (errorData?.subStatus === 11002) { - console.warn(`Auth failed on ${baseUrl}. Trying next instance...`); - instanceIndex++; - continue; - } - } - - if (response.status >= 500) { - console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`); - instanceIndex++; - continue; - } - - lastError = new Error(`Request failed with status ${response.status}`); - instanceIndex++; - } catch (error) { - if (error.name === 'AbortError') throw error; - lastError = error; - console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`); - instanceIndex++; - await delay(200); - } - } - - throw lastError || new Error(`All API instances failed for: ${relativePath}`); + return await tryInstances(await getInstances(true)); } findSearchSection(source, key, visited) { @@ -454,19 +497,9 @@ export class LosslessAPI { if (cached) return cached; try { - // Keep direct TIDAL combined search behavior for normal mode. - // If direct query fails, fall back to hifi-api-compatible scoped searches (?s, ?a, ?al, ?v, ?p). - const response = await this.fetchWithRetry(`/search/?q=${encodeURIComponent(query)}`, { - ...options, - directOnly: true, - }); + const response = await this.fetchWithRetry(`/search/?q=${encodeURIComponent(query)}`, options); const data = await response.json(); - // Check if backend returned an error or if this looks like individual fallback - if (data.error) { - throw new Error('Fallback to individual searches'); - } - const extractSection = (key) => this.normalizeSearchResponse(data, key); const tracksData = extractSection('tracks'); @@ -503,8 +536,12 @@ export class LosslessAPI { await this.cache.set('search_all', query, results); return results; - } catch (_error) { - // Fallback to individual searches if the backend proxy doesn't support ?q= or throws + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[search] combined search failed, using HiFi scoped fallback', error); + } + + // Final fallback: hifi-api-compatible scoped searches (?s, ?a, ?al, ?v, ?p) const [tracks, videos, artists, albums, playlists] = await Promise.all([ this.searchTracks(query, options).catch(() => ({ items: [] })), this.searchVideos(query, options).catch(() => ({ items: [] })), @@ -1126,7 +1163,7 @@ export class LosslessAPI { const result = { ...artist, albums, eps, tracks, videos }; - if (!(primaryResponse instanceof TidalResponse) && !(contentResponse instanceof TidalResponse)) { + if (!(primaryResponse instanceof TidalResponse)) { await this.cache.set('artist', cacheKey, result); } return result; @@ -1247,7 +1284,7 @@ export class LosslessAPI { if (cached) return cached; try { - const response = await HiFiClient.instance.query(`/artist/bio/?id=${artistId}`); + const response = await this.fetchWithRetry(`/artist/bio/?id=${artistId}`, { type: 'api' }); if (response.ok) { const { data } = await response.json(); diff --git a/js/audio-context.js b/js/audio-context.js index c919414..e0616b8 100644 --- a/js/audio-context.js +++ b/js/audio-context.js @@ -2,9 +2,7 @@ // Shared Audio Context Manager - handles EQ and provides context for visualizer // Supports 3-32 parametric EQ bands -import { isIos } from './platform-detection.js'; import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.js'; -import { BinauralDSP } from './binaural-dsp.js'; // Generate frequency array for given number of bands using logarithmic spacing function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) { @@ -475,11 +473,6 @@ class AudioContextManager { this.audio = audioElement; - if (isIos) { - console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility'); - return; - } - try { const AudioContext = window.AudioContext || window.webkitAudioContext; @@ -490,29 +483,10 @@ class AudioContextManager { this.audioContext = new AudioContext(); } - if (!this.sources.has(audioElement)) { - const src = this.audioContext.createMediaElementSource(audioElement); - this.sources.set(audioElement, src); - } - this.source = this.sources.get(audioElement); - - // Enable multichannel passthrough for Atmos/spatial content - try { - this.audioContext.destination.channelCount = Math.min(this.audioContext.destination.maxChannelCount, 8); - this.audioContext.destination.channelCountMode = 'explicit'; - this.audioContext.destination.channelInterpretation = 'discrete'; - } catch { - // Some browsers may not support changing destination channel count - } - this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 1024; this.analyser.smoothingTimeConstant = 0.7; - // Create binaural DSP processor - this.binauralDsp = new BinauralDSP(this.audioContext); - void this._loadBinauralSettings(); - this._createEQ(); this._createGraphicEQ(); this._createMSNodes(); @@ -528,15 +502,12 @@ class AudioContextManager { this.monoMergerNode = this.audioContext.createChannelMerger(2); - this._connectGraph(); - // Auto-recover from unexpected suspensions (e.g. background throttling) this.audioContext.addEventListener('statechange', () => { if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') { console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`); - // Use a short delay to let the system settle before resuming setTimeout(() => { - if (this.audioContext && this.audioContext.state !== 'running' && this.source) { + if (this.audioContext && this.audioContext.state !== 'running') { this.audioContext.resume().catch((e) => { console.warn('[AudioContext] Auto-resume failed:', e); }); @@ -558,28 +529,7 @@ class AudioContextManager { } if (this.audio === audioElement) return; - try { - if (this.source) { - try { - this.source.disconnect(); - } catch { - // node may already be disconnected - } - } - - this.audio = audioElement; - - if (!this.sources.has(audioElement)) { - this.sources.set(audioElement, this.audioContext.createMediaElementSource(audioElement)); - } - this.source = this.sources.get(audioElement); - - if (this.isInitialized) { - this._connectGraph(); - } - } catch (e) { - console.warn('changeSource failed:', e); - } + this.audio = audioElement; } /** @@ -588,179 +538,7 @@ class AudioContextManager { * the new chain is wired up first, then the old connections are torn down. */ _connectGraph() { - if (!this.isInitialized || !this.source || !this.audioContext) return; - - // Ensure graphic EQ nodes exist - if (this.geqFilters.length === 0 && this.isGraphicEQEnabled) { - this._createGraphicEQ(); - } - - // Helper: connect a chain segment from lastNode through graphic EQ (if enabled) to analyser -> volume -> dest - const connectTail = (lastNode) => { - if (this.isGraphicEQEnabled && this.geqFilters.length > 0) { - lastNode.connect(this.geqPreampNode); - this.geqPreampNode.connect(this.geqFilters[0]); - for (let i = 0; i < this.geqFilters.length - 1; i++) { - this.geqFilters[i].connect(this.geqFilters[i + 1]); - } - this.geqFilters[this.geqFilters.length - 1].connect(this.geqOutputNode); - this.geqOutputNode.connect(this.analyser); - } else { - lastNode.connect(this.analyser); - } - this.analyser.connect(this.volumeNode); - this.volumeNode.connect(this.audioContext.destination); - }; - - try { - // Ensure mono gain node exists if needed - if (this.isMonoAudioEnabled && this.monoMergerNode && !this.monoGainNode) { - this.monoGainNode = this.audioContext.createGain(); - this.monoGainNode.gain.value = 0.5; - } - - // --- 1. Disconnect all existing connections --- - const safeDisconnect = (node) => { - try { - node?.disconnect(); - } catch { - /* */ - } - }; - safeDisconnect(this.source); - safeDisconnect(this.monoGainNode); - safeDisconnect(this.monoMergerNode); - // Binaural DSP disconnects internally - if (this.binauralDsp) { - const { input, output } = this.binauralDsp.getNodes(); - safeDisconnect(input); - safeDisconnect(output); - } - safeDisconnect(this.preampNode); - this.filters.forEach(safeDisconnect); - safeDisconnect(this.outputNode); - // M/S nodes - safeDisconnect(this.msSplitter); - safeDisconnect(this.msEncoderMidL); - safeDisconnect(this.msEncoderMidR); - safeDisconnect(this.msEncoderSideL); - safeDisconnect(this.msEncoderSideR); - safeDisconnect(this.msMidInput); - safeDisconnect(this.msSideInput); - this.midFilters.forEach(safeDisconnect); - this.sideFilters.forEach(safeDisconnect); - safeDisconnect(this.midOutputNode); - safeDisconnect(this.sideOutputNode); - safeDisconnect(this.msDecoderMidToL); - safeDisconnect(this.msDecoderSideToL); - safeDisconnect(this.msDecoderMidToR); - safeDisconnect(this.msDecoderSideToR); - safeDisconnect(this.msLMix); - safeDisconnect(this.msRMix); - safeDisconnect(this.msMerger); - safeDisconnect(this.msOutputNode); - // Graphic EQ + tail - safeDisconnect(this.geqPreampNode); - this.geqFilters.forEach(safeDisconnect); - safeDisconnect(this.geqOutputNode); - safeDisconnect(this.analyser); - safeDisconnect(this.volumeNode); - - // --- 2. Reconnect the graph --- - let lastNode = this.source; - - if (this.isMonoAudioEnabled && this.monoMergerNode) { - this.source.connect(this.monoGainNode); - this.monoGainNode.connect(this.monoMergerNode, 0, 0); - this.monoGainNode.connect(this.monoMergerNode, 0, 1); - lastNode = this.monoMergerNode; - } - - // Insert binaural DSP before EQ - if (this.isBinauralEnabled && this.binauralDsp) { - const { input, output } = this.binauralDsp.getNodes(); - lastNode.connect(input); - this.binauralDsp.reconnect(); - lastNode = output; - } - - if (this.isEQEnabled && this.filters.length > 0) { - const useMS = this.msEnabled && this.midFilters.length > 0 && this.sideFilters.length > 0; - - // Connect preamp - if (this.preampNode) { - lastNode.connect(this.preampNode); - lastNode = this.preampNode; - } - - if (useMS) { - // === M/S processing path === - // Encode L/R → M/S - lastNode.connect(this.msSplitter); - - this.msSplitter.connect(this.msEncoderMidL, 0); // L → Mid - this.msSplitter.connect(this.msEncoderMidR, 1); // R → Mid - this.msEncoderMidL.connect(this.msMidInput); - this.msEncoderMidR.connect(this.msMidInput); // Mid = (L+R)*0.5 - - this.msSplitter.connect(this.msEncoderSideL, 0); // L → Side - this.msSplitter.connect(this.msEncoderSideR, 1); // R → Side (-0.5) - this.msEncoderSideL.connect(this.msSideInput); - this.msEncoderSideR.connect(this.msSideInput); // Side = (L-R)*0.5 - - // Mid filter chain - this.msMidInput.connect(this.midFilters[0]); - for (let i = 0; i < this.midFilters.length - 1; i++) { - this.midFilters[i].connect(this.midFilters[i + 1]); - } - this.midFilters[this.midFilters.length - 1].connect(this.midOutputNode); - - // Side filter chain - this.msSideInput.connect(this.sideFilters[0]); - for (let i = 0; i < this.sideFilters.length - 1; i++) { - this.sideFilters[i].connect(this.sideFilters[i + 1]); - } - this.sideFilters[this.sideFilters.length - 1].connect(this.sideOutputNode); - - // Decode M/S → L/R - this.midOutputNode.connect(this.msDecoderMidToL); - this.sideOutputNode.connect(this.msDecoderSideToL); - this.msDecoderMidToL.connect(this.msLMix); - this.msDecoderSideToL.connect(this.msLMix); // L = Mid + Side - - this.midOutputNode.connect(this.msDecoderMidToR); - this.sideOutputNode.connect(this.msDecoderSideToR); - this.msDecoderMidToR.connect(this.msRMix); - this.msDecoderSideToR.connect(this.msRMix); // R = Mid - Side - - this.msLMix.connect(this.msMerger, 0, 0); - this.msRMix.connect(this.msMerger, 0, 1); - this.msMerger.connect(this.msOutputNode); - - connectTail(this.msOutputNode); - } else { - // === Normal stereo path === - lastNode.connect(this.filters[0]); - for (let i = 0; i < this.filters.length - 1; i++) { - this.filters[i].connect(this.filters[i + 1]); - } - this.filters[this.filters.length - 1].connect(this.outputNode); - connectTail(this.outputNode); - } - } else { - connectTail(lastNode); - } - - // Notify visualizers that graph has been reconnected - this._notifyGraphChange(); - } catch (e) { - console.warn('[AudioContext] Failed to connect graph:', e); - try { - this.source.connect(this.audioContext.destination); - } catch { - /* ignore */ - } - } + if (!this.isInitialized || !this.audioContext) return; } /** diff --git a/js/player.js b/js/player.js index 6b72fa8..7952b98 100644 --- a/js/player.js +++ b/js/player.js @@ -294,21 +294,7 @@ export class Player { const el = this.activeElement; - // Apply to audio element and/or Web Audio graph - const isApple = isIos || isSafari; - - if (audioContextManager.isReady() && !isApple) { - // If Web Audio is active, we apply volume there for better compatibility - // Especially on Linux where audio.volume might not affect the Web Audio graph - el.volume = 1.0; - audioContextManager.setVolume(effectiveVolume); - } else { - // Safari bypasses WebAudio for HLS, so we MUST set el.volume directly to reflect ReplayGain - if (audioContextManager.isReady()) { - audioContextManager.setVolume(1.0); // Reset graph gain if it somehow routes - } - el.volume = Math.max(0, Math.min(1, effectiveVolume)); - } + el.volume = Math.max(0, Math.min(1, effectiveVolume)); } applyAudioEffects() { From 2096ad8873be5ab91a5f76b29600d469b49452f5 Mon Sep 17 00:00:00 2001 From: binimum <61615730+binimum@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:16:43 +0000 Subject: [PATCH 4/4] style: auto-fix linting issues --- js/HiFi.ts | 3 ++- js/api.js | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/js/HiFi.ts b/js/HiFi.ts index 09007a5..e96a4d6 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -2172,7 +2172,8 @@ class HiFiClient { return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(fallback) }); } - const includeQ = 'albums,albums.coverArt,albums.artists,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt,artists,playlists,videos'; + const includeQ = + 'albums,albums.coverArt,albums.artists,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt,artists,playlists,videos'; const includeS = 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt'; const includeA = 'artists,artists.profileArt,tracks,tracks.artists,tracks.albums,tracks.albums.coverArt'; const includeAl = 'albums,albums.artists,albums.coverArt'; diff --git a/js/api.js b/js/api.js index 15f2bad..80a1635 100644 --- a/js/api.js +++ b/js/api.js @@ -117,7 +117,9 @@ export class LosslessAPI { for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) { const instance = instances[instanceIndex % instances.length]; const baseUrl = typeof instance === 'string' ? instance : instance.url; - const url = baseUrl.endsWith('/') ? `${baseUrl}${relativePath.substring(1)}` : `${baseUrl}${relativePath}`; + const url = baseUrl.endsWith('/') + ? `${baseUrl}${relativePath.substring(1)}` + : `${baseUrl}${relativePath}`; try { const response = await fetch(url, { signal: options.signal }); @@ -134,7 +136,10 @@ export class LosslessAPI { } if (response.status === 401) { - const errorData = await response.clone().json().catch(() => null); + const errorData = await response + .clone() + .json() + .catch(() => null); if (errorData?.subStatus === 11002) { console.warn(`Auth failed on ${baseUrl}. Trying next instance...`); instanceIndex++;