Agent-Logs-Url: https://github.com/monochrome-music/monochrome/sessions/f6c0d1c5-5b9f-486a-a3c1-333a4bcb62c4 Co-authored-by: binimum <61615730+binimum@users.noreply.github.com>
2029 lines
77 KiB
JavaScript
2029 lines
77 KiB
JavaScript
//js/api.js
|
|
import {
|
|
RATE_LIMIT_ERROR_MESSAGE,
|
|
deriveTrackQuality,
|
|
delay,
|
|
isTrackUnavailable,
|
|
getExtensionFromBlob,
|
|
getTrackDiscNumber,
|
|
} from './utils.js';
|
|
import { preferDolbyAtmosSettings, trackDateSettings } from './storage.js';
|
|
import { APICache } from './cache.js';
|
|
import { DashDownloader } from './dash-downloader.ts';
|
|
import { HlsDownloader } from './hls-downloader.js';
|
|
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
|
import { DownloadProgress } from './progressEvents.js';
|
|
import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
|
|
import { readableStreamIterator } from './readableStreamIterator.js';
|
|
import { HiFiClient, TidalResponse } from './HiFi.ts';
|
|
import { isIos, isSafari } from './platform-detection.js';
|
|
import {
|
|
TrackAlbum,
|
|
EnrichedAlbum,
|
|
EnrichedTrack,
|
|
ReplayGain,
|
|
PlaybackInfo,
|
|
Track,
|
|
Album,
|
|
PreparedVideo,
|
|
PreparedTrack,
|
|
} from './container-classes.js';
|
|
|
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
|
export { resolveDownloadTotalBytes };
|
|
|
|
export class LosslessAPI {
|
|
constructor(settings) {
|
|
this.settings = settings;
|
|
this.cache = new APICache({
|
|
maxSize: 200,
|
|
ttl: 1000 * 60 * 30,
|
|
});
|
|
this.streamCache = new Map();
|
|
|
|
setInterval(
|
|
async () => {
|
|
await this.cache.clearExpired();
|
|
this.pruneStreamCache();
|
|
},
|
|
1000 * 60 * 5
|
|
);
|
|
}
|
|
|
|
pruneStreamCache() {
|
|
if (this.streamCache.size > 50) {
|
|
const entries = Array.from(this.streamCache.entries());
|
|
const toDelete = entries.slice(0, entries.length - 50);
|
|
toDelete.forEach(([key]) => this.streamCache.delete(key));
|
|
}
|
|
}
|
|
|
|
async fetchWithRetry(relativePath, options = {}) {
|
|
const type = options.type || 'api';
|
|
const instanceRoutes = [
|
|
'/track',
|
|
'/album/similar',
|
|
'/artist/similar',
|
|
'/video',
|
|
'/recommendations',
|
|
'/trackManifests',
|
|
];
|
|
|
|
if (window.allTidal == true || !instanceRoutes.some((route) => relativePath.startsWith(route))) {
|
|
try {
|
|
if (import.meta.env.DEV) {
|
|
console.log(relativePath);
|
|
}
|
|
|
|
return await HiFiClient.instance.query(relativePath);
|
|
} catch (err) {
|
|
if (options.directOnly) {
|
|
throw 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}`);
|
|
}
|
|
|
|
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(', ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (!source || typeof source !== 'object') return;
|
|
|
|
if (Array.isArray(source)) {
|
|
for (const e of source) {
|
|
const f = this.findSearchSection(e, key, visited);
|
|
if (f) return f;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (visited.has(source)) return;
|
|
visited.add(source);
|
|
|
|
if ('items' in source && Array.isArray(source.items)) return source;
|
|
|
|
if (key in source) {
|
|
const f = this.findSearchSection(source[key], key, visited);
|
|
if (f) return f;
|
|
}
|
|
|
|
for (const v of Object.values(source)) {
|
|
const f = this.findSearchSection(v, key, visited);
|
|
if (f) return f;
|
|
}
|
|
}
|
|
|
|
buildSearchResponse(section) {
|
|
const items = section?.items ?? [];
|
|
return {
|
|
items,
|
|
limit: section?.limit ?? items.length,
|
|
offset: section?.offset ?? 0,
|
|
totalNumberOfItems: section?.totalNumberOfItems ?? items.length,
|
|
};
|
|
}
|
|
|
|
normalizeSearchResponse(data, key) {
|
|
const section = this.findSearchSection(data, key, new Set());
|
|
return this.buildSearchResponse(section);
|
|
}
|
|
|
|
prepareTrack(track) {
|
|
let normalized = track;
|
|
|
|
if (track.type && typeof track.type === 'string') {
|
|
const lowType = track.type.toLowerCase();
|
|
if (lowType.includes('video')) {
|
|
normalized = { ...track, type: 'video' };
|
|
} else if (lowType.includes('track')) {
|
|
normalized = { ...track, type: 'track' };
|
|
} else {
|
|
normalized = { ...track, type: lowType };
|
|
}
|
|
}
|
|
|
|
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
|
|
normalized = { ...normalized, artist: track.artists[0] };
|
|
}
|
|
|
|
const derivedQuality = deriveTrackQuality(normalized);
|
|
if (derivedQuality && normalized.audioQuality !== derivedQuality) {
|
|
normalized = { ...normalized, audioQuality: derivedQuality };
|
|
}
|
|
|
|
normalized.isUnavailable = isTrackUnavailable(normalized);
|
|
|
|
return normalized.type == 'video' ? new PreparedVideo(normalized) : new PreparedTrack(normalized);
|
|
}
|
|
|
|
prepareAlbum(album) {
|
|
if (!album.artist && Array.isArray(album.artists) && album.artists.length > 0) {
|
|
return { ...album, artist: album.artists[0] };
|
|
}
|
|
return album;
|
|
}
|
|
|
|
preparePlaylist(playlist) {
|
|
return playlist;
|
|
}
|
|
|
|
prepareVideo(video) {
|
|
let normalized = { ...video, type: 'video' };
|
|
|
|
if (!video.artist && Array.isArray(video.artists) && video.artists.length > 0) {
|
|
normalized.artist = video.artists[0];
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
prepareArtist(artist) {
|
|
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
|
|
return { ...artist, type: artist.artistTypes[0] };
|
|
}
|
|
return artist;
|
|
}
|
|
|
|
async enrichTracksWithAlbumDates(tracks, maxRequests = 20) {
|
|
if (!trackDateSettings.useAlbumYear()) return tracks;
|
|
|
|
const albumIdsToFetch = [];
|
|
for (const track of tracks) {
|
|
if (!track.album?.releaseDate && track.album?.id && !albumIdsToFetch.includes(track.album.id)) {
|
|
albumIdsToFetch.push(track.album.id);
|
|
}
|
|
}
|
|
|
|
if (albumIdsToFetch.length === 0) return tracks;
|
|
|
|
// Limit the number of albums to fetch to prevent spamming
|
|
const limitedIds = albumIdsToFetch.slice(0, maxRequests);
|
|
if (albumIdsToFetch.length > maxRequests) {
|
|
console.warn(`[Enrich] Too many albums to fetch (${albumIdsToFetch.length}). limiting to ${maxRequests}.`);
|
|
}
|
|
|
|
const albumDateMap = new Map();
|
|
|
|
// Chunk requests to avoid spamming
|
|
const chunkSize = 5;
|
|
for (let i = 0; i < limitedIds.length; i += chunkSize) {
|
|
const chunk = limitedIds.slice(i, i + chunkSize);
|
|
const results = await Promise.allSettled(chunk.map((id) => this.getAlbum(id)));
|
|
|
|
for (let j = 0; j < results.length; j++) {
|
|
const result = results[j];
|
|
const id = chunk[j];
|
|
if (result.status === 'fulfilled' && result.value.album?.releaseDate) {
|
|
albumDateMap.set(id, result.value.album.releaseDate);
|
|
}
|
|
}
|
|
}
|
|
|
|
return tracks.map((track) => {
|
|
if (!track.album?.releaseDate && track.album?.id && albumDateMap.has(track.album.id)) {
|
|
return { ...track, album: { ...track.album, releaseDate: albumDateMap.get(track.album.id) } };
|
|
}
|
|
return track;
|
|
});
|
|
}
|
|
|
|
parseTrackLookup(data) {
|
|
const entries = Array.isArray(data) ? data : [data];
|
|
let track, info, originalTrackUrl;
|
|
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
if (!track && 'duration' in entry) {
|
|
track = entry;
|
|
continue;
|
|
}
|
|
|
|
if (!info && 'manifest' in entry) {
|
|
info = entry;
|
|
continue;
|
|
}
|
|
|
|
if (!originalTrackUrl && 'OriginalTrackUrl' in entry) {
|
|
const candidate = entry.OriginalTrackUrl;
|
|
if (typeof candidate === 'string') {
|
|
originalTrackUrl = candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!track || !info) {
|
|
throw new Error('Malformed track response');
|
|
}
|
|
|
|
return { track, info, originalTrackUrl };
|
|
}
|
|
|
|
extractStreamUrlFromManifest(manifest) {
|
|
if (!manifest) return null;
|
|
|
|
try {
|
|
let decoded;
|
|
if (typeof manifest === 'string') {
|
|
try {
|
|
decoded = atob(manifest);
|
|
} catch {
|
|
decoded = manifest;
|
|
}
|
|
} else if (typeof manifest === 'object') {
|
|
if (manifest.urls && Array.isArray(manifest.urls)) {
|
|
const priorityKeywords = ['flac', 'lossless', 'hi-res', 'high'];
|
|
const sortedUrls = [...manifest.urls].sort((a, b) => {
|
|
const aLow = a.toLowerCase();
|
|
const bLow = b.toLowerCase();
|
|
const aScore = priorityKeywords.findIndex((k) => aLow.includes(k));
|
|
const bScore = priorityKeywords.findIndex((k) => bLow.includes(k));
|
|
|
|
const finalAScore = aScore === -1 ? 999 : aScore;
|
|
const finalBScore = bScore === -1 ? 999 : bScore;
|
|
|
|
return finalAScore - finalBScore;
|
|
});
|
|
return sortedUrls[0];
|
|
}
|
|
if (manifest.urls?.[0]) return manifest.urls[0];
|
|
return null;
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
// Check if it's a DASH manifest (XML)
|
|
if (decoded.includes('<MPD')) {
|
|
const blob = new Blob([decoded], { type: 'application/dash+xml' });
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(decoded);
|
|
if (parsed?.urls && Array.isArray(parsed.urls)) {
|
|
const priorityKeywords = ['flac', 'lossless', 'hi-res', 'high'];
|
|
const sortedUrls = [...parsed.urls].sort((a, b) => {
|
|
const aLow = a.toLowerCase();
|
|
const bLow = b.toLowerCase();
|
|
const aScore = priorityKeywords.findIndex((k) => aLow.includes(k));
|
|
const bScore = priorityKeywords.findIndex((k) => bLow.includes(k));
|
|
const finalAScore = aScore === -1 ? 999 : aScore;
|
|
const finalBScore = bScore === -1 ? 999 : bScore;
|
|
return finalAScore - finalBScore;
|
|
});
|
|
return sortedUrls[0];
|
|
}
|
|
if (parsed?.urls?.[0]) {
|
|
return parsed.urls[0];
|
|
}
|
|
} catch {
|
|
const match = decoded.match(/https?:\/\/[\w\-.~:?#[@!$&'()*+,;=%/]+/);
|
|
return match ? match[0] : null;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to decode manifest:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
deduplicateAlbums(albums) {
|
|
const unique = new Map();
|
|
|
|
for (const album of albums) {
|
|
// Key based on title and numberOfTracks (excluding duration and explicit)
|
|
const key = JSON.stringify([album.title, album.numberOfTracks || 0]);
|
|
|
|
if (unique.has(key)) {
|
|
const existing = unique.get(key);
|
|
|
|
// Priority 1: Explicit
|
|
if (album.explicit && !existing.explicit) {
|
|
unique.set(key, album);
|
|
continue;
|
|
}
|
|
if (!album.explicit && existing.explicit) {
|
|
continue;
|
|
}
|
|
|
|
// Priority 2: More Metadata Tags (if explicit status is same)
|
|
const existingTags = existing.mediaMetadata?.tags?.length || 0;
|
|
const newTags = album.mediaMetadata?.tags?.length || 0;
|
|
|
|
if (newTags > existingTags) {
|
|
unique.set(key, album);
|
|
}
|
|
} else {
|
|
unique.set(key, album);
|
|
}
|
|
}
|
|
|
|
return Array.from(unique.values());
|
|
}
|
|
|
|
async search(query, options = {}) {
|
|
const cached = await this.cache.get('search_all', query);
|
|
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 data = await response.json();
|
|
|
|
// Check if backend returned an error or if this looks like individual fallback
|
|
if (data.error || (!data.tracks && !data.artists && !data.albums && (!data.data || !data.data.tracks))) {
|
|
throw new Error('Fallback to individual searches');
|
|
}
|
|
|
|
const extractSection = (key) => this.normalizeSearchResponse(data, key);
|
|
|
|
const tracksData = extractSection('tracks');
|
|
const artistsData = extractSection('artists');
|
|
const albumsData = extractSection('albums');
|
|
const playlistsData = extractSection('playlists');
|
|
const videosData = extractSection('videos');
|
|
|
|
const results = {
|
|
tracks: {
|
|
...tracksData,
|
|
items: tracksData.items.map((t) => this.prepareTrack(t)),
|
|
},
|
|
artists: {
|
|
...artistsData,
|
|
items: artistsData.items.map((a) => this.prepareArtist(a)),
|
|
},
|
|
albums: {
|
|
...albumsData,
|
|
items: albumsData.items.map((a) => this.prepareAlbum(a)),
|
|
},
|
|
playlists: playlistsData
|
|
? {
|
|
...playlistsData,
|
|
items: playlistsData.items.map((p) => this.preparePlaylist(p)),
|
|
}
|
|
: { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 },
|
|
videos: {
|
|
...videosData,
|
|
items: videosData.items.map((v) => this.prepareTrack(v)),
|
|
},
|
|
};
|
|
|
|
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
|
|
const [tracks, videos, artists, albums, playlists] = await Promise.all([
|
|
this.searchTracks(query, options).catch(() => ({ items: [] })),
|
|
this.searchVideos(query, options).catch(() => ({ items: [] })),
|
|
this.searchArtists(query, options).catch(() => ({ items: [] })),
|
|
this.searchAlbums(query, options).catch(() => ({ items: [] })),
|
|
this.searchPlaylists(query, options).catch(() => ({ items: [] })),
|
|
]);
|
|
|
|
return {
|
|
tracks,
|
|
videos,
|
|
artists,
|
|
albums,
|
|
playlists,
|
|
};
|
|
}
|
|
}
|
|
|
|
async searchTracks(query, options = {}) {
|
|
const cached = await this.cache.get('search_tracks', query);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/search/?s=${encodeURIComponent(query)}`, options);
|
|
const data = await response.json();
|
|
const normalized = this.normalizeSearchResponse(data, 'tracks');
|
|
const preparedTracks = normalized.items.map((t) => this.prepareTrack(t));
|
|
// Skip enrichment for search to be fast and lightweight
|
|
// const enrichedTracks = await this.enrichTracksWithAlbumDates(preparedTracks);
|
|
const result = {
|
|
...normalized,
|
|
items: preparedTracks,
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('search_tracks', query, result);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') throw error;
|
|
console.error('Track search failed:', error);
|
|
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
|
}
|
|
}
|
|
|
|
async searchArtists(query, options = {}) {
|
|
const cached = await this.cache.get('search_artists', query);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/search/?a=${encodeURIComponent(query)}`, options);
|
|
const data = await response.json();
|
|
const normalized = this.normalizeSearchResponse(data, 'artists');
|
|
const result = {
|
|
...normalized,
|
|
items: normalized.items.map((a) => this.prepareArtist(a)),
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('search_artists', query, result);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') throw error;
|
|
console.error('Artist search failed:', error);
|
|
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
|
}
|
|
}
|
|
|
|
async searchAlbums(query, options = {}) {
|
|
const cached = await this.cache.get('search_albums', query);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/search/?al=${encodeURIComponent(query)}`, options);
|
|
const data = await response.json();
|
|
const normalized = this.normalizeSearchResponse(data, 'albums');
|
|
const preparedItems = normalized.items.map((a) => this.prepareAlbum(a));
|
|
const result = {
|
|
...normalized,
|
|
items: this.deduplicateAlbums(preparedItems),
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('search_albums', query, result);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') throw error;
|
|
console.error('Album search failed:', error);
|
|
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
|
}
|
|
}
|
|
|
|
async searchPlaylists(query, options = {}) {
|
|
const cached = await this.cache.get('search_playlists', query);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/search/?p=${encodeURIComponent(query)}`, options);
|
|
const data = await response.json();
|
|
const normalized = this.normalizeSearchResponse(data, 'playlists');
|
|
const result = {
|
|
...normalized,
|
|
items: normalized.items.map((p) => this.preparePlaylist(p)),
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('search_playlists', query, result);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') throw error;
|
|
console.error('Playlist search failed:', error);
|
|
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
|
}
|
|
}
|
|
|
|
async searchVideos(query, options = {}) {
|
|
const cached = await this.cache.get('search_videos', query);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/search/?v=${encodeURIComponent(query)}`, {
|
|
...options,
|
|
});
|
|
const data = await response.json();
|
|
const normalized = this.normalizeSearchResponse(data, 'videos');
|
|
const result = {
|
|
...normalized,
|
|
items: normalized.items.map((v) => this.prepareVideo(v)),
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('search_videos', query, result);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') throw error;
|
|
console.error('Video search failed:', error);
|
|
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
|
}
|
|
}
|
|
|
|
async getVideo(id) {
|
|
const cached = await this.cache.get('video', id);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.fetchWithRetry(`/video/?id=${id}`, {
|
|
type: 'streaming',
|
|
});
|
|
const jsonResponse = await response.json();
|
|
|
|
const data = jsonResponse.data || jsonResponse;
|
|
|
|
const result = {
|
|
track: data,
|
|
info: data,
|
|
originalTrackUrl: data.OriginalTrackUrl || null,
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('video', id, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getAlbum(id) {
|
|
const cached = await this.cache.get('album', id);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.fetchWithRetry(`/album/?id=${id}`);
|
|
const jsonData = await response.json();
|
|
|
|
// Unwrap the data property if it exists
|
|
const data = jsonData.data || jsonData;
|
|
|
|
let album, tracksSection;
|
|
|
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
// Check for album metadata at root level
|
|
if ('numberOfTracks' in data || 'title' in data) {
|
|
album = this.prepareAlbum(data);
|
|
}
|
|
|
|
// Set tracksSection if items exist
|
|
if ('items' in data) {
|
|
tracksSection = data;
|
|
|
|
// If we still don't have album but have items with tracks, try to extract album from first track
|
|
if (!album && data.items && data.items.length > 0) {
|
|
const firstItem = data.items[0];
|
|
const track = firstItem.item || firstItem;
|
|
|
|
// Check if track has album property
|
|
if (track && track.album) {
|
|
album = this.prepareAlbum(track.album);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!album) throw new Error('Album not found');
|
|
|
|
// If album exists but has no artist, try to extract from tracks
|
|
if (!album.artist && tracksSection?.items && tracksSection.items.length > 0) {
|
|
const firstTrack = tracksSection.items[0];
|
|
const track = firstTrack.item || firstTrack;
|
|
if (track && track.artist) {
|
|
album = { ...album, artist: track.artist };
|
|
}
|
|
}
|
|
|
|
// If album exists but has no releaseDate, try to extract from tracks
|
|
if (!album.releaseDate && tracksSection?.items && tracksSection.items.length > 0) {
|
|
const firstTrack = tracksSection.items[0];
|
|
const track = firstTrack.item || firstTrack;
|
|
|
|
if (track) {
|
|
if (track.album && track.album.releaseDate) {
|
|
album = { ...album, releaseDate: track.album.releaseDate };
|
|
} else if (track.streamStartDate) {
|
|
album = { ...album, releaseDate: track.streamStartDate.split('T')[0] };
|
|
}
|
|
}
|
|
}
|
|
|
|
let tracks = (tracksSection?.items || []).map((i) => this.prepareTrack(i.item || i));
|
|
|
|
// Handle pagination if there are more tracks
|
|
if (album && album.numberOfTracks > tracks.length) {
|
|
let offset = tracks.length;
|
|
const SAFE_MAX_TRACKS = 10000;
|
|
|
|
while (tracks.length < album.numberOfTracks && tracks.length < SAFE_MAX_TRACKS) {
|
|
try {
|
|
const nextResponse = await this.fetchWithRetry(`/album/?id=${id}&offset=${offset}&limit=500`);
|
|
const nextJson = await nextResponse.json();
|
|
const nextData = nextJson.data || nextJson;
|
|
|
|
let nextItems = [];
|
|
|
|
if (nextData.items) {
|
|
nextItems = nextData.items;
|
|
} else if (Array.isArray(nextData)) {
|
|
for (const entry of nextData) {
|
|
if (entry && typeof entry === 'object' && 'items' in entry && Array.isArray(entry.items)) {
|
|
nextItems = entry.items;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!nextItems || nextItems.length === 0) break;
|
|
|
|
const preparedItems = nextItems.map((i) => this.prepareTrack(i.item || i));
|
|
if (preparedItems.length === 0) break;
|
|
|
|
// Safeguard: If API ignores offset, it returns the first page again.
|
|
// Check if the first new item matches the very first track we have.
|
|
if (tracks.length > 0 && preparedItems[0].id === tracks[0].id) {
|
|
break;
|
|
}
|
|
|
|
// Also check if the first new item matches the last track we have (overlap check)
|
|
if (tracks.length > 0 && preparedItems[0].id === tracks[tracks.length - 1].id) {
|
|
// If it's just one overlap, maybe we should skip it?
|
|
// But usually offset should be precise.
|
|
// If we see exact same id as first track, it's definitely a loop.
|
|
}
|
|
|
|
tracks = tracks.concat(preparedItems);
|
|
offset += preparedItems.length;
|
|
} catch (error) {
|
|
console.error(`Error fetching album tracks at offset ${offset}:`, error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enrich tracks with album releaseDate if available
|
|
if (album?.releaseDate) {
|
|
tracks = tracks.map((track) => {
|
|
if (track.album && !track.album.releaseDate) {
|
|
return { ...track, album: { ...track.album, releaseDate: album.releaseDate } };
|
|
}
|
|
return track;
|
|
});
|
|
}
|
|
|
|
tracks = tracks.map((t) => {
|
|
if (t.album) {
|
|
t.album = new TrackAlbum(t.album);
|
|
}
|
|
|
|
return new Track(t);
|
|
});
|
|
|
|
album = new Album(album);
|
|
|
|
const result = { album, tracks };
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('album', id, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getPlaylist(id) {
|
|
const cached = await this.cache.get('playlist', id);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.fetchWithRetry(`/playlist/?id=${id}`);
|
|
const jsonData = await response.json();
|
|
|
|
// Unwrap the data property if it exists
|
|
const data = jsonData.data || jsonData;
|
|
|
|
let playlist = null;
|
|
let tracksSection = null;
|
|
|
|
// Check for direct playlist property (common in v2 responses)
|
|
if (data.playlist) {
|
|
playlist = data.playlist;
|
|
}
|
|
|
|
// Check for direct items property
|
|
if (data.items) {
|
|
tracksSection = { items: data.items };
|
|
}
|
|
|
|
// Fallback: iterate if we still missed something or if structure is flat array
|
|
if (!playlist || !tracksSection) {
|
|
const entries = Array.isArray(data) ? data : [data];
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
if (
|
|
!playlist &&
|
|
('uuid' in entry || 'numberOfTracks' in entry || ('title' in entry && 'id' in entry))
|
|
) {
|
|
playlist = entry;
|
|
}
|
|
|
|
if (!tracksSection && 'items' in entry) {
|
|
tracksSection = entry;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback 2: If we have a list of entries but no explicit playlist object, try to find one that looks like a playlist
|
|
if (!playlist && Array.isArray(data)) {
|
|
for (const entry of data) {
|
|
if (entry && typeof entry === 'object' && ('uuid' in entry || 'numberOfTracks' in entry)) {
|
|
playlist = entry;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!playlist) throw new Error('Playlist not found');
|
|
|
|
let tracks = (tracksSection?.items || []).map((i) => this.prepareTrack(i.item || i));
|
|
|
|
// Handle pagination if there are more tracks
|
|
if (playlist.numberOfTracks > tracks.length) {
|
|
let offset = tracks.length;
|
|
const SAFE_MAX_TRACKS = 10000;
|
|
|
|
while (tracks.length < playlist.numberOfTracks && tracks.length < SAFE_MAX_TRACKS) {
|
|
try {
|
|
const nextResponse = await this.fetchWithRetry(`/playlist/?id=${id}&offset=${offset}`);
|
|
const nextJson = await nextResponse.json();
|
|
const nextData = nextJson.data || nextJson;
|
|
|
|
let nextItems = [];
|
|
|
|
if (nextData.items) {
|
|
nextItems = nextData.items;
|
|
} else if (Array.isArray(nextData)) {
|
|
for (const entry of nextData) {
|
|
if (entry && typeof entry === 'object' && 'items' in entry && Array.isArray(entry.items)) {
|
|
nextItems = entry.items;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!nextItems || nextItems.length === 0) break;
|
|
|
|
const preparedItems = nextItems.map((i) => this.prepareTrack(i.item || i));
|
|
if (preparedItems.length === 0) break;
|
|
|
|
// Safeguard: If API ignores offset, it returns the first page again.
|
|
// Check if the first new item matches the very first track we have.
|
|
if (tracks.length > 0 && preparedItems[0].id === tracks[0].id) {
|
|
break;
|
|
}
|
|
|
|
tracks = tracks.concat(preparedItems);
|
|
offset += preparedItems.length;
|
|
} catch (error) {
|
|
console.error(`Error fetching playlist tracks at offset ${offset}:`, error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enrich tracks with album release dates
|
|
// Removed to reduce API load. Playlists can be very large.
|
|
// tracks = await this.enrichTracksWithAlbumDates(tracks);
|
|
|
|
tracks = tracks.map((t) => {
|
|
if (t.album) {
|
|
t.album = new TrackAlbum(t.album);
|
|
}
|
|
|
|
return new Track(t);
|
|
});
|
|
|
|
const result = { playlist, tracks };
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('playlist', id, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getMix(id) {
|
|
const cached = await this.cache.get('mix', id);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.fetchWithRetry(`/mix/?id=${id}`, { type: 'api', minVersion: '2.3' });
|
|
const data = await response.json();
|
|
|
|
const mixData = data.mix;
|
|
const items = data.items || [];
|
|
|
|
if (!mixData) {
|
|
throw new Error('Mix metadata not found');
|
|
}
|
|
|
|
let tracks = items.map((i) => this.prepareTrack(i.item || i));
|
|
|
|
// Enrich tracks with album release dates
|
|
// Limited to reduce API load
|
|
tracks = await this.enrichTracksWithAlbumDates(tracks, 10);
|
|
|
|
tracks = tracks.map((t) => {
|
|
if (t.album) {
|
|
t.album = new TrackAlbum(t.album);
|
|
}
|
|
|
|
return new Track(t);
|
|
});
|
|
|
|
const mix = {
|
|
id: mixData.id,
|
|
title: mixData.title,
|
|
subTitle: mixData.subTitle,
|
|
description: mixData.description,
|
|
mixType: mixData.mixType,
|
|
cover: mixData.images?.LARGE?.url || mixData.images?.MEDIUM?.url || mixData.images?.SMALL?.url || null,
|
|
};
|
|
|
|
const result = { mix, tracks };
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('mix', id, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getArtistSocials(artistName) {
|
|
const cacheKey = `artist_socials_${artistName}`;
|
|
const cached = await this.cache.get('artist', cacheKey);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const searchUrl = `https://musicbrainz.org/ws/2/artist/?query=artist:${encodeURIComponent(artistName)}&fmt=json`;
|
|
const searchRes = await fetch(searchUrl, {
|
|
headers: { 'User-Agent': 'Monochrome/2.0.0 ( https://github.com/monochrome-music/monochrome )' },
|
|
});
|
|
const searchData = await searchRes.json();
|
|
|
|
if (!searchData.artists || searchData.artists.length === 0) return [];
|
|
|
|
const artist = searchData.artists[0];
|
|
const mbid = artist.id;
|
|
|
|
const detailsUrl = `https://musicbrainz.org/ws/2/artist/${mbid}?inc=url-rels&fmt=json`;
|
|
const detailsRes = await fetch(detailsUrl, {
|
|
headers: { 'User-Agent': 'Monochrome/2.0.0 ( https://github.com/monochrome-music/monochrome )' },
|
|
});
|
|
const detailsData = await detailsRes.json();
|
|
|
|
const links = [];
|
|
if (detailsData.relations) {
|
|
for (const rel of detailsData.relations) {
|
|
if (
|
|
[
|
|
'social network',
|
|
'streaming',
|
|
'official homepage',
|
|
'youtube',
|
|
'soundcloud',
|
|
'bandcamp',
|
|
].includes(rel.type)
|
|
) {
|
|
links.push({ type: rel.type, url: rel.url.resource });
|
|
}
|
|
}
|
|
}
|
|
|
|
await this.cache.set('artist', cacheKey, links);
|
|
return links;
|
|
} catch (e) {
|
|
console.warn('Failed to fetch artist socials:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getArtist(artistId, options = {}) {
|
|
const cacheKey = options.lightweight ? `artist_${artistId}_light` : `artist_${artistId}`;
|
|
if (!options.skipCache) {
|
|
const cached = await this.cache.get('artist', cacheKey);
|
|
if (cached) return cached;
|
|
}
|
|
|
|
const [primaryResponse, contentResponse] = await Promise.all([
|
|
this.fetchWithRetry(`/artist/?id=${artistId}`),
|
|
this.fetchWithRetry(`/artist/?f=${artistId}&skip_tracks=true`),
|
|
]);
|
|
|
|
const primaryJsonData = await primaryResponse.json();
|
|
|
|
// Unwrap data property if it exists, then unwrap artist property if it exists
|
|
let primaryData = primaryJsonData.data || primaryJsonData;
|
|
const rawArtist = primaryData.artist || (Array.isArray(primaryData) ? primaryData[0] : primaryData);
|
|
|
|
if (!rawArtist) throw new Error('Primary artist details not found.');
|
|
|
|
const artist = {
|
|
...this.prepareArtist(rawArtist),
|
|
picture: rawArtist.picture || null,
|
|
name: rawArtist.name || 'Unknown Artist',
|
|
};
|
|
|
|
const contentJsonData = await contentResponse.json();
|
|
// Unwrap data property if it exists
|
|
const contentData = contentJsonData.data || contentJsonData;
|
|
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
|
|
|
const albumMap = new Map();
|
|
const trackMap = new Map();
|
|
const videoMap = new Map();
|
|
|
|
const isTrack = (v) => v?.id && v.duration;
|
|
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
|
|
const isVideo = (v) => v?.id && !!v.type?.toLowerCase().includes('video');
|
|
|
|
const scan = (value, visited) => {
|
|
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
|
visited.add(value);
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item) => scan(item, visited));
|
|
return;
|
|
}
|
|
|
|
const item = value.item || value;
|
|
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
|
if (isTrack(item) && !isAlbum(item) && !isVideo(item)) {
|
|
trackMap.set(item.id, this.prepareTrack(item));
|
|
}
|
|
if (isVideo(item)) videoMap.set(item.id, this.prepareVideo(item));
|
|
|
|
Object.values(value).forEach((nested) => scan(nested, visited));
|
|
};
|
|
|
|
const visited = new Set();
|
|
entries.forEach((entry) => scan(entry, visited));
|
|
scan(primaryData, visited);
|
|
|
|
const matchesArtistId = (item) => {
|
|
const candidateIds = [
|
|
item.artist?.id,
|
|
...(Array.isArray(item.artists) ? item.artists.map((a) => a.id) : []),
|
|
].filter((id) => id != null);
|
|
return candidateIds.some((id) => Number(id) === Number(artistId));
|
|
};
|
|
|
|
if (!options.lightweight) {
|
|
try {
|
|
const videoSearch = await this.searchVideos(artist.name);
|
|
if (videoSearch && videoSearch.items) {
|
|
for (const item of videoSearch.items) {
|
|
if (matchesArtistId(item) && !videoMap.has(item.id)) {
|
|
videoMap.set(item.id, item);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to fetch additional videos via search:', e);
|
|
}
|
|
}
|
|
|
|
const rawReleases = Array.from(albumMap.values()).filter(matchesArtistId);
|
|
const allReleases = this.deduplicateAlbums(rawReleases).sort(
|
|
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
|
);
|
|
|
|
const eps = allReleases.filter((a) => a.type === 'EP' || a.type === 'SINGLE');
|
|
const albums = allReleases.filter((a) => !eps.includes(a));
|
|
|
|
const topTracks = Array.from(trackMap.values())
|
|
.filter(matchesArtistId)
|
|
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
|
.slice(0, 15);
|
|
|
|
const videos = Array.from(videoMap.values()).sort(
|
|
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
|
);
|
|
|
|
// Enrich tracks with album release dates
|
|
const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks);
|
|
|
|
const result = { ...artist, albums, eps, tracks, videos };
|
|
|
|
if (!(primaryResponse instanceof TidalResponse) && !(contentResponse instanceof TidalResponse)) {
|
|
await this.cache.set('artist', cacheKey, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getArtistTopTracks(artistId, options = {}) {
|
|
const offset = options.offset || 0;
|
|
const limit = options.limit || 15;
|
|
console.log('[getArtistTopTracks] Called:', { artistId, offset, limit, options });
|
|
|
|
const cacheKey = `artist_tracks_${artistId}_${offset}_${limit}`;
|
|
if (!options.skipCache) {
|
|
const cached = await this.cache.get('artist', cacheKey);
|
|
if (cached) return cached;
|
|
}
|
|
|
|
try {
|
|
// Use f parameter with skip_tracks=true to get toptracks from the dedicated endpoint
|
|
const response = await this.fetchWithRetry(
|
|
`/artist/?f=${artistId}&skip_tracks=true&offset=${offset}&limit=${limit}`
|
|
);
|
|
const jsonData = await response.json();
|
|
|
|
let data = jsonData.data || jsonData;
|
|
console.log(
|
|
'[getArtistTopTracks] Raw response data keys:',
|
|
Object.keys(data),
|
|
'tracks:',
|
|
data.tracks?.length
|
|
);
|
|
|
|
// Extract tracks from the response
|
|
let tracks = [];
|
|
|
|
// Check for tracks array directly (from toptracks endpoint)
|
|
if (Array.isArray(data.tracks)) {
|
|
tracks = data.tracks;
|
|
}
|
|
|
|
// Also scan for tracks in the data structure
|
|
if (tracks.length === 0) {
|
|
const trackMap = new Map();
|
|
const isTrack = (v) => v?.id && v.duration;
|
|
|
|
const scan = (value, visited) => {
|
|
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
|
visited.add(value);
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item) => scan(item, visited));
|
|
return;
|
|
}
|
|
|
|
const item = value.item || value;
|
|
if (isTrack(item)) {
|
|
trackMap.set(item.id, this.prepareTrack(item));
|
|
}
|
|
|
|
Object.values(value).forEach((nested) => scan(nested, visited));
|
|
};
|
|
|
|
const visited = new Set();
|
|
scan(data, visited);
|
|
tracks = Array.from(trackMap.values());
|
|
}
|
|
|
|
tracks = tracks.map((t) => this.prepareTrack(t)).sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
|
|
tracks = await this.enrichTracksWithAlbumDates(tracks);
|
|
|
|
// Safeguard: If API ignores offset, it returns the same first tracks
|
|
const hasMore = tracks.length === limit && (offset === 0 || tracks[0]?.id !== options.firstTrackId);
|
|
const result = {
|
|
tracks,
|
|
offset,
|
|
limit,
|
|
hasMore,
|
|
};
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('artist', cacheKey, result);
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
console.warn('Failed to fetch artist top tracks:', e);
|
|
return { tracks: [], offset, limit, hasMore: false };
|
|
}
|
|
}
|
|
|
|
async getSimilarArtists(artistId) {
|
|
const cached = await this.cache.get('similar_artists', artistId);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/artist/similar/?id=${artistId}`, {
|
|
type: 'api',
|
|
minVersion: '2.3',
|
|
});
|
|
const data = await response.json();
|
|
|
|
// Handle various response structures
|
|
const items = data.artists || data.items || data.data || (Array.isArray(data) ? data : []);
|
|
|
|
const result = items.map((artist) => this.prepareArtist(artist));
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('similar_artists', artistId, result);
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
console.warn('Failed to fetch similar artists:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getArtistBiography(artistId) {
|
|
const cacheKey = `artist_bio_v1_${artistId}`;
|
|
const cached = await this.cache.get('artist', cacheKey);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await HiFiClient.instance.query(`/artist/bio/?id=${artistId}`);
|
|
|
|
if (response.ok) {
|
|
const { data } = await response.json();
|
|
if (data && data.text) {
|
|
const bio = {
|
|
text: data.text,
|
|
source: data.source || 'Tidal',
|
|
};
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('artist', cacheKey, bio);
|
|
}
|
|
return bio;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to fetch Tidal biography:', e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async getSimilarAlbums(albumId) {
|
|
const cached = await this.cache.get('similar_albums', albumId);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/album/similar/?id=${albumId}`, {
|
|
type: 'api',
|
|
minVersion: '2.3',
|
|
});
|
|
const data = await response.json();
|
|
|
|
const items = data.items || data.albums || data.data || (Array.isArray(data) ? data : []);
|
|
|
|
const result = items.map((album) => this.prepareAlbum(album));
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('similar_albums', albumId, result);
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
console.warn('Failed to fetch similar albums:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getRecommendedTracksForPlaylist(tracks, limit = 20, options = {}) {
|
|
const artistMap = new Map();
|
|
|
|
// Check if tracks already have artist info (some might)
|
|
for (const track of tracks) {
|
|
if (track.artist && track.artist.id) {
|
|
artistMap.set(track.artist.id, track.artist);
|
|
}
|
|
if (track.artists && Array.isArray(track.artists)) {
|
|
for (const artist of track.artists) {
|
|
if (artist.id) {
|
|
artistMap.set(artist.id, artist);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (artistMap.size < 3) {
|
|
console.log('Not enough artists from stored data, trying search approach...');
|
|
|
|
for (const track of tracks.slice(0, 5)) {
|
|
try {
|
|
// Search for the track to get full metadata
|
|
const searchQuery = `"${track.title}" ${track.artist?.name || ''}`.trim();
|
|
const searchResult = await this.searchTracks(searchQuery, { signal: AbortSignal.timeout(5000) });
|
|
|
|
if (searchResult.items && searchResult.items.length > 0) {
|
|
const foundTrack = searchResult.items[0];
|
|
if (foundTrack.artist && foundTrack.artist.id) {
|
|
artistMap.set(foundTrack.artist.id, foundTrack.artist);
|
|
}
|
|
if (foundTrack.artists && Array.isArray(foundTrack.artists)) {
|
|
for (const artist of foundTrack.artists) {
|
|
if (artist.id) {
|
|
artistMap.set(artist.id, artist);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Search failed for track "${track.title}":`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
const artists = Array.from(artistMap.values());
|
|
console.log(`Found ${artists.length} unique artists from ${tracks.length} tracks`);
|
|
|
|
if (artists.length === 0) {
|
|
console.log('No artists found, cannot generate recommendations');
|
|
return [];
|
|
}
|
|
|
|
const recommendedTracks = [];
|
|
const seenTrackIds = new Set(tracks.map((t) => t.id));
|
|
|
|
const shuffledArtists = [...artists].sort(() => Math.random() - 0.5);
|
|
const artistsToProcess = shuffledArtists.slice(0, Math.min(15, shuffledArtists.length));
|
|
|
|
const artistPromises = artistsToProcess.map(async (artist) => {
|
|
try {
|
|
const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh });
|
|
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
|
|
const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id));
|
|
|
|
const newTracks = options.knownTrackIds
|
|
? availableTracks.filter((t) => !options.knownTrackIds.has(t.id))
|
|
: availableTracks;
|
|
const knownTracks = options.knownTrackIds
|
|
? availableTracks.filter((t) => options.knownTrackIds.has(t.id))
|
|
: [];
|
|
|
|
const shuffledNew = [...newTracks].sort(() => Math.random() - 0.5);
|
|
const shuffledKnown = [...knownTracks].sort(() => Math.random() - 0.5);
|
|
|
|
const combined = [...shuffledNew, ...shuffledKnown];
|
|
return combined.slice(0, 2);
|
|
} else {
|
|
console.warn(`No tracks found for artist ${artist.name}`);
|
|
return [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to get tracks for artist ${artist.name}:`, e);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(artistPromises);
|
|
results.forEach((tracks) => {
|
|
for (const t of tracks) {
|
|
if (!seenTrackIds.has(t.id)) {
|
|
seenTrackIds.add(t.id);
|
|
recommendedTracks.push(t);
|
|
}
|
|
}
|
|
});
|
|
|
|
const shuffled = recommendedTracks.sort(() => 0.5 - Math.random());
|
|
return shuffled.slice(0, limit);
|
|
}
|
|
|
|
normalizeTrackResponse(apiResponse) {
|
|
if (!apiResponse || typeof apiResponse !== 'object') {
|
|
return apiResponse;
|
|
}
|
|
|
|
// unwrap { version, data } if present
|
|
const raw = apiResponse.data ?? apiResponse;
|
|
|
|
// fabricate the track object expected by parseTrackLookup
|
|
const trackStub = {
|
|
duration: raw.duration ?? 0,
|
|
id: raw.trackId ?? null,
|
|
};
|
|
|
|
// return exactly what parseTrackLookup expects
|
|
return [trackStub, raw];
|
|
}
|
|
|
|
async getTrackMetadata(id) {
|
|
const cacheKey = `meta_${id}`;
|
|
const cached = await this.cache.get('track', cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.fetchWithRetry(`/info/?id=${id}`, { type: 'api' });
|
|
const json = await response.json();
|
|
const data = json.data || json;
|
|
|
|
let track;
|
|
const items = Array.isArray(data) ? data : [data];
|
|
const found = items.find((i) => i.id == id || (i.item && i.item.id == id));
|
|
|
|
if (found) {
|
|
track = this.prepareTrack(found.item || found);
|
|
await this.cache.set('track', cacheKey, track);
|
|
return track;
|
|
}
|
|
|
|
throw new Error('Track metadata not found');
|
|
}
|
|
|
|
async getTrackRecommendations(id) {
|
|
const cached = await this.cache.get('recommendations', id);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await this.fetchWithRetry(`/recommendations/?id=${id}`, {
|
|
type: 'api',
|
|
minVersion: '2.4',
|
|
});
|
|
const json = await response.json();
|
|
const data = json.data || json;
|
|
|
|
const items = data.items || [];
|
|
const tracks = items.map((item) => this.prepareTrack(item.track || item));
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('recommendations', id, tracks);
|
|
}
|
|
return tracks;
|
|
} catch (error) {
|
|
console.error('Failed to fetch recommendations:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getTrack(id, quality = 'HI_RES_LOSSLESS') {
|
|
const cacheKey = `${id}_${quality}`;
|
|
const cached = await this.cache.get('track', cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.fetchWithRetry(`/track/?id=${id}&quality=${quality}`, { type: 'streaming' });
|
|
const jsonResponse = await response.json();
|
|
const result = this.parseTrackLookup(this.normalizeTrackResponse(jsonResponse));
|
|
|
|
if (!(response instanceof TidalResponse)) {
|
|
await this.cache.set('track', cacheKey, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS', download = false) {
|
|
const cacheKey = `stream_info_${id}_${quality}`;
|
|
|
|
if (this.streamCache.has(cacheKey)) {
|
|
return this.streamCache.get(cacheKey);
|
|
}
|
|
|
|
let streamUrl;
|
|
let manifestRgInfo = null;
|
|
let isUsingManifestEndpoint = false;
|
|
|
|
try {
|
|
const manifestType = isIos || isSafari ? 'HLS' : 'MPEG_DASH';
|
|
const isApple = isIos || isSafari;
|
|
|
|
let canPlayAtmos = false;
|
|
try {
|
|
if (window.MediaSource && typeof window.MediaSource.isTypeSupported === 'function') {
|
|
canPlayAtmos =
|
|
MediaSource.isTypeSupported('audio/mp4; codecs="ec-3"') ||
|
|
MediaSource.isTypeSupported('audio/mp4; codecs="eac3"');
|
|
}
|
|
if (!canPlayAtmos && typeof document !== 'undefined') {
|
|
const a = document.createElement('audio');
|
|
canPlayAtmos = !!(
|
|
a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"')
|
|
);
|
|
}
|
|
} catch {
|
|
// Atmos codec probe - intentionally swallowed; canPlayAtmos stays false
|
|
}
|
|
|
|
const paramsArray = [];
|
|
|
|
if (quality === 'LOW') {
|
|
paramsArray.push(['formats', 'HEAACV1']);
|
|
} else if (quality === 'HIGH') {
|
|
if (!isApple) paramsArray.push(['formats', 'HEAACV1']);
|
|
paramsArray.push(['formats', 'AACLC']);
|
|
} else if (quality === 'LOSSLESS') {
|
|
// For Safari to not auto-downgrade to AAC, only request FLAC
|
|
paramsArray.push(['formats', 'HEAACV1']);
|
|
paramsArray.push(['formats', 'AACLC']);
|
|
paramsArray.push(['formats', 'FLAC']);
|
|
} else if (quality === 'HI_RES_LOSSLESS') {
|
|
paramsArray.push(['formats', 'HEAACV1']);
|
|
paramsArray.push(['formats', 'AACLC']);
|
|
paramsArray.push(['formats', 'FLAC_HIRES']);
|
|
paramsArray.push(['formats', 'FLAC']);
|
|
} else if (quality === 'DOLBY_ATMOS' && (canPlayAtmos || download)) {
|
|
paramsArray.push(['formats', 'EAC3_JOC']);
|
|
} else {
|
|
// Default fallback or "auto" behavior
|
|
paramsArray.push(['formats', 'HEAACV1']);
|
|
paramsArray.push(['formats', 'AACLC']);
|
|
paramsArray.push(['formats', 'FLAC']);
|
|
paramsArray.push(['formats', 'FLAC_HIRES']);
|
|
if (canPlayAtmos || download) {
|
|
paramsArray.push(['formats', 'EAC3_JOC']);
|
|
}
|
|
}
|
|
|
|
paramsArray.push(
|
|
['adaptive', 'true'],
|
|
['manifestType', manifestType],
|
|
['uriScheme', 'HTTPS'],
|
|
['usage', 'PLAYBACK']
|
|
);
|
|
|
|
const params = new URLSearchParams(paramsArray);
|
|
|
|
const response = await this.fetchWithRetry(`/trackManifests/?id=${id}&${params.toString()}`, {
|
|
type: 'streaming',
|
|
minVersion: '2.7',
|
|
});
|
|
const jsonResponse = await response.json();
|
|
const url = jsonResponse?.data?.data?.attributes?.uri;
|
|
if (url) {
|
|
streamUrl = url;
|
|
manifestRgInfo = {
|
|
trackReplayGain: jsonResponse?.data?.data?.attributes?.trackAudioNormalizationData?.replayGain,
|
|
trackPeakAmplitude:
|
|
jsonResponse?.data?.data?.attributes?.trackAudioNormalizationData?.peakAmplitude,
|
|
albumReplayGain: jsonResponse?.data?.data?.attributes?.albumAudioNormalizationData?.replayGain,
|
|
albumPeakAmplitude:
|
|
jsonResponse?.data?.data?.attributes?.albumAudioNormalizationData?.peakAmplitude,
|
|
};
|
|
isUsingManifestEndpoint = true;
|
|
} else {
|
|
throw new Error('No URI in trackManifests response');
|
|
}
|
|
} catch (_err) {
|
|
// Fallback to /track endpoint
|
|
}
|
|
|
|
if (!isUsingManifestEndpoint) {
|
|
const lookup = await this.getTrack(id, quality);
|
|
|
|
if (lookup.originalTrackUrl) {
|
|
streamUrl = lookup.originalTrackUrl;
|
|
} else {
|
|
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
|
|
if (!streamUrl) {
|
|
throw new Error('Could not resolve stream URL');
|
|
}
|
|
}
|
|
if (lookup.info) {
|
|
manifestRgInfo = {
|
|
trackReplayGain: lookup.info.trackReplayGain || lookup.info.replayGain,
|
|
trackPeakAmplitude: lookup.info.trackPeakAmplitude || lookup.info.peakAmplitude,
|
|
albumReplayGain: lookup.info.albumReplayGain,
|
|
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
|
};
|
|
}
|
|
}
|
|
|
|
const result = { url: streamUrl, rgInfo: manifestRgInfo };
|
|
this.streamCache.set(cacheKey, result);
|
|
|
|
return result;
|
|
}
|
|
|
|
async getVideoStreamUrl(id) {
|
|
const cacheKey = `video_stream_${id}`;
|
|
|
|
if (this.streamCache.has(cacheKey)) {
|
|
return this.streamCache.get(cacheKey);
|
|
}
|
|
|
|
const lookup = await this.getVideo(id);
|
|
|
|
let streamUrl;
|
|
|
|
const findValue = (obj, key) => {
|
|
if (!obj || typeof obj !== 'object') return null;
|
|
if (obj[key]) return obj[key];
|
|
for (const v of Object.values(obj)) {
|
|
if (v && typeof v === 'object') {
|
|
const f = findValue(v, key);
|
|
if (f) return f;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const manifest = findValue(lookup, 'manifest') || findValue(lookup, 'Manifest');
|
|
if (manifest) {
|
|
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
|
}
|
|
|
|
if (!streamUrl) {
|
|
streamUrl =
|
|
findValue(lookup, 'OriginalTrackUrl') ||
|
|
findValue(lookup, 'originalTrackUrl') ||
|
|
findValue(lookup, 'url') ||
|
|
findValue(lookup, 'streamUrl') ||
|
|
findValue(lookup, 'manifestUrl');
|
|
}
|
|
|
|
if (!streamUrl) {
|
|
throw new Error(`Could not resolve video stream URL for ID: ${id}`);
|
|
}
|
|
|
|
if (!(lookup instanceof TidalResponse)) {
|
|
this.streamCache.set(cacheKey, streamUrl);
|
|
}
|
|
return streamUrl;
|
|
}
|
|
|
|
async enrichTrack(input, { downloadQuality = 'HI_RES_LOSSLESS' }) {
|
|
if (downloadQuality == 'DOLBY_ATMOS' && !input?.audioModes?.includes('DOLBY_ATMOS')) {
|
|
downloadQuality = 'LOSSLESS';
|
|
}
|
|
|
|
const id = input?.id || input;
|
|
const track = typeof input === 'object' ? input : await this.getTrack(id, downloadQuality);
|
|
const isVideo = track?.type?.toLowerCase().includes('video');
|
|
downloadQuality = isCustomFormat(downloadQuality) ? 'LOSSLESS' : downloadQuality;
|
|
|
|
let lookup;
|
|
if (isVideo) {
|
|
lookup = await this.getVideo(id);
|
|
} else {
|
|
lookup = new PlaybackInfo(await this.getTrack(id, downloadQuality));
|
|
}
|
|
|
|
if (input instanceof EnrichedTrack) {
|
|
return {
|
|
lookup,
|
|
enrichedTrack: input,
|
|
isVideo,
|
|
};
|
|
}
|
|
|
|
const enrichedTrack = { ...this.prepareTrack(track) };
|
|
if (lookup.info) {
|
|
enrichedTrack.replayGain = new ReplayGain({
|
|
trackReplayGain: lookup.info.trackReplayGain,
|
|
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
|
albumReplayGain: lookup.info.albumReplayGain,
|
|
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
|
});
|
|
}
|
|
|
|
if (track.album?.id && (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)) {
|
|
try {
|
|
const albumData = await this.getAlbum(track.album.id);
|
|
enrichedTrack.album = new EnrichedAlbum({
|
|
...albumData.album,
|
|
...enrichedTrack.album,
|
|
});
|
|
|
|
if (albumData.tracks?.length > 0) {
|
|
const discTrackCounts = new Map();
|
|
let maxDiscNumber = 0;
|
|
for (const t of albumData.tracks) {
|
|
const dn = getTrackDiscNumber(t);
|
|
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
|
|
if (dn > maxDiscNumber) maxDiscNumber = dn;
|
|
}
|
|
const totalDiscs = maxDiscNumber || 1;
|
|
const discNumber = getTrackDiscNumber(track);
|
|
enrichedTrack.album = new EnrichedAlbum({
|
|
...(enrichedTrack.album || {}),
|
|
|
|
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
|
numberOfTracksOnDisc: track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber),
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to fetch album for disc info:', e);
|
|
}
|
|
}
|
|
|
|
if (!(enrichedTrack.album instanceof EnrichedAlbum)) {
|
|
enrichedTrack.album = new TrackAlbum(enrichedTrack.album);
|
|
}
|
|
|
|
return { lookup, enrichedTrack: new EnrichedTrack(enrichedTrack), isVideo };
|
|
}
|
|
|
|
/**
|
|
* Downloads a track or video from TIDAL in the specified quality.
|
|
*
|
|
* Handles multiple stream types (DASH, HLS, and direct HTTP), applies post-processing
|
|
* for audio tracks, adds metadata, and optionally triggers a browser download.
|
|
*
|
|
* @async
|
|
* @param {string} id - The TIDAL track or video ID
|
|
* @param {string} [quality='HI_RES_LOSSLESS'] - The desired audio quality (e.g., 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'NORMAL').
|
|
* Custom FFMPEG formats are transcoded from LOSSLESS.
|
|
* @param {string} filename - The filename to save the downloaded content as
|
|
* @param {Object} [options={}] - Additional download options
|
|
* @param {Function} [options.onProgress] - Callback function for progress updates with signature:
|
|
* `(progressEvent) => void`
|
|
* @param {Object} [options.track] - Track metadata object to attach to the audio file
|
|
* @param {boolean} [options.calculateDashBytes=true] - Whether to calculate total bytes for DASH streams
|
|
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the download
|
|
* @param {boolean} [options.triggerDownload=true] - Whether to trigger browser download after completion
|
|
*
|
|
* @returns {Promise<Blob>} The downloaded content as a Blob object
|
|
*
|
|
* @throws {Error} If stream URL cannot be resolved, manifest is missing, or download fails
|
|
* @throws {AbortError} If the download is aborted via the signal
|
|
* @throws {FfmpegError} If audio transcoding fails
|
|
*/
|
|
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
|
// Load ffmpeg in the background.
|
|
loadFfmpeg().catch(console.error);
|
|
const metadataModule = await import('./metadata.js');
|
|
const { prefetchMetadataObjects, addMetadataToAudio } = metadataModule;
|
|
|
|
const { onProgress, track, calculateDashBytes = true } = options;
|
|
const prefetchPromises = prefetchMetadataObjects(track, this);
|
|
|
|
try {
|
|
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
|
let downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
|
|
|
const { lookup, enrichedTrack, isVideo } = await this.enrichTrack(track, { downloadQuality });
|
|
|
|
let postProcessingQuality = lookup.info?.audioQuality ?? null;
|
|
let streamUrl;
|
|
let blob;
|
|
|
|
if (lookup.originalTrackUrl) {
|
|
streamUrl = lookup.originalTrackUrl;
|
|
} else {
|
|
const findValue = (obj, key) => {
|
|
if (!obj || typeof obj !== 'object') return null;
|
|
if (obj[key]) return obj[key];
|
|
for (const v of Object.values(obj)) {
|
|
if (v && typeof v === 'object') {
|
|
const f = findValue(v, key);
|
|
if (f) return f;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const manifest = isVideo
|
|
? findValue(lookup, 'manifest') || findValue(lookup, 'Manifest')
|
|
: lookup.info?.manifest;
|
|
|
|
if (!manifest) {
|
|
throw new Error('Could not resolve manifest');
|
|
}
|
|
|
|
if (preferDolbyAtmosSettings.isEnabled() && track.audioModes?.includes('DOLBY_ATMOS')) {
|
|
try {
|
|
const stream = await this.getStreamUrl(id, 'DOLBY_ATMOS', true);
|
|
const manifest = await fetch(stream.url, { signal: options.signal });
|
|
const manifestText = await manifest.text();
|
|
streamUrl = this.extractStreamUrlFromManifest(btoa(manifestText));
|
|
|
|
if (streamUrl) {
|
|
postProcessingQuality = 'DOLBY_ATMOS';
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to extract Dolby Atmos stream URL:', err);
|
|
}
|
|
}
|
|
|
|
if (!streamUrl) {
|
|
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
|
if (!streamUrl) {
|
|
throw new Error('Could not resolve stream URL');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle DASH streams (blob URLs)
|
|
if (streamUrl.startsWith('blob:')) {
|
|
try {
|
|
const downloader = new DashDownloader();
|
|
blob = await downloader.downloadDashStream(streamUrl, {
|
|
signal: options.signal,
|
|
onProgress,
|
|
calculateDashBytes: calculateDashBytes ?? true,
|
|
});
|
|
} catch (dashError) {
|
|
console.error('DASH download failed:', dashError);
|
|
if (isVideo) throw dashError;
|
|
|
|
// Fallback to LOSSLESS if DASH fails, but not if we're already downloading LOSSLESS
|
|
if (downloadQuality !== 'LOSSLESS') {
|
|
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
|
return this.downloadTrack(id, 'LOSSLESS', filename, options);
|
|
}
|
|
throw dashError;
|
|
}
|
|
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
|
try {
|
|
const downloader = new HlsDownloader();
|
|
blob = await downloader.downloadHlsStream(streamUrl, {
|
|
signal: options.signal,
|
|
onProgress,
|
|
});
|
|
} catch (hlsError) {
|
|
console.error('HLS download failed:', hlsError);
|
|
throw hlsError;
|
|
}
|
|
} else {
|
|
// Try HEAD first to get Content-Length when GET uses chunked encoding (fixes #278)
|
|
let headContentLength = null;
|
|
try {
|
|
const headResponse = await fetch(streamUrl, {
|
|
method: 'HEAD',
|
|
cache: 'no-store',
|
|
signal: options.signal,
|
|
});
|
|
if (headResponse.ok) {
|
|
const cl = headResponse.headers.get('Content-Length');
|
|
if (cl) headContentLength = parseInt(cl, 10);
|
|
}
|
|
} catch (_) {
|
|
/* ignore HEAD failure; proceed with GET */
|
|
}
|
|
|
|
const response = await fetch(streamUrl, {
|
|
cache: 'no-store',
|
|
signal: options.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Fetch failed: ${response.status}`);
|
|
}
|
|
|
|
const contentLengthHeader = response.headers.get('Content-Length');
|
|
const totalBytes = resolveDownloadTotalBytes(contentLengthHeader, headContentLength);
|
|
|
|
let receivedBytes = 0;
|
|
|
|
if (response.body) {
|
|
const chunks = [];
|
|
|
|
for await (const chunk of readableStreamIterator(response.body)) {
|
|
chunks.push(chunk);
|
|
receivedBytes += chunk.byteLength;
|
|
|
|
onProgress?.(new DownloadProgress(receivedBytes, totalBytes || undefined));
|
|
}
|
|
|
|
const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
|
|
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
|
|
} else {
|
|
onProgress?.(new DownloadProgress(0, undefined));
|
|
blob = await response.blob();
|
|
onProgress?.(new DownloadProgress(blob.size, blob.size));
|
|
}
|
|
}
|
|
|
|
if (!isVideo) {
|
|
blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal, postProcessingQuality);
|
|
}
|
|
|
|
// Add metadata if track information is provided
|
|
if (track) {
|
|
onProgress?.({
|
|
stage: 'processing',
|
|
message: 'Adding metadata...',
|
|
});
|
|
|
|
onProgress?.(new DownloadProgress('Adding metadata'));
|
|
try {
|
|
if (isVideo) {
|
|
blob = new File(
|
|
[
|
|
await ffmpeg(blob, {
|
|
args: ['-c', 'copy'],
|
|
outputName: 'output.mp4',
|
|
outputMime: 'video/mp4',
|
|
onProgress,
|
|
signal: options.signal,
|
|
}),
|
|
],
|
|
'output.mp4',
|
|
{ type: 'video/mp4' }
|
|
);
|
|
}
|
|
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
if (options.triggerDownload ?? true) {
|
|
// Detect actual format and fix filename extension if needed
|
|
const detectedExtension = await getExtensionFromBlob(blob);
|
|
let finalFilename = filename;
|
|
|
|
// Replace extension if it doesn't match detected format
|
|
const currentExtension = filename.split('.').pop()?.toLowerCase();
|
|
if (currentExtension && currentExtension !== detectedExtension) {
|
|
finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`);
|
|
}
|
|
|
|
triggerDownload(blob, finalFilename);
|
|
}
|
|
|
|
return blob;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
throw error;
|
|
}
|
|
console.error('Download failed:', error);
|
|
if (error instanceof FfmpegError || error.code === 'MP3_ENCODING_FAILED') {
|
|
throw error;
|
|
}
|
|
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
|
throw error;
|
|
}
|
|
throw new Error('Download failed. The stream may require a proxy.');
|
|
}
|
|
}
|
|
|
|
getCoverUrl(id, size = '320') {
|
|
if (!id) {
|
|
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
|
}
|
|
|
|
if (typeof id === 'string' && (id.startsWith('http') || id.startsWith('blob:') || id.startsWith('assets/'))) {
|
|
return id;
|
|
}
|
|
|
|
const formattedId = String(id).replace(/-/g, '/');
|
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
|
}
|
|
|
|
getCoverSrcset(id) {
|
|
if (
|
|
!id ||
|
|
(typeof id === 'string' && (id.startsWith('http') || id.startsWith('blob:') || id.startsWith('assets/')))
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
const formattedId = String(id).replace(/-/g, '/');
|
|
const baseUrl = `https://resources.tidal.com/images/${formattedId}`;
|
|
return `${baseUrl}/160x160.jpg 160w, ${baseUrl}/320x320.jpg 320w, ${baseUrl}/640x640.jpg 640w`;
|
|
}
|
|
|
|
getArtistPictureUrl(id, size = '320') {
|
|
if (!id) {
|
|
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
|
}
|
|
|
|
if (typeof id === 'string' && (id.startsWith('blob:') || id.startsWith('assets/'))) {
|
|
return id;
|
|
}
|
|
|
|
const formattedId = String(id).replace(/-/g, '/');
|
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
|
}
|
|
|
|
getArtistPictureSrcset(id) {
|
|
if (!id || (typeof id === 'string' && (id.startsWith('blob:') || id.startsWith('assets/')))) {
|
|
return '';
|
|
}
|
|
|
|
const formattedId = String(id).replace(/-/g, '/');
|
|
const baseUrl = `https://resources.tidal.com/images/${formattedId}`;
|
|
return `${baseUrl}/160x160.jpg 160w, ${baseUrl}/320x320.jpg 320w, ${baseUrl}/640x640.jpg 640w`;
|
|
}
|
|
|
|
getVideoCoverUrl(imageId, size = '1280') {
|
|
if (!imageId) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
typeof imageId === 'string' &&
|
|
(imageId.startsWith('http') || imageId.startsWith('blob:') || imageId.startsWith('assets/'))
|
|
) {
|
|
return imageId;
|
|
}
|
|
|
|
const formattedId = String(imageId).replace(/-/g, '/');
|
|
return `https://resources.tidal.com/images/${formattedId}/${size}x720.jpg`;
|
|
}
|
|
|
|
async clearCache() {
|
|
await this.cache.clear();
|
|
this.streamCache.clear();
|
|
}
|
|
|
|
getCacheStats() {
|
|
return {
|
|
...this.cache.getCacheStats(),
|
|
streamUrls: this.streamCache.size,
|
|
};
|
|
}
|
|
}
|