Files
client/greyscale/src/api.js
T
2026-04-17 16:52:51 -04:00

519 lines
18 KiB
JavaScript

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('<MPD')) {
const blob = new Blob([decoded], { type: 'application/dash+xml' });
return URL.createObjectURL(blob);
}
try {
const parsed = JSON.parse(decoded);
if (parsed?.urls?.[0]) return parsed.urls[0];
} catch {
// fall through
}
const match = decoded.match(/https?:\/\/[\w\-.~:?#[@!$&'()*+,;=%/]+/);
return match ? match[0] : null;
}
function findSearchSection(source, key, visited = new Set()) {
if (!source || typeof source !== 'object') return null;
if (Array.isArray(source)) {
for (const item of source) {
const found = findSearchSection(item, key, visited);
if (found) return found;
}
return null;
}
if (visited.has(source)) return null;
visited.add(source);
if ('items' in source && Array.isArray(source.items)) return source;
if (key in source) {
const found = findSearchSection(source[key], key, visited);
if (found) return found;
}
for (const value of Object.values(source)) {
const found = findSearchSection(value, key, visited);
if (found) return found;
}
return null;
}
function buildSearchResponse(section) {
const items = section?.items ?? [];
return {
items,
limit: section?.limit ?? items.length,
offset: section?.offset ?? 0,
totalNumberOfItems: section?.totalNumberOfItems ?? items.length,
};
}
function parseTrackLookup(data) {
const entries = Array.isArray(data) ? data : [data];
let info = null;
let originalTrackUrl = null;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!info && 'manifest' in entry) info = entry;
if (!originalTrackUrl && typeof entry.OriginalTrackUrl === 'string') {
originalTrackUrl = entry.OriginalTrackUrl;
}
}
return { info, originalTrackUrl };
}
export class GreyscaleAPI {
constructor() {
this.instances = { ...FALLBACK_INSTANCES };
this.lastDashBlobUrl = null;
this.cache = new APICache({ maxSize: 400, ttl: 1000 * 60 * 20 });
this.cacheCleanupInterval = setInterval(() => {
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();
}
}