fix streaming for now
This commit is contained in:
parent
6ddb411b94
commit
c2b3f7312e
5 changed files with 290 additions and 357 deletions
|
|
@ -115,8 +115,8 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<audio id="audio-player" style="display: none" crossorigin="anonymous"></audio>
|
<audio id="audio-player" style="display: none"></audio>
|
||||||
<video id="video-player" style="display: none" crossorigin="anonymous"></video>
|
<video id="video-player" style="display: none"></video>
|
||||||
<div id="context-menu">
|
<div id="context-menu">
|
||||||
<ul>
|
<ul>
|
||||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||||
|
|
|
||||||
174
js/HiFi.ts
174
js/HiFi.ts
|
|
@ -1340,11 +1340,18 @@ class HiFiClient {
|
||||||
force: unauthorized,
|
force: unauthorized,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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 {
|
try {
|
||||||
res = await fetch(final, {
|
res = await fetch(final, {
|
||||||
headers: {
|
headers,
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|
@ -1744,10 +1751,21 @@ class HiFiClient {
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = payload?.data;
|
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 = {
|
const artist_data: any = {
|
||||||
id: Number(data?.id || id),
|
id: Number(data?.id || id),
|
||||||
name: data?.attributes?.name || '',
|
name: data?.attributes?.name || '',
|
||||||
picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null,
|
picture: getPic(data, 'profileArt') || data?.attributes?.selectedAlbumCoverFallback || null,
|
||||||
|
biography: biography,
|
||||||
};
|
};
|
||||||
|
|
||||||
const picture = artist_data.picture;
|
const picture = artist_data.picture;
|
||||||
|
|
@ -2019,6 +2037,110 @@ class HiFiClient {
|
||||||
): Promise<TidalResponse<SearchResponse>> {
|
): Promise<TidalResponse<SearchResponse>> {
|
||||||
const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options;
|
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<string, any>();
|
||||||
|
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) {
|
if (i) {
|
||||||
// try filtered track search first
|
// try filtered track search first
|
||||||
try {
|
try {
|
||||||
|
|
@ -2037,58 +2159,68 @@ class HiFiClient {
|
||||||
if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err;
|
if (err instanceof ResponseError && ![400, 404].includes(err.status)) throw err;
|
||||||
// fallback to text search
|
// fallback to text search
|
||||||
}
|
}
|
||||||
const fallback = await this.#fetchJson<SearchResponse['data']>(
|
const fallback = await this.#fetchJson<any>(
|
||||||
'https://api.tidal.com/v1/search/tracks',
|
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(i)}`,
|
||||||
{
|
{
|
||||||
query: i,
|
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
|
include: 'tracks,tracks.artists,tracks.albums,tracks.albums.coverArt',
|
||||||
countryCode: this.#countryCode,
|
countryCode: this.#countryCode,
|
||||||
},
|
},
|
||||||
signal
|
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]> = [
|
const mapping: Array<[string | undefined, string, Params]> = [
|
||||||
[
|
[
|
||||||
q,
|
q,
|
||||||
'https://api.tidal.com/v1/search',
|
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(q || '')}`,
|
||||||
{
|
{
|
||||||
query: q,
|
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
types: 'ARTISTS,ALBUMS,TRACKS,VIDEOS,PLAYLISTS',
|
include: includeQ,
|
||||||
countryCode: this.#countryCode,
|
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,
|
a,
|
||||||
'https://api.tidal.com/v1/search/top-hits',
|
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(a || '')}`,
|
||||||
{ query: a, limit, offset, types: 'ARTISTS,TRACKS', countryCode: this.#countryCode },
|
{ limit, offset, include: includeA, countryCode: this.#countryCode },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
al,
|
al,
|
||||||
'https://api.tidal.com/v1/search/top-hits',
|
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(al || '')}`,
|
||||||
{ query: al, limit, offset, types: 'ALBUMS', countryCode: this.#countryCode },
|
{ limit, offset, include: includeAl, countryCode: this.#countryCode },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
v,
|
v,
|
||||||
'https://api.tidal.com/v1/search/top-hits',
|
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(v || '')}`,
|
||||||
{ query: v, limit, offset, types: 'VIDEOS', countryCode: this.#countryCode },
|
{ limit, offset, include: includeV, countryCode: this.#countryCode },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
p,
|
p,
|
||||||
'https://api.tidal.com/v1/search/top-hits',
|
`https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(p || '')}`,
|
||||||
{ query: p, limit, offset, types: 'PLAYLISTS', countryCode: this.#countryCode },
|
{ limit, offset, include: includeP, countryCode: this.#countryCode },
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [val, url, params] of mapping) {
|
for (const [val, url, params] of mapping) {
|
||||||
if (val) {
|
if (val) {
|
||||||
const data = await this.#fetchJson<SearchResponse['data']>(url, params, signal);
|
const data = await this.#fetchJson<any>(url, params, signal);
|
||||||
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data });
|
return HiFiClient.#jsonResponse({ version: HiFiClient.API_VERSION, data: parseOpenApiSearch(data) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
225
js/api.js
225
js/api.js
|
|
@ -62,6 +62,105 @@ export class LosslessAPI {
|
||||||
|
|
||||||
async fetchWithRetry(relativePath, options = {}) {
|
async fetchWithRetry(relativePath, options = {}) {
|
||||||
const type = options.type || 'api';
|
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()) {
|
if (devModeSettings.isEnabled()) {
|
||||||
const devBaseUrl = devModeSettings.getUrl().replace(/\/+$/, '');
|
const devBaseUrl = devModeSettings.getUrl().replace(/\/+$/, '');
|
||||||
|
|
@ -78,101 +177,45 @@ export class LosslessAPI {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== 'streaming') {
|
const shouldTryNative = type !== 'streaming';
|
||||||
|
|
||||||
|
if (shouldTryNative) {
|
||||||
try {
|
try {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log(relativePath);
|
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);
|
return await HiFiClient.instance.query(relativePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (options.directOnly) {
|
if (options.directOnly) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
console.warn(
|
|
||||||
`Direct Tidal API fetch failed for ${relativePath}. Falling back to configured API instances...`,
|
if (import.meta.env.DEV && isSearchRequest) {
|
||||||
err
|
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);
|
try {
|
||||||
if (instances.length === 0) {
|
return await tryInstances(await getInstances(false));
|
||||||
throw new Error(`No API instances configured for type: ${type}`);
|
} catch (error) {
|
||||||
}
|
if (type === 'streaming' || options.userInstancesOnly) {
|
||||||
|
throw error;
|
||||||
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) {
|
return await tryInstances(await getInstances(true));
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
findSearchSection(source, key, visited) {
|
findSearchSection(source, key, visited) {
|
||||||
|
|
@ -454,19 +497,9 @@ export class LosslessAPI {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Keep direct TIDAL combined search behavior for normal mode.
|
const response = await this.fetchWithRetry(`/search/?q=${encodeURIComponent(query)}`, options);
|
||||||
// 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 data = await response.json();
|
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 extractSection = (key) => this.normalizeSearchResponse(data, key);
|
||||||
|
|
||||||
const tracksData = extractSection('tracks');
|
const tracksData = extractSection('tracks');
|
||||||
|
|
@ -503,8 +536,12 @@ export class LosslessAPI {
|
||||||
await this.cache.set('search_all', query, results);
|
await this.cache.set('search_all', query, results);
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
// Fallback to individual searches if the backend proxy doesn't support ?q= or throws
|
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([
|
const [tracks, videos, artists, albums, playlists] = await Promise.all([
|
||||||
this.searchTracks(query, options).catch(() => ({ items: [] })),
|
this.searchTracks(query, options).catch(() => ({ items: [] })),
|
||||||
this.searchVideos(query, options).catch(() => ({ items: [] })),
|
this.searchVideos(query, options).catch(() => ({ items: [] })),
|
||||||
|
|
@ -1126,7 +1163,7 @@ export class LosslessAPI {
|
||||||
|
|
||||||
const result = { ...artist, albums, eps, tracks, videos };
|
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);
|
await this.cache.set('artist', cacheKey, result);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -1247,7 +1284,7 @@ export class LosslessAPI {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.ok) {
|
||||||
const { data } = await response.json();
|
const { data } = await response.json();
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
// Shared Audio Context Manager - handles EQ and provides context for visualizer
|
// Shared Audio Context Manager - handles EQ and provides context for visualizer
|
||||||
// Supports 3-32 parametric EQ bands
|
// Supports 3-32 parametric EQ bands
|
||||||
|
|
||||||
import { isIos } from './platform-detection.js';
|
|
||||||
import { equalizerSettings, monoAudioSettings, binauralDspSettings } from './storage.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
|
// Generate frequency array for given number of bands using logarithmic spacing
|
||||||
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
function generateFrequencies(bandCount, minFreq = 20, maxFreq = 20000) {
|
||||||
|
|
@ -475,11 +473,6 @@ class AudioContextManager {
|
||||||
|
|
||||||
this.audio = audioElement;
|
this.audio = audioElement;
|
||||||
|
|
||||||
if (isIos) {
|
|
||||||
console.log('[AudioContext] Skipping Web Audio initialization on iOS for lock screen compatibility');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
|
||||||
|
|
@ -490,29 +483,10 @@ class AudioContextManager {
|
||||||
this.audioContext = new AudioContext();
|
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 = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 1024;
|
this.analyser.fftSize = 1024;
|
||||||
this.analyser.smoothingTimeConstant = 0.7;
|
this.analyser.smoothingTimeConstant = 0.7;
|
||||||
|
|
||||||
// Create binaural DSP processor
|
|
||||||
this.binauralDsp = new BinauralDSP(this.audioContext);
|
|
||||||
void this._loadBinauralSettings();
|
|
||||||
|
|
||||||
this._createEQ();
|
this._createEQ();
|
||||||
this._createGraphicEQ();
|
this._createGraphicEQ();
|
||||||
this._createMSNodes();
|
this._createMSNodes();
|
||||||
|
|
@ -528,15 +502,12 @@ class AudioContextManager {
|
||||||
|
|
||||||
this.monoMergerNode = this.audioContext.createChannelMerger(2);
|
this.monoMergerNode = this.audioContext.createChannelMerger(2);
|
||||||
|
|
||||||
this._connectGraph();
|
|
||||||
|
|
||||||
// Auto-recover from unexpected suspensions (e.g. background throttling)
|
// Auto-recover from unexpected suspensions (e.g. background throttling)
|
||||||
this.audioContext.addEventListener('statechange', () => {
|
this.audioContext.addEventListener('statechange', () => {
|
||||||
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
|
if (this.audioContext.state === 'interrupted' || this.audioContext.state === 'suspended') {
|
||||||
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
|
console.log(`[AudioContext] State changed to ${this.audioContext.state}, attempting resume`);
|
||||||
// Use a short delay to let the system settle before resuming
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.audioContext && this.audioContext.state !== 'running' && this.source) {
|
if (this.audioContext && this.audioContext.state !== 'running') {
|
||||||
this.audioContext.resume().catch((e) => {
|
this.audioContext.resume().catch((e) => {
|
||||||
console.warn('[AudioContext] Auto-resume failed:', e);
|
console.warn('[AudioContext] Auto-resume failed:', e);
|
||||||
});
|
});
|
||||||
|
|
@ -558,28 +529,7 @@ class AudioContextManager {
|
||||||
}
|
}
|
||||||
if (this.audio === audioElement) return;
|
if (this.audio === audioElement) return;
|
||||||
|
|
||||||
try {
|
this.audio = audioElement;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -588,179 +538,7 @@ class AudioContextManager {
|
||||||
* the new chain is wired up first, then the old connections are torn down.
|
* the new chain is wired up first, then the old connections are torn down.
|
||||||
*/
|
*/
|
||||||
_connectGraph() {
|
_connectGraph() {
|
||||||
if (!this.isInitialized || !this.source || !this.audioContext) return;
|
if (!this.isInitialized || !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 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
16
js/player.js
16
js/player.js
|
|
@ -294,21 +294,7 @@ export class Player {
|
||||||
|
|
||||||
const el = this.activeElement;
|
const el = this.activeElement;
|
||||||
|
|
||||||
// Apply to audio element and/or Web Audio graph
|
el.volume = Math.max(0, Math.min(1, effectiveVolume));
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyAudioEffects() {
|
applyAudioEffects() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue