519 lines
18 KiB
JavaScript
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();
|
|
}
|
|
}
|