185 lines
5.8 KiB
JavaScript
185 lines
5.8 KiB
JavaScript
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 normalizeItems(value) {
|
|
if (!value || typeof value !== 'object') return [];
|
|
if (Array.isArray(value.items)) return value.items;
|
|
|
|
for (const nested of Object.values(value)) {
|
|
if (nested && typeof nested === 'object' && Array.isArray(nested.items)) {
|
|
return nested.items;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export class GreyscaleAPI {
|
|
constructor() {
|
|
this.instances = { ...FALLBACK_INSTANCES };
|
|
this.lastDashBlobUrl = null;
|
|
}
|
|
|
|
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) {
|
|
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);
|
|
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}`);
|
|
}
|
|
|
|
async searchTracks(query) {
|
|
const q = encodeURIComponent(query.trim());
|
|
const data = await this.fetchFromPool('api', `/search/?s=${q}`);
|
|
const payload = data?.data ?? data;
|
|
const items = normalizeItems(payload);
|
|
|
|
return items.map((entry) => {
|
|
const track = entry?.item ?? entry;
|
|
return {
|
|
id: track?.id,
|
|
title: track?.title || 'Unknown title',
|
|
artist:
|
|
track?.artist?.name ||
|
|
track?.artists?.map((a) => a?.name).filter(Boolean).join(', ') ||
|
|
'Unknown artist',
|
|
duration: track?.duration || 0,
|
|
cover: track?.album?.cover || null,
|
|
albumTitle: track?.album?.title || '',
|
|
raw: track,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getTrackStream(id, quality = 'HI_RES_LOSSLESS') {
|
|
if (!id) throw new Error('Track id is required');
|
|
|
|
const data = await this.fetchFromPool('streaming', `/track/?id=${id}&quality=${quality}`);
|
|
const payload = data?.data ?? data;
|
|
const entries = Array.isArray(payload) ? payload : [payload];
|
|
|
|
const info = entries.find((e) => e && typeof e === 'object' && 'manifest' in e);
|
|
const direct = entries.find((e) => e && typeof e === 'object' && typeof e.OriginalTrackUrl === 'string');
|
|
|
|
if (this.lastDashBlobUrl && this.lastDashBlobUrl.startsWith('blob:')) {
|
|
URL.revokeObjectURL(this.lastDashBlobUrl);
|
|
this.lastDashBlobUrl = null;
|
|
}
|
|
|
|
if (direct?.OriginalTrackUrl) {
|
|
return { streamUrl: direct.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 };
|
|
}
|
|
|
|
getCoverUrl(coverId, size = 320) {
|
|
if (!coverId || typeof coverId !== 'string') return '';
|
|
return `https://resources.tidal.com/images/${coverId.replace(/-/g, '/')}/${size}x${size}.jpg`;
|
|
}
|
|
}
|