import { APICache } from './cache.js'; import { getTrackArtists } from './utils.js'; const UPTIME_URLS = [ 'https://tidal-uptime.jiffy-puffs-1j.workers.dev/', 'https://tidal-uptime.props-76styles.workers.dev/', ]; const FALLBACK_INSTANCES = { api: [ 'https://eu-central.monochrome.tf', 'https://us-west.monochrome.tf', 'https://arran.monochrome.tf', 'https://triton.squid.wtf', 'https://api.monochrome.tf', ], streaming: ['https://arran.monochrome.tf', 'https://triton.squid.wtf', 'https://us-west.monochrome.tf'], }; function shuffle(arr) { const copy = [...arr]; for (let i = copy.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy; } function decodeManifest(manifest) { const decoded = atob(manifest); if (decoded.includes(' { this.cache.clearExpired(); }, 1000 * 60 * 5); if (typeof this.cacheCleanupInterval?.unref === 'function') { this.cacheCleanupInterval.unref(); } } async initInstances() { let loaded = null; for (const url of shuffle(UPTIME_URLS)) { try { const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) continue; const data = await res.json(); const api = Array.isArray(data?.api) ? data.api.map((i) => (typeof i === 'string' ? i : i?.url)).filter(Boolean) : []; const streaming = Array.isArray(data?.streaming) ? data.streaming.map((i) => (typeof i === 'string' ? i : i?.url)).filter(Boolean) : []; if (api.length > 0) { loaded = { api, streaming: streaming.length > 0 ? streaming : api, }; break; } } catch { // try next URL } } if (loaded) this.instances = loaded; return this.instances; } async fetchFromPool(poolType, path, options = {}) { const pool = shuffle(this.instances[poolType] || []); let lastError = null; for (const baseUrl of pool) { try { const slashPath = path.startsWith('/') ? path : `/${path}`; const url = baseUrl.endsWith('/') ? `${baseUrl.slice(0, -1)}${slashPath}` : `${baseUrl}${slashPath}`; const res = await fetch(url, options); if (!res.ok) { lastError = new Error(`HTTP ${res.status} from ${baseUrl}`); continue; } return await res.json(); } catch (err) { lastError = err; } } throw lastError || new Error(`No instances available for ${poolType}`); } normalizeSearchResponse(data, key) { const payload = data?.data ?? data; const section = findSearchSection(payload, key, new Set()); return buildSearchResponse(section); } prepareTrack(track) { const normalized = { ...(track || {}) }; if (!normalized.artist && Array.isArray(normalized.artists) && normalized.artists.length > 0) { normalized.artist = normalized.artists[0]; } return { ...normalized, id: normalized.id, title: normalized.title || 'Unknown title', artistName: getTrackArtists(normalized), duration: normalized.duration || 0, cover: normalized.album?.cover || normalized.cover || null, albumTitle: normalized.album?.title || '', }; } prepareAlbum(album) { let normalized = { ...(album || {}) }; if (!normalized.artist && Array.isArray(normalized.artists) && normalized.artists.length > 0) { normalized = { ...normalized, artist: normalized.artists[0] }; } return normalized; } prepareArtist(artist) { let normalized = { ...(artist || {}) }; if (!normalized.type && Array.isArray(normalized.artistTypes) && normalized.artistTypes.length > 0) { normalized = { ...normalized, type: normalized.artistTypes[0] }; } return normalized; } deduplicateAlbums(albums) { const unique = new Map(); for (const album of albums) { const key = JSON.stringify([album?.title || '', album?.numberOfTracks || 0]); if (!unique.has(key)) { unique.set(key, album); continue; } const existing = unique.get(key); if (album?.explicit && !existing?.explicit) { unique.set(key, album); } } return [...unique.values()]; } async searchTracks(query) { const q = query.trim(); if (!q) return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; const cached = await this.cache.get('search_tracks', q); if (cached) return cached; const data = await this.fetchFromPool('api', `/search/?s=${encodeURIComponent(q)}`); const normalized = this.normalizeSearchResponse(data, 'tracks'); const result = { ...normalized, items: normalized.items.map((entry) => this.prepareTrack(entry?.item ?? entry)), }; await this.cache.set('search_tracks', q, result); return result; } async searchAlbums(query) { const q = query.trim(); if (!q) return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; const cached = await this.cache.get('search_albums', q); if (cached) return cached; const data = await this.fetchFromPool('api', `/search/?al=${encodeURIComponent(q)}`); const normalized = this.normalizeSearchResponse(data, 'albums'); const preparedItems = normalized.items.map((entry) => this.prepareAlbum(entry?.item ?? entry)); const result = { ...normalized, items: this.deduplicateAlbums(preparedItems), }; await this.cache.set('search_albums', q, result); return result; } async searchArtists(query) { const q = query.trim(); if (!q) return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 }; const cached = await this.cache.get('search_artists', q); if (cached) return cached; const data = await this.fetchFromPool('api', `/search/?a=${encodeURIComponent(q)}`); const normalized = this.normalizeSearchResponse(data, 'artists'); const result = { ...normalized, items: normalized.items.map((entry) => this.prepareArtist(entry?.item ?? entry)), }; await this.cache.set('search_artists', q, result); return result; } async getAlbum(id) { const cacheKey = String(id); const cached = await this.cache.get('album', cacheKey); if (cached) return cached; const jsonData = await this.fetchFromPool('api', `/album/?id=${encodeURIComponent(id)}`); const data = jsonData?.data ?? jsonData; let album = null; let tracksSection = null; if (data && typeof data === 'object' && !Array.isArray(data)) { if ('numberOfTracks' in data || 'title' in data) album = this.prepareAlbum(data); if (Array.isArray(data.items)) tracksSection = data; } if (!album && Array.isArray(data)) { for (const entry of data) { if (!entry || typeof entry !== 'object') continue; if (!album && ('numberOfTracks' in entry || 'title' in entry)) album = this.prepareAlbum(entry); if (!tracksSection && Array.isArray(entry.items)) tracksSection = entry; } } if (!album) throw new Error('Album not found'); let tracks = (tracksSection?.items || []).map((i) => this.prepareTrack(i?.item ?? i)); const totalTracks = Number(album.numberOfTracks || tracks.length); if (totalTracks > tracks.length && totalTracks < 2000) { let offset = tracks.length; while (tracks.length < totalTracks) { const pageData = await this.fetchFromPool('api', `/album/?id=${encodeURIComponent(id)}&offset=${offset}&limit=500`); const payload = pageData?.data ?? pageData; const items = Array.isArray(payload?.items) ? payload.items : []; if (items.length === 0) break; const prepared = items.map((i) => this.prepareTrack(i?.item ?? i)); if (prepared.length === 0) break; if (tracks.length > 0 && prepared[0]?.id === tracks[0]?.id) break; tracks = tracks.concat(prepared); offset += prepared.length; } } const result = { album, tracks }; await this.cache.set('album', cacheKey, result); return result; } 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 [primaryJsonData, contentJsonData] = await Promise.all([ this.fetchFromPool('api', `/artist/?id=${encodeURIComponent(artistId)}`), this.fetchFromPool('api', `/artist/?f=${encodeURIComponent(artistId)}&skip_tracks=true`), ]); const primaryData = primaryJsonData?.data ?? primaryJsonData; const rawArtist = primaryData?.artist || (Array.isArray(primaryData) ? primaryData[0] : primaryData); if (!rawArtist) throw new Error('Artist not found'); const artist = { ...this.prepareArtist(rawArtist), picture: rawArtist.picture || primaryData?.cover || null, name: rawArtist.name || 'Unknown Artist', }; const contentData = contentJsonData?.data ?? contentJsonData; const entries = Array.isArray(contentData) ? contentData : [contentData]; const albumMap = new Map(); const trackMap = new Map(); const isTrack = (v) => v?.id && v?.duration && v?.album; const isAlbum = (v) => v?.id && 'numberOfTracks' in v; const scan = (value, visited = new Set()) => { 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)) trackMap.set(item.id, this.prepareTrack(item)); Object.values(value).forEach((nested) => scan(nested, visited)); }; entries.forEach((entry) => scan(entry)); const allReleases = this.deduplicateAlbums([...albumMap.values()]).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 tracks = [...trackMap.values()].sort((a, b) => (b.popularity || 0) - (a.popularity || 0)).slice(0, 20); const result = { ...artist, albums, eps, tracks }; await this.cache.set('artist', cacheKey, result); return result; } async getTrackMetadata(id) { const cacheKey = String(id); const cached = await this.cache.get('track_meta', cacheKey); if (cached) return cached; const json = await this.fetchFromPool('api', `/info/?id=${encodeURIComponent(id)}`); const data = json?.data ?? json; const items = Array.isArray(data) ? data : [data]; const found = items.find((i) => i?.id == id || i?.item?.id == id); if (!found) throw new Error('Track metadata not found'); const track = this.prepareTrack(found.item || found); await this.cache.set('track_meta', cacheKey, track); return track; } async getTrackRecommendations(id) { const cacheKey = String(id); const cached = await this.cache.get('recommendations', cacheKey); if (cached) return cached; try { const json = await this.fetchFromPool('api', `/recommendations/?id=${encodeURIComponent(id)}`); const data = json?.data ?? json; const items = Array.isArray(data?.items) ? data.items : []; const tracks = items.map((item) => this.prepareTrack(item.track || item)); await this.cache.set('recommendations', cacheKey, tracks, 1000 * 60 * 10); return tracks; } catch { return []; } } async getTrackStream(id, quality = 'HI_RES_LOSSLESS') { if (!id) throw new Error('Track id is required'); const data = await this.fetchFromPool('streaming', `/track/?id=${encodeURIComponent(id)}&quality=${quality}`); const payload = data?.data ?? data; const { info, originalTrackUrl } = parseTrackLookup(payload); if (this.lastDashBlobUrl && this.lastDashBlobUrl.startsWith('blob:')) { URL.revokeObjectURL(this.lastDashBlobUrl); this.lastDashBlobUrl = null; } if (originalTrackUrl) { return { streamUrl: originalTrackUrl, isDash: false }; } if (!info?.manifest) throw new Error('No stream manifest returned for track'); const streamUrl = decodeManifest(info.manifest); if (!streamUrl) throw new Error('Could not resolve stream URL from manifest'); const isDash = streamUrl.startsWith('blob:'); if (isDash) this.lastDashBlobUrl = streamUrl; return { streamUrl, isDash }; } async downloadTrackBlob(id, quality = 'LOSSLESS', onProgress = null) { const track = await this.getTrackMetadata(id).catch(() => ({ id, title: `track-${id}` })); const { streamUrl, isDash } = await this.getTrackStream(id, quality); if (isDash) { throw new Error('DASH stream download is not yet supported in this simplified downloader.'); } const response = await fetch(streamUrl); if (!response.ok) throw new Error(`Download failed with status ${response.status}`); const contentLength = Number(response.headers.get('Content-Length') || 0); if (!response.body) { const blob = await response.blob(); return { blob, filename: `${track.title || id}.flac`, track }; } const reader = response.body.getReader(); const chunks = []; let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); received += value.byteLength; if (typeof onProgress === 'function') { onProgress({ receivedBytes: received, totalBytes: contentLength || undefined }); } } } const blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' }); return { blob, filename: `${track.title || id}.flac`, track }; } getCoverUrl(coverId, size = 320) { if (!coverId || typeof coverId !== 'string') return ''; return `https://resources.tidal.com/images/${coverId.replace(/-/g, '/')}/${size}x${size}.jpg`; } getArtistPictureUrl(id, size = 320) { if (!id || typeof id !== 'string') return ''; return `https://resources.tidal.com/images/${id.replace(/-/g, '/')}/${size}x${size}.jpg`; } async clearCache() { await this.cache.clear(); } getCacheStats() { return this.cache.getCacheStats(); } }