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

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`;
}
}