lofi / src /lib /api.ts
veltrixcode's picture
Upload 84 files
3c56493 verified
Raw
History Blame Contribute Delete
78.2 kB
// API service for HIFI API
import { API_CONFIG, fetchWithCORS, selectApiTargetForRegion } from './config';
import type { RegionOption } from '$lib/stores/region';
import { deriveTrackQuality } from '$lib/utils/audioQuality';
import { parseTidalUrl, type TidalUrlParseResult } from '$lib/utils/urlParser';
import { formatArtistsForMetadata } from '$lib/utils';
import type {
Track,
Artist,
Album,
Playlist,
SearchResponse,
AudioQuality,
StreamData,
CoverImage,
Lyrics,
TrackInfo,
TrackLookup,
ArtistDetails,
TrackRecommendationsResponse
} from './types';
const API_BASE = API_CONFIG.baseUrl;
const RATE_LIMIT_ERROR_MESSAGE = 'Too Many Requests. Please wait a moment and try again.';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
type CodedError = Error & { code?: string };
export type TrackDownloadProgress =
| { stage: 'downloading'; receivedBytes: number; totalBytes?: number }
| { stage: 'embedding'; progress: number };
export type DashManifestResult =
| {
kind: 'dash';
manifest: string;
contentType: string | null;
}
| {
kind: 'flac';
manifestText: string;
urls: string[];
contentType: string | null;
};
export interface DashManifestWithMetadata {
result: DashManifestResult;
trackInfo: {
sampleRate: number | null;
bitDepth: number | null;
replayGain: number | null;
};
}
export interface DownloadTrackOptions {
signal?: AbortSignal;
onProgress?: (progress: TrackDownloadProgress) => void;
onFfmpegCountdown?: (options: { totalBytes?: number; autoTriggered: boolean }) => void;
onFfmpegStart?: () => void;
onFfmpegProgress?: (progress: number) => void;
onFfmpegComplete?: () => void;
onFfmpegError?: (error: unknown) => void;
ffmpegAutoTriggered?: boolean;
convertAacToMp3?: boolean;
downloadCoverSeperately?: boolean;
}
class LosslessAPI {
public baseUrl: string;
private metadataQueue: Promise<void> = Promise.resolve();
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
private resolveRegionalBase(region: RegionOption = 'auto'): string {
try {
const target = selectApiTargetForRegion(region);
if (target?.baseUrl) {
return target.baseUrl;
}
} catch (error) {
console.warn('Falling back to default API base URL for region selection', { region, error });
}
return this.baseUrl;
}
private buildRegionalUrl(path: string, region: RegionOption = 'auto'): string {
const base = this.resolveRegionalBase(region).replace(/\/+$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${base}${normalizedPath}`;
}
private normalizeSearchResponse<T>(
data: unknown,
key: 'tracks' | 'albums' | 'artists' | 'playlists'
): SearchResponse<T> {
const section = this.findSearchSection<T>(data, key, new Set());
return this.buildSearchResponse<T>(section);
}
private buildSearchResponse<T>(
section: Partial<SearchResponse<T>> | undefined
): SearchResponse<T> {
const items = section?.items;
const list = Array.isArray(items) ? (items as T[]) : [];
const limit = typeof section?.limit === 'number' ? section.limit : list.length;
const offset = typeof section?.offset === 'number' ? section.offset : 0;
const total =
typeof section?.totalNumberOfItems === 'number' ? section.totalNumberOfItems : list.length;
return {
items: list,
limit,
offset,
totalNumberOfItems: total
};
}
private findSearchSection<T>(
source: unknown,
key: 'tracks' | 'albums' | 'artists' | 'playlists',
visited: Set<object>
): Partial<SearchResponse<T>> | undefined {
if (!source) {
return undefined;
}
if (Array.isArray(source)) {
for (const entry of source) {
const found = this.findSearchSection<T>(entry, key, visited);
if (found) {
return found;
}
}
return undefined;
}
if (typeof source !== 'object') {
return undefined;
}
const objectRef = source as Record<string, unknown>;
if (visited.has(objectRef)) {
return undefined;
}
visited.add(objectRef);
if (!Array.isArray(source) && 'items' in objectRef && Array.isArray(objectRef.items)) {
return objectRef as Partial<SearchResponse<T>>;
}
if (key in objectRef) {
const nested = objectRef[key];
const fromKey = this.findSearchSection<T>(nested, key, visited);
if (fromKey) {
return fromKey;
}
}
for (const value of Object.values(objectRef)) {
const found = this.findSearchSection<T>(value, key, visited);
if (found) {
return found;
}
}
return undefined;
}
private prepareTrack(track: Track): Track {
let normalized = track;
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
normalized = { ...track, artist: track.artists[0]! };
}
const derivedQuality = deriveTrackQuality(normalized);
if (derivedQuality && normalized.audioQuality !== derivedQuality) {
normalized = { ...normalized, audioQuality: derivedQuality };
}
return normalized;
}
private prepareAlbum(album: Album): Album {
if (!album.artist && Array.isArray(album.artists) && album.artists.length > 0) {
return { ...album, artist: album.artists[0]! };
}
return album;
}
private prepareArtist(artist: Artist): Artist {
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
return { ...artist, type: artist.artistTypes[0]! } as Artist;
}
return artist;
}
private ensureNotRateLimited(response: Response): void {
if (response.status === 429) {
throw new Error(RATE_LIMIT_ERROR_MESSAGE);
}
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
private parseTrackLookup(data: unknown): TrackLookup {
const entries = Array.isArray(data) ? data : [data];
let track: Track | undefined;
let info: TrackInfo | undefined;
let originalTrackUrl: string | undefined;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!track && 'album' in entry && 'artist' in entry && 'duration' in entry) {
track = entry as Track;
continue;
}
if (!info && 'manifest' in entry) {
info = entry as TrackInfo;
continue;
}
if (!originalTrackUrl && 'OriginalTrackUrl' in entry) {
const candidate = (entry as { OriginalTrackUrl?: unknown }).OriginalTrackUrl;
if (typeof candidate === 'string') {
originalTrackUrl = candidate;
}
}
}
if (!track || !info) {
throw new Error('Malformed track response');
}
return { track, info, originalTrackUrl };
}
private extractStreamUrlFromManifest(manifest: string): string | null {
try {
const decoded = this.decodeBase64Manifest(manifest);
try {
const parsed = JSON.parse(decoded) as { urls?: string[] };
if (parsed && Array.isArray(parsed.urls) && parsed.urls.length > 0) {
return parsed.urls[0] ?? null;
}
} catch (jsonError) {
// Ignore JSON parse failure and fall back to regex/XML search
console.debug('Manifest JSON parse failed, falling back to pattern match', jsonError);
}
// If this is a segmented DASH manifest, don't extract a URL - let it fall through to segment download
if (this.isSegmentedDashManifest(decoded)) {
return null;
}
const mpdUrl = this.parseFlacUrlFromMpd(decoded);
if (mpdUrl) {
return mpdUrl;
}
// Match all URLs and filter out schema/namespace URLs and segment URLs
const urlRegex = /https?:\/\/[\w\-.~:?#[\]@!$&'()*+,;=%/]+/g;
let match: RegExpExecArray | null;
while ((match = urlRegex.exec(decoded)) !== null) {
const url = match[0];
// Skip segment template URLs and initialization segments
if (url.includes('$Number$')) continue;
if (/\/\d+\.mp4/.test(url)) continue; // Skip segment files like /0.mp4, /1.mp4, etc.
if (this.isValidMediaUrl(url)) {
return url;
}
}
return null;
} catch (error) {
console.error('Failed to decode manifest:', error);
return null;
}
}
private isSegmentedDashManifest(decoded: string): boolean {
// Check if manifest contains SegmentTemplate which indicates segmented content
return /<SegmentTemplate/i.test(decoded);
}
private isDashManifestPayload(payload: string, contentType: string | null): boolean {
const trimmed = payload.trim();
if (!trimmed) {
return false;
}
if (contentType && contentType.toLowerCase().includes('xml')) {
return trimmed.startsWith('<');
}
return /^<\?xml/i.test(trimmed) || /^<MPD[\s>]/i.test(trimmed) || /^<\w+/i.test(trimmed);
}
private parseJsonSafely<T>(payload: string): T | null {
try {
return JSON.parse(payload) as T;
} catch (error) {
console.debug('Failed to parse JSON payload from DASH response', error);
return null;
}
}
private createDashUnavailableError(message: string): CodedError {
const error = new Error(message) as CodedError;
error.code = DASH_MANIFEST_UNAVAILABLE_CODE;
return error;
}
private isXmlContentType(contentType: string | null): boolean {
if (!contentType) return false;
return (
/(application|text)\/(?:.+\+)?xml/i.test(contentType) || /dash\+xml|mpd/i.test(contentType)
);
}
private isJsonContentType(contentType: string | null): boolean {
if (!contentType) return false;
return /json/i.test(contentType) || /application\/vnd\.tidal\.bts/i.test(contentType);
}
private extractUrlsFromDashJsonPayload(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') {
return [];
}
const candidate = (payload as { urls?: unknown }).urls;
if (!Array.isArray(candidate)) {
return [];
}
return candidate
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
private isHiResQuality(quality: AudioQuality | string): boolean {
return String(quality).toUpperCase() === 'HI_RES_LOSSLESS';
}
private isV2ApiContainer(payload: unknown): payload is { version?: unknown; data?: unknown } {
return Boolean(
payload &&
typeof payload === 'object' &&
'version' in (payload as Record<string, unknown>) &&
String((payload as { version?: unknown }).version).startsWith('2.')
);
}
private decodeBase64Manifest(manifest: string): string {
if (typeof manifest !== 'string') return '';
const trimmed = manifest.trim();
if (!trimmed) return '';
try {
// Support URL-safe base64 and missing padding
const normalized = (() => {
let value = trimmed.replace(/-/g, '+').replace(/_/g, '/');
const pad = value.length % 4;
if (pad === 2) value += '==';
if (pad === 3) value += '=';
return value;
})();
const decoded = atob(normalized);
return decoded || trimmed;
} catch {
return trimmed;
}
}
private extractTrackFromPayload(payload: unknown): Track | undefined {
const candidates: unknown[] = [];
if (!payload) return undefined;
if (Array.isArray(payload)) {
candidates.push(...payload);
} else if (typeof payload === 'object') {
candidates.push(payload);
for (const value of Object.values(payload as Record<string, unknown>)) {
if (value && (typeof value === 'object' || Array.isArray(value))) {
candidates.push(value);
}
}
}
const isTrackLike = (entry: unknown): entry is Track => {
if (!entry || typeof entry !== 'object') return false;
const record = entry as Record<string, unknown>;
return (
typeof record.id === 'number' &&
typeof record.title === 'string' &&
typeof record.duration === 'number'
);
};
for (const candidate of candidates) {
if (isTrackLike(candidate)) {
return candidate as Track;
}
}
return undefined;
}
private async fetchTrackMetadata(
trackId: number,
apiVersion: 'v1' | 'v2' = 'v2'
): Promise<Track> {
const response = await this.fetch(`${this.baseUrl}/info/?id=${trackId}`, { apiVersion });
this.ensureNotRateLimited(response);
if (!response.ok) {
throw new Error('Failed to fetch track metadata');
}
const payload = await response.json();
const data = this.isV2ApiContainer(payload) ? payload.data : payload;
const track = this.extractTrackFromPayload(data);
if (!track) {
throw new Error('Track metadata not found');
}
return this.prepareTrack(track);
}
private buildTrackInfoFromV2(data: Record<string, unknown>, fallbackTrackId: number): TrackInfo {
const manifestMimeType =
typeof data.manifestMimeType === 'string' && data.manifestMimeType.trim().length > 0
? data.manifestMimeType
: 'application/dash+xml';
return {
trackId: typeof data.trackId === 'number' ? data.trackId : fallbackTrackId,
audioMode: typeof data.audioMode === 'string' ? data.audioMode : 'STEREO',
audioQuality: typeof data.audioQuality === 'string' ? data.audioQuality : 'LOSSLESS',
manifest: typeof data.manifest === 'string' ? data.manifest : '',
manifestMimeType,
manifestHash: typeof data.manifestHash === 'string' ? data.manifestHash : undefined,
assetPresentation:
typeof data.assetPresentation === 'string' ? data.assetPresentation : 'FULL',
albumReplayGain: typeof data.albumReplayGain === 'number' ? data.albumReplayGain : undefined,
albumPeakAmplitude:
typeof data.albumPeakAmplitude === 'number' ? data.albumPeakAmplitude : undefined,
trackReplayGain: typeof data.trackReplayGain === 'number' ? data.trackReplayGain : undefined,
trackPeakAmplitude:
typeof data.trackPeakAmplitude === 'number' ? data.trackPeakAmplitude : undefined,
bitDepth: typeof data.bitDepth === 'number' ? data.bitDepth : undefined,
sampleRate: typeof data.sampleRate === 'number' ? data.sampleRate : undefined
};
}
private extractOriginalTrackUrl(payload: Record<string, unknown>): string | undefined {
const originalUrl =
typeof payload.OriginalTrackUrl === 'string'
? payload.OriginalTrackUrl
: typeof payload.originalTrackUrl === 'string'
? payload.originalTrackUrl
: undefined;
return originalUrl;
}
private async parseTrackLookupV2(
trackId: number,
payload: { data?: unknown },
apiVersion: 'v1' | 'v2' = 'v2'
): Promise<TrackLookup> {
const container = (payload?.data ?? payload) as Record<string, unknown>;
const trackInfo = this.buildTrackInfoFromV2(container, trackId);
let track = this.extractTrackFromPayload(container) ?? null;
if (!track) {
track = await this.fetchTrackMetadata(trackId, apiVersion);
}
return {
track: this.prepareTrack(track),
info: trackInfo,
originalTrackUrl: this.extractOriginalTrackUrl(container)
};
}
private buildDashManifestResult(payload: string, contentType: string | null): DashManifestResult {
const manifestText = this.decodeBase64Manifest(payload);
if (
this.isXmlContentType(contentType) ||
this.isDashManifestPayload(manifestText, contentType)
) {
return { kind: 'dash', manifest: manifestText, contentType };
}
const trimmed = manifestText.trim();
if (this.isJsonContentType(contentType) || trimmed.startsWith('{') || trimmed.startsWith('[')) {
const parsed = this.parseJsonSafely<{ detail?: unknown; urls?: unknown }>(manifestText);
if (
parsed &&
typeof parsed === 'object' &&
parsed.detail &&
typeof parsed.detail === 'string' &&
parsed.detail.toLowerCase() === 'not found'
) {
throw this.createDashUnavailableError('Dash manifest not found for track');
}
const urls = this.extractUrlsFromDashJsonPayload(parsed);
if (urls.length > 0) {
return { kind: 'flac', manifestText, urls, contentType };
}
}
if (this.isDashManifestPayload(manifestText, contentType)) {
return { kind: 'dash', manifest: manifestText, contentType };
}
const parsed = this.parseJsonSafely(manifestText);
const urls = this.extractUrlsFromDashJsonPayload(parsed);
if (urls.length > 0) {
return { kind: 'flac', manifestText, urls, contentType };
}
throw this.createDashUnavailableError('Received unexpected payload from dash endpoint.');
}
private isValidMediaUrl(url: string): boolean {
if (!url) return false;
const normalized = url.toLowerCase();
// Filter out XML schema/namespace URLs
if (normalized.includes('w3.org')) return false;
if (normalized.includes('xmlschema')) return false;
if (normalized.includes('xmlns')) return false;
// Must look like a media URL (has extension or query params suggesting media)
if (
normalized.includes('.flac') ||
normalized.includes('.mp4') ||
normalized.includes('.m4a') ||
normalized.includes('.aac') ||
normalized.includes('token=') ||
normalized.includes('/audio/')
) {
return true;
}
// If it has a file-like path segment, it's likely valid
if (/\/[^/]+\.[a-z0-9]{2,5}(\?|$)/i.test(url)) return true;
// If it's a relative path starting with a segment name, likely valid
if (/^[a-z0-9_-]+\//i.test(url)) return true;
// If it ends with a path segment that could be a file
if (/\/[a-z0-9_-]+$/i.test(url)) return true;
return false;
}
private parseFlacUrlFromMpd(manifestText: string): string | null {
const trimmed = manifestText.trim();
if (!trimmed) return null;
const isValidMediaUrl = this.isValidMediaUrl.bind(this);
const scoreUrl = (url: string | undefined | null): number => {
if (!url) return -1;
const normalized = url.toLowerCase();
let score = 0;
if (normalized.includes('flac')) score += 3;
if (normalized.includes('hires')) score += 1;
if (normalized.endsWith('.flac')) score += 4;
if (normalized.includes('token=')) score += 1;
return score;
};
const pickBest = (urls: Array<string | undefined | null>): string | null => {
const candidates = urls
.map((u) => (typeof u === 'string' ? u.trim() : ''))
.filter((u) => u.length > 0 && isValidMediaUrl(u));
if (candidates.length === 0) return null;
return candidates.sort((a, b) => scoreUrl(b) - scoreUrl(a))[0] ?? null;
};
// Prefer DOMParser when available (browser side)
if (typeof DOMParser !== 'undefined') {
try {
const doc = new DOMParser().parseFromString(trimmed, 'application/xml');
const baseUrls = Array.from(doc.getElementsByTagName('BaseURL')).map(
(n) => n.textContent?.trim() ?? ''
);
if (baseUrls.length > 0) {
const best = pickBest(baseUrls);
if (best) return best;
}
const reps = Array.from(doc.getElementsByTagName('Representation'));
for (const rep of reps) {
const codecs = rep.getAttribute('codecs')?.toLowerCase() ?? '';
const base = Array.from(rep.getElementsByTagName('BaseURL')).map(
(n) => n.textContent?.trim() ?? ''
);
if (base.length > 0 && codecs.includes('flac')) {
const best = pickBest(base);
if (best) return best;
}
}
} catch (error) {
console.debug('Failed to parse MPD manifest via DOMParser', error);
}
}
// Regex fallback for SSR / non-browser
const baseUrlMatch = trimmed.match(/<BaseURL[^>]*>([^<]+)<\/BaseURL>/i);
if (baseUrlMatch?.[1]) {
const candidate = baseUrlMatch[1].trim();
if (isValidMediaUrl(candidate)) {
return candidate;
}
}
return null;
}
private parseMpdSegmentTemplate(manifestText: string): {
initializationUrl: string;
mediaUrlTemplate: string;
startNumber: number;
segmentTimeline: Array<{ duration: number; repeat: number }>;
baseUrl?: string;
codec?: string;
} | null {
const trimmed = manifestText.trim();
if (!trimmed) return null;
const parseWithDom = () => {
if (typeof DOMParser === 'undefined') return null;
try {
const doc = new DOMParser().parseFromString(trimmed, 'application/xml');
const rawBaseUrl = doc.getElementsByTagName('BaseURL')[0]?.textContent?.trim();
// Filter out schema/namespace URLs
const baseUrl = rawBaseUrl && this.isValidMediaUrl(rawBaseUrl) ? rawBaseUrl : undefined;
let template: Element | null = null;
let codec: string | undefined;
const representations = Array.from(doc.getElementsByTagName('Representation'));
for (const rep of representations) {
const candidateTemplate = rep.getElementsByTagName('SegmentTemplate')[0];
if (!candidateTemplate) continue;
const codecsAttr = rep.getAttribute('codecs')?.toLowerCase() ?? '';
if (!template || codecsAttr.includes('flac')) {
template = candidateTemplate;
codec = codecsAttr || undefined;
if (codecsAttr.includes('flac')) break;
}
}
if (!template) {
template = doc.getElementsByTagName('SegmentTemplate')[0] ?? null;
}
if (!template) return null;
const initializationUrl = template.getAttribute('initialization')?.trim();
const mediaUrlTemplate = template.getAttribute('media')?.trim();
if (!initializationUrl || !mediaUrlTemplate) return null;
const startNumber = Number.parseInt(template.getAttribute('startNumber') ?? '1', 10);
const timelineParent = template.getElementsByTagName('SegmentTimeline')[0];
const segmentTimeline: Array<{ duration: number; repeat: number }> = [];
if (timelineParent) {
const segments = timelineParent.getElementsByTagName('S');
for (const seg of Array.from(segments)) {
const duration = Number.parseInt(seg.getAttribute('d') ?? '0', 10);
if (!Number.isFinite(duration) || duration <= 0) continue;
const repeat = Number.parseInt(seg.getAttribute('r') ?? '0', 10);
segmentTimeline.push({ duration, repeat: Number.isFinite(repeat) ? repeat : 0 });
}
}
return {
initializationUrl,
mediaUrlTemplate,
startNumber: Number.isFinite(startNumber) && startNumber > 0 ? startNumber : 1,
segmentTimeline,
baseUrl,
codec
};
} catch (error) {
console.debug('Failed to parse MPD manifest with DOMParser', error);
return null;
}
};
const parseWithRegex = () => {
const initializationUrl = /initialization="([^"]+)"/i.exec(trimmed)?.[1]?.trim();
const mediaUrlTemplate = /media="([^"]+)"/i.exec(trimmed)?.[1]?.trim();
if (!initializationUrl || !mediaUrlTemplate) return null;
const startNumberMatch = /startNumber="(\d+)"/i.exec(trimmed);
const startNumber = startNumberMatch ? Number.parseInt(startNumberMatch[1]!, 10) : 1;
const segmentTimeline: Array<{ duration: number; repeat: number }> = [];
// Match <S> elements with d attribute, r attribute is optional
const timelineRegex = /<S[^>]*\sd="(\d+)"(?:[^>]*\sr="(-?\d+)")?[^>]*\/?>/gi;
let match: RegExpExecArray | null;
while ((match = timelineRegex.exec(trimmed)) !== null) {
const duration = Number.parseInt(match[1]!, 10);
const repeat = match[2] ? Number.parseInt(match[2], 10) : 0;
if (Number.isFinite(duration) && duration > 0) {
segmentTimeline.push({ duration, repeat: Number.isFinite(repeat) ? repeat : 0 });
}
}
return {
initializationUrl,
mediaUrlTemplate,
startNumber: Number.isFinite(startNumber) && startNumber > 0 ? startNumber : 1,
segmentTimeline
};
};
return parseWithDom() ?? parseWithRegex();
}
private buildMpdSegmentUrls(
template: {
initializationUrl: string;
mediaUrlTemplate: string;
startNumber: number;
segmentTimeline: Array<{ duration: number; repeat: number }>;
baseUrl?: string;
codec?: string;
} | null
): { initializationUrl: string; segmentUrls: string[] } | null {
if (!template) return null;
const resolveUrl = (url: string): string => {
if (/^https?:\/\//i.test(url)) return url;
if (template.baseUrl) {
try {
return new URL(url, template.baseUrl).toString();
} catch {
return `${template.baseUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
}
}
return url;
};
const initializationUrl = resolveUrl(template.initializationUrl);
const segmentUrls: string[] = [];
let segmentNumber = template.startNumber;
const timeline =
template.segmentTimeline.length > 0 ? template.segmentTimeline : [{ duration: 0, repeat: 0 }];
for (const entry of timeline) {
const repeat = Number.isFinite(entry.repeat) ? entry.repeat : 0;
const count = Math.max(1, repeat + 1);
for (let i = 0; i < count; i += 1) {
const url = template.mediaUrlTemplate.replace('$Number$', `${segmentNumber}`);
segmentUrls.push(resolveUrl(url));
segmentNumber += 1;
}
}
return { initializationUrl, segmentUrls };
}
private async downloadFlacFromMpd(
manifestText: string,
options?: DownloadTrackOptions
): Promise<{ blob: Blob; mimeType: string } | null> {
const template = this.parseMpdSegmentTemplate(manifestText);
const segments = this.buildMpdSegmentUrls(template);
if (!segments) return null;
const urls = [segments.initializationUrl, ...segments.segmentUrls];
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
for (const url of urls) {
const response = await this.fetch(url, { signal: options?.signal });
if (!response.ok) {
throw new Error(`Failed to fetch DASH segment (status ${response.status})`);
}
const buffer = await response.arrayBuffer();
const chunk = new Uint8Array(buffer);
receivedBytes += chunk.byteLength;
chunks.push(chunk);
options?.onProgress?.({ stage: 'downloading', receivedBytes, totalBytes: undefined });
}
const totalBytes = chunks.reduce((total, current) => total + current.byteLength, 0);
const merged = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return { blob: new Blob([merged], { type: 'audio/flac' }), mimeType: 'audio/flac' };
}
private async resolveHiResStreamFromDash(trackId: number): Promise<string> {
const manifest = await this.getDashManifest(trackId, 'HI_RES_LOSSLESS');
if (manifest.kind === 'flac') {
const url = manifest.urls.find(
(candidate) => typeof candidate === 'string' && candidate.length > 0
);
if (url) {
return url;
}
throw new Error('DASH manifest did not include any FLAC URLs.');
}
const directUrl = this.parseFlacUrlFromMpd(manifest.manifest);
if (directUrl) {
return directUrl;
}
throw new Error('Hi-res DASH manifest does not expose a direct FLAC URL.');
}
/**
* Fetch wrapper with CORS handling
*/
private async fetch(
url: string,
options?: RequestInit & {
apiVersion?: 'v1' | 'v2';
preferredQuality?: string;
validateResponse?: (res: Response) => Promise<boolean>;
}
): Promise<Response> {
return fetchWithCORS(url, options);
}
/**
* Search for tracks
*/
async searchTracks(query: string, region: RegionOption = 'auto'): Promise<SearchResponse<Track>> {
const response = await this.fetch(
this.buildRegionalUrl(`/search/?s=${encodeURIComponent(query)}`, region)
);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to search tracks');
const data = await response.json();
const normalized = this.normalizeSearchResponse<Track>(data, 'tracks');
return {
...normalized,
items: normalized.items.map((track) => this.prepareTrack(track))
};
}
/**
* Search for artists
*/
async searchArtists(
query: string,
region: RegionOption = 'auto'
): Promise<SearchResponse<Artist>> {
const response = await this.fetch(
this.buildRegionalUrl(`/search/?a=${encodeURIComponent(query)}`, region)
);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to search artists');
const data = await response.json();
const normalized = this.normalizeSearchResponse<Artist>(data, 'artists');
return {
...normalized,
items: normalized.items.map((artist) => this.prepareArtist(artist))
};
}
/**
* Search for albums
*/
async searchAlbums(query: string, region: RegionOption = 'auto'): Promise<SearchResponse<Album>> {
const response = await this.fetch(
this.buildRegionalUrl(`/search/?al=${encodeURIComponent(query)}`, region)
);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to search albums');
const data = await response.json();
const normalized = this.normalizeSearchResponse<Album>(data, 'albums');
return {
...normalized,
items: normalized.items.map((album) => this.prepareAlbum(album))
};
}
/**
* Search for playlists
*/
async searchPlaylists(
query: string,
region: RegionOption = 'auto'
): Promise<SearchResponse<Playlist>> {
const response = await this.fetch(
this.buildRegionalUrl(`/search/?p=${encodeURIComponent(query)}`, region)
);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to search playlists');
const data = await response.json();
return this.normalizeSearchResponse<Playlist>(data, 'playlists');
}
/**
* Import content from a Tidal URL
* Supports track, album, artist, and playlist URLs
*/
async importFromUrl(url: string): Promise<{
type: 'track' | 'album' | 'artist' | 'playlist';
data: Track | Album | Artist | { playlist: Playlist; tracks: Track[] };
}> {
const parsed = parseTidalUrl(url);
if (parsed.type === 'unknown') {
throw new Error(
'Invalid Tidal URL. Please provide a valid track, album, artist, or playlist URL.'
);
}
switch (parsed.type) {
case 'track': {
if (!parsed.trackId) {
throw new Error('Could not extract track ID from URL');
}
const lookup = await this.getTrack(parsed.trackId);
return {
type: 'track',
data: this.prepareTrack(lookup.track)
};
}
case 'album': {
if (!parsed.albumId) {
throw new Error('Could not extract album ID from URL');
}
const { album } = await this.getAlbum(parsed.albumId);
return {
type: 'album',
data: this.prepareAlbum(album)
};
}
case 'artist': {
if (!parsed.artistId) {
throw new Error('Could not extract artist ID from URL');
}
const artist = await this.getArtist(parsed.artistId);
return {
type: 'artist',
data: this.prepareArtist(artist)
};
}
case 'playlist': {
if (!parsed.playlistId) {
throw new Error('Could not extract playlist ID from URL');
}
const { playlist, items } = await this.getPlaylist(parsed.playlistId);
const tracks = items.map((item) => this.prepareTrack(item.item));
return {
type: 'playlist',
data: { playlist, tracks }
};
}
default:
throw new Error('Unsupported URL type');
}
}
/**
* Get track info and stream URL (with retries for quality fallback)
*/
async getTrack(id: number, quality: AudioQuality = 'LOSSLESS'): Promise<TrackLookup> {
const url = `${this.baseUrl}/track/?id=${id}&quality=${quality}`;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
const response = await this.fetch(url, {
apiVersion: 'v2',
validateResponse: async (res) => {
try {
const data = await res.json();
const container = (data?.data ?? data) as Record<string, unknown>;
return container?.assetPresentation !== 'PREVIEW';
} catch {
return true;
}
}
});
this.ensureNotRateLimited(response);
if (response.ok) {
const data = await response.json();
if (this.isV2ApiContainer(data)) {
return await this.parseTrackLookupV2(id, data, 'v2');
}
return this.parseTrackLookup(data);
}
let detail: string | undefined;
let userMessage: string | undefined;
let subStatus: number | undefined;
try {
const errorData = (await response.json()) as {
detail?: unknown;
subStatus?: unknown;
userMessage?: unknown;
};
if (typeof errorData?.detail === 'string') {
detail = errorData.detail;
}
if (typeof errorData?.userMessage === 'string') {
userMessage = errorData.userMessage;
if (!detail) {
detail = errorData.userMessage;
}
}
if (typeof errorData?.subStatus === 'number') {
subStatus = errorData.subStatus;
}
} catch {
// Ignore JSON parse errors
}
const isTokenRetry = response.status === 401 && subStatus === 11002;
const message = detail ?? `Failed to get track (status ${response.status})`;
lastError = new Error(isTokenRetry ? (userMessage ?? message) : message);
const shouldRetry =
isTokenRetry || (detail ? /quality not found/i.test(detail) : response.status >= 500);
if (attempt === 3 || !shouldRetry) {
throw lastError;
}
await this.delay(200 * attempt);
}
throw lastError ?? new Error('Failed to get track');
}
async getRecommendations(trackId: number): Promise<Track[]> {
const response = await this.fetch(`${this.baseUrl}/recommendations/?id=${trackId}`);
this.ensureNotRateLimited(response);
if (!response.ok) {
throw new Error('Failed to fetch track recommendations');
}
const payload: TrackRecommendationsResponse = await response.json();
if (!payload.data.items) {
throw new Error('No recommendations found');
}
return payload.data.items.map(item => item.track);
}
async getDashManifest(
trackId: number,
quality: AudioQuality = 'HI_RES_LOSSLESS'
): Promise<DashManifestResult> {
const { result } = await this.getDashManifestWithMetadata(trackId, quality);
return result;
}
async getDashManifestWithMetadata(
trackId: number,
quality: AudioQuality = 'HI_RES_LOSSLESS'
): Promise<DashManifestWithMetadata> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const lookup = await this.getTrack(trackId, quality);
const manifestPayload = lookup.info?.manifest ?? '';
const contentType = lookup.info?.manifestMimeType ?? null;
const result = this.buildDashManifestResult(manifestPayload, contentType);
const trackInfo = {
sampleRate: lookup.info?.sampleRate ?? null,
bitDepth: lookup.info?.bitDepth ?? null,
replayGain: lookup.info?.trackReplayGain ?? null
};
return { result, trackInfo };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
if (attempt < 3) {
await this.delay(200 * attempt);
}
}
throw lastError ?? this.createDashUnavailableError('Unable to load dash manifest for track');
}
/**
* Get song with stream info
*/
async getSong(query: string, quality: AudioQuality = 'LOSSLESS'): Promise<StreamData> {
const response = await this.fetch(
`${this.baseUrl}/song/?q=${encodeURIComponent(query)}&quality=${quality}`
);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to get song');
return response.json();
}
/**
* Get album details with track listing
*/
async getAlbum(id: number): Promise<{ album: Album; tracks: Track[] }> {
const response = await this.fetch(`${this.baseUrl}/album/?id=${id}`);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to get album');
const data = await response.json();
// Handle v2/new API structure where response is { version, data: { items: [...] } }
if (data && typeof data === 'object' && 'data' in data && 'items' in data.data) {
const items = data.data.items;
if (Array.isArray(items) && items.length > 0) {
const firstItem = items[0];
const firstTrack = firstItem.item || firstItem;
if (firstTrack && firstTrack.album) {
let albumEntry = this.prepareAlbum(firstTrack.album);
// If album doesn't have artist info, try to get it from the track
if (!albumEntry.artist && firstTrack.artist) {
albumEntry = { ...albumEntry, artist: firstTrack.artist };
}
const tracks = items
.map((i: unknown) => {
if (!i || typeof i !== 'object') return null;
const itemObj = i as { item?: unknown };
const t = (itemObj.item || itemObj) as Track;
if (!t) return null;
// Ensure track has album reference
return this.prepareTrack({ ...t, album: albumEntry });
})
.filter((t): t is Track => t !== null);
return { album: albumEntry, tracks };
}
}
}
const entries = Array.isArray(data) ? data : [data];
let albumEntry: Album | undefined;
let trackCollection: { items?: unknown[] } | undefined;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!albumEntry && 'title' in entry && 'id' in entry && 'cover' in entry) {
albumEntry = this.prepareAlbum(entry as Album);
continue;
}
if (
!trackCollection &&
'items' in entry &&
Array.isArray((entry as { items?: unknown[] }).items)
) {
trackCollection = entry as { items?: unknown[] };
}
}
if (!albumEntry) {
throw new Error('Album not found');
}
const tracks: Track[] = [];
if (trackCollection?.items) {
for (const rawItem of trackCollection.items) {
if (!rawItem || typeof rawItem !== 'object') continue;
let trackCandidate: Track | undefined;
if ('item' in rawItem && rawItem.item && typeof rawItem.item === 'object') {
trackCandidate = rawItem.item as Track;
} else {
trackCandidate = rawItem as Track;
}
if (!trackCandidate) continue;
const candidateWithAlbum = trackCandidate.album
? trackCandidate
: ({ ...trackCandidate, album: albumEntry } as Track);
tracks.push(this.prepareTrack(candidateWithAlbum));
}
}
return { album: albumEntry, tracks };
}
/**
* Get playlist details
*/
async getPlaylist(uuid: string): Promise<{ playlist: Playlist; items: Array<{ item: Track }> }> {
const response = await this.fetch(`${this.baseUrl}/playlist/?id=${uuid}`);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to get playlist');
const data = await response.json();
// Handle v2 structure (object with playlist and items keys)
if (data && typeof data === 'object' && 'playlist' in data && 'items' in data) {
return {
playlist: data.playlist,
items: data.items
};
}
return {
playlist: Array.isArray(data) ? data[0] : data,
items: Array.isArray(data) && data[1] ? data[1].items : []
};
}
/**
* Get artist overview, including discography modules and top tracks
*/
async getArtist(id: number): Promise<ArtistDetails> {
const response = await this.fetch(`${this.baseUrl}/artist/?f=${id}`);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to get artist');
const data = await response.json();
const entries = Array.isArray(data) ? data : [data];
const visited = new Set<object>();
const albumMap = new Map<number, Album>();
const trackMap = new Map<number, Track>();
let artist: Artist | undefined;
const isTrackLike = (value: unknown): value is Track => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
const albumCandidate = candidate.album as unknown;
return (
typeof candidate.id === 'number' &&
typeof candidate.title === 'string' &&
typeof candidate.duration === 'number' &&
'trackNumber' in candidate &&
albumCandidate !== undefined &&
albumCandidate !== null &&
typeof albumCandidate === 'object'
);
};
const isAlbumLike = (value: unknown): value is Album => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === 'number' &&
typeof candidate.title === 'string' &&
'cover' in candidate
);
};
const isArtistLike = (value: unknown): value is Artist => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === 'number' &&
typeof candidate.name === 'string' &&
typeof candidate.type === 'string' &&
('artistRoles' in candidate || 'artistTypes' in candidate || 'url' in candidate)
);
};
const recordArtist = (candidate: Artist | undefined) => {
if (!candidate) return;
const normalized = this.prepareArtist(candidate);
if (!artist || artist.id === normalized.id) {
artist = normalized;
}
};
const addAlbum = (candidate: Album | undefined) => {
if (!candidate || typeof candidate.id !== 'number') return;
const normalized = this.prepareAlbum({ ...candidate });
albumMap.set(normalized.id, normalized);
recordArtist(normalized.artist ?? normalized.artists?.[0]);
};
const addTrack = (candidate: Track | undefined) => {
if (!candidate || typeof candidate.id !== 'number') return;
const normalized = this.prepareTrack({ ...candidate });
if (!normalized.album) {
return;
}
addAlbum(normalized.album);
const knownAlbum = albumMap.get(normalized.album.id);
if (knownAlbum) {
normalized.album = knownAlbum;
}
trackMap.set(normalized.id, normalized);
recordArtist(normalized.artist);
};
const parseModuleItems = (items: unknown) => {
if (!Array.isArray(items)) return;
for (const entry of items) {
if (!entry || typeof entry !== 'object') {
continue;
}
const candidate = 'item' in entry ? (entry as { item?: unknown }).item : entry;
if (isAlbumLike(candidate)) {
addAlbum(candidate as Album);
const normalizedAlbum = albumMap.get((candidate as Album).id);
recordArtist(normalizedAlbum?.artist ?? normalizedAlbum?.artists?.[0]);
continue;
}
if (isTrackLike(candidate)) {
addTrack(candidate as Track);
continue;
}
scanValue(candidate);
}
};
const scanValue = (value: unknown) => {
if (!value) return;
if (Array.isArray(value)) {
const trackCandidates = value.filter(isTrackLike);
if (trackCandidates.length > 0) {
for (const track of trackCandidates) {
addTrack(track);
}
return;
}
for (const entry of value) {
scanValue(entry);
}
return;
}
if (typeof value !== 'object') {
return;
}
const objectRef = value as Record<string, unknown>;
if (visited.has(objectRef)) {
return;
}
visited.add(objectRef);
if (isArtistLike(objectRef)) {
recordArtist(objectRef as Artist);
}
if ('modules' in objectRef && Array.isArray(objectRef.modules)) {
for (const moduleEntry of objectRef.modules) {
scanValue(moduleEntry);
}
}
if (
'pagedList' in objectRef &&
objectRef.pagedList &&
typeof objectRef.pagedList === 'object'
) {
const pagedList = objectRef.pagedList as { items?: unknown };
parseModuleItems(pagedList.items);
}
if ('items' in objectRef && Array.isArray(objectRef.items)) {
parseModuleItems(objectRef.items);
}
if ('rows' in objectRef && Array.isArray(objectRef.rows)) {
parseModuleItems(objectRef.rows);
}
if ('listItems' in objectRef && Array.isArray(objectRef.listItems)) {
parseModuleItems(objectRef.listItems);
}
for (const nested of Object.values(objectRef)) {
scanValue(nested);
}
};
for (const entry of entries) {
scanValue(entry);
}
if (!artist) {
const trackPrimaryArtist = Array.from(trackMap.values())
.map((track) => track.artist ?? track.artists?.[0])
.find(Boolean);
const albumPrimaryArtist = Array.from(albumMap.values())
.map((album) => album.artist ?? album.artists?.[0])
.find(Boolean);
recordArtist(trackPrimaryArtist ?? albumPrimaryArtist);
}
if (!artist) {
try {
const fallbackResponse = await this.fetch(`${this.baseUrl}/artist/?id=${id}`);
this.ensureNotRateLimited(fallbackResponse);
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
const baseArtist = Array.isArray(fallbackData) ? fallbackData[0] : fallbackData;
if (baseArtist && typeof baseArtist === 'object') {
recordArtist(baseArtist as Artist);
}
}
} catch (fallbackError) {
console.warn('Failed to fetch base artist details:', fallbackError);
}
}
if (!artist) {
throw new Error('Artist not found');
}
const albums = Array.from(albumMap.values()).map((album) => {
if (!album.artist && artist) {
return { ...album, artist };
}
return album;
});
const albumById = new Map(albums.map((album) => [album.id, album] as const));
const tracks = Array.from(trackMap.values()).map((track) => {
const enrichedArtist = track.artist ?? artist;
const album = track.album;
const enrichedAlbum = album
? (albumById.get(album.id) ?? (artist && !album.artist ? { ...album, artist } : album))
: undefined;
return {
...track,
artist: enrichedArtist ?? track.artist,
album: enrichedAlbum ?? album
};
});
const parseDate = (value?: string): number => {
if (!value) return Number.NaN;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : Number.NaN;
};
const sortedAlbums = albums.sort((a, b) => {
const timeA = parseDate(a.releaseDate);
const timeB = parseDate(b.releaseDate);
if (Number.isNaN(timeA) && Number.isNaN(timeB)) {
return (b.popularity ?? 0) - (a.popularity ?? 0);
}
if (Number.isNaN(timeA)) return 1;
if (Number.isNaN(timeB)) return -1;
return timeB - timeA;
});
const sortedTracks = tracks
.sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0))
.slice(0, 100);
return {
...artist,
albums: sortedAlbums,
tracks: sortedTracks
};
}
/**
* Get cover image
*/
async getCover(id?: number, query?: string): Promise<CoverImage[]> {
let url = `${this.baseUrl}/cover/?`;
if (id) url += `id=${id}`;
if (query) url += `q=${encodeURIComponent(query)}`;
const response = await this.fetch(url);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to get cover');
return response.json();
}
/**
* Get lyrics for a track
*/
async getLyrics(id: number): Promise<Lyrics> {
const response = await this.fetch(`${this.baseUrl}/lyrics/?id=${id}`);
this.ensureNotRateLimited(response);
if (!response.ok) throw new Error('Failed to get lyrics');
const data = await response.json();
return Array.isArray(data) ? data[0] : data;
}
/**
* Get stream data including URL and replay gain
*/
async getStreamData(
trackId: number,
quality: AudioQuality = 'LOSSLESS'
): Promise<{
url: string;
replayGain: number | null;
sampleRate: number | null;
bitDepth: number | null;
}> {
let replayGain: number | null = null;
let sampleRate: number | null = null;
let bitDepth: number | null = null;
if (this.isHiResQuality(quality)) {
try {
// Try to fetch metadata for replay gain, but don't fail if it fails
try {
const lookup = await this.getTrack(trackId, quality);
replayGain = lookup.info.trackReplayGain ?? null;
sampleRate = lookup.info.sampleRate ?? null;
bitDepth = lookup.info.bitDepth ?? null;
} catch {
// Ignore metadata fetch failure for HiRes
}
const url = await this.resolveHiResStreamFromDash(trackId);
return { url, replayGain, sampleRate, bitDepth };
} catch (error) {
console.warn('Failed to resolve hi-res stream via DASH manifest', error);
quality = 'LOSSLESS';
}
}
let lastError: Error | null = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const lookup = await this.getTrack(trackId, quality);
replayGain = lookup.info.trackReplayGain ?? null;
sampleRate = lookup.info.sampleRate ?? null;
bitDepth = lookup.info.bitDepth ?? null;
if (lookup.originalTrackUrl) {
return { url: lookup.originalTrackUrl, replayGain, sampleRate, bitDepth };
}
const manifestUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (manifestUrl) {
return { url: manifestUrl, replayGain, sampleRate, bitDepth };
}
lastError = new Error('Unable to resolve stream URL for track');
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
if (attempt < 3) {
await this.delay(200 * attempt);
}
}
throw lastError ?? new Error('Unable to resolve stream URL for track');
}
/**
* Get stream URL for a track
*/
async getStreamUrl(trackId: number, quality: AudioQuality = 'LOSSLESS'): Promise<string> {
const data = await this.getStreamData(trackId, quality);
return data.url;
}
/**
* Attempt to embed metadata into a downloaded track using FFmpeg WASM
*/
private async embedMetadataIntoBlob(
blob: Blob,
lookup: TrackLookup,
filename: string,
contentType: string | null,
options: DownloadTrackOptions | undefined,
quality: AudioQuality,
convertToMp3: boolean
): Promise<Blob | null> {
const job = this.metadataQueue.then(() =>
this.runMetadataEmbedding(
blob,
lookup,
filename,
contentType ?? undefined,
options,
quality,
convertToMp3
)
);
this.metadataQueue = job.then(
() => undefined,
() => undefined
);
try {
return await job;
} catch (error) {
console.warn('Metadata embedding failed', error);
return null;
}
}
private inferExtensionFromFilename(filename: string): string | null {
const match = /\.([a-z0-9]+)(?:\?.*)?$/i.exec(filename);
return match ? match[1]!.toLowerCase() : null;
}
private inferExtensionFromMime(mime?: string | null): string | null {
if (!mime) return null;
const normalized = mime.split(';')[0]?.trim().toLowerCase();
switch (normalized) {
case 'audio/flac':
return 'flac';
case 'audio/x-flac':
return 'flac';
case 'audio/mpeg':
return 'mp3';
case 'audio/mp3':
return 'mp3';
case 'audio/mp4':
case 'audio/aac':
case 'audio/x-m4a':
return 'm4a';
case 'audio/wav':
case 'audio/x-wav':
return 'wav';
case 'audio/ogg':
return 'ogg';
default:
return null;
}
}
private inferMimeFromExtension(
ext: string | null | undefined,
fallbackType?: string
): string | undefined {
switch (ext) {
case 'flac':
return 'audio/flac';
case 'mp3':
return 'audio/mpeg';
case 'm4a':
case 'aac':
return 'audio/mp4';
case 'wav':
return 'audio/wav';
case 'ogg':
return 'audio/ogg';
default:
return fallbackType;
}
}
private validateImageData(data: Uint8Array): boolean {
// Check if data is long enough to contain magic bytes
if (!data || data.length < 4) {
return false;
}
// Check for JPEG magic bytes (FF D8 FF)
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
return true;
}
// Check for PNG magic bytes (89 50 4E 47)
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
return true;
}
// Check for WebP magic bytes (52 49 46 46 ... 57 45 42 50)
if (
data.length >= 12 &&
data[0] === 0x52 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x46 &&
data[8] === 0x57 &&
data[9] === 0x45 &&
data[10] === 0x42 &&
data[11] === 0x50
) {
return true;
}
return false;
}
private detectImageFormat(data: Uint8Array): { extension: string; mimeType: string } | null {
if (!data || data.length < 4) {
return null;
}
// Check for JPEG magic bytes (FF D8 FF)
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
return { extension: 'jpg', mimeType: 'image/jpeg' };
}
// Check for PNG magic bytes (89 50 4E 47)
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
return { extension: 'png', mimeType: 'image/png' };
}
// Check for WebP magic bytes (52 49 46 46 ... 57 45 42 50)
if (
data.length >= 12 &&
data[0] === 0x52 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x46 &&
data[8] === 0x57 &&
data[9] === 0x45 &&
data[10] === 0x42 &&
data[11] === 0x50
) {
return { extension: 'webp', mimeType: 'image/webp' };
}
return null;
}
private buildMetadataEntries(lookup: TrackLookup): Array<[string, string]> {
const entries: Array<[string, string]> = [];
const { track } = lookup;
const album = track.album;
const mainArtist = formatArtistsForMetadata(track.artists);
const albumArtist =
album?.artist?.name ??
(album?.artists && album.artists.length > 0 ? album.artists[0]?.name : undefined) ??
track.artists?.[0]?.name;
if (track.title) entries.push(['title', track.title]);
if (mainArtist) entries.push(['artist', mainArtist]);
if (albumArtist) entries.push(['album_artist', albumArtist]);
if (album?.title) entries.push(['album', album.title]);
const trackNumber = Number(track.trackNumber);
const totalTracks = Number(album?.numberOfTracks);
if (Number.isFinite(trackNumber) && trackNumber > 0) {
const value =
Number.isFinite(totalTracks) && totalTracks > 0
? `${trackNumber}/${totalTracks}`
: `${trackNumber}`;
entries.push(['track', value]);
}
const discNumber = Number(track.volumeNumber);
const totalDiscs = Number(album?.numberOfVolumes);
if (Number.isFinite(discNumber) && discNumber > 0) {
const value =
Number.isFinite(totalDiscs) && totalDiscs > 0
? `${discNumber}/${totalDiscs}`
: `${discNumber}`;
entries.push(['disc', value]);
}
const releaseDate = album?.releaseDate ?? track.streamStartDate;
if (releaseDate) {
const yearMatch = /^(\d{4})/.exec(releaseDate);
if (yearMatch?.[1]) {
entries.push(['date', yearMatch[1]]);
entries.push(['year', yearMatch[1]]);
}
}
// API does not include genre
/*
const tags = track.mediaMetadata?.tags ?? album?.mediaMetadata?.tags;
if (tags && tags.length > 0) {
entries.push(['genre', tags.join('; ')]);
}
*/
if (track.isrc) {
entries.push(['ISRC', track.isrc]);
}
if (album?.copyright) {
entries.push(['copyright', album.copyright]);
}
// ReplayGain
if (lookup.info) {
const { trackReplayGain, trackPeakAmplitude, albumReplayGain, albumPeakAmplitude } =
lookup.info;
if (trackReplayGain !== undefined && trackReplayGain !== null) {
entries.push(['REPLAYGAIN_TRACK_GAIN', `${trackReplayGain} dB`]);
}
if (trackPeakAmplitude !== undefined && trackPeakAmplitude !== null) {
entries.push(['REPLAYGAIN_TRACK_PEAK', `${trackPeakAmplitude}`]);
}
if (albumReplayGain !== undefined && albumReplayGain !== null) {
entries.push(['REPLAYGAIN_ALBUM_GAIN', `${albumReplayGain} dB`]);
}
if (albumPeakAmplitude !== undefined && albumPeakAmplitude !== null) {
entries.push(['REPLAYGAIN_ALBUM_PEAK', `${albumPeakAmplitude}`]);
}
} else if (track.replayGain) {
entries.push(['REPLAYGAIN_TRACK_GAIN', `${track.replayGain} dB`]);
if (track.peak) {
entries.push(['REPLAYGAIN_TRACK_PEAK', `${track.peak}`]);
}
}
entries.push(['comment', 'Downloaded from music.binimum.org/tidal.squid.wtf']);
return entries;
}
private async runMetadataEmbedding(
blob: Blob,
lookup: TrackLookup,
filename: string,
contentType: string | undefined,
options: DownloadTrackOptions | undefined,
quality: AudioQuality,
convertToMp3: boolean
): Promise<Blob | null> {
if (typeof window === 'undefined') {
return null;
}
const extensionFromMime = this.inferExtensionFromMime(contentType);
const extensionFromFilename = this.inferExtensionFromFilename(filename);
const extension = extensionFromMime ?? extensionFromFilename;
if (!extension) {
return null;
}
const supportedExtensions = new Set(['flac', 'mp3', 'm4a', 'aac', 'wav', 'ogg']);
if (!supportedExtensions.has(extension)) {
return null;
}
const convertibleExtensions = new Set(['m4a', 'aac', 'mp4']);
const shouldConvertToMp3 = convertToMp3 && convertibleExtensions.has(extension);
const outputExtension = shouldConvertToMp3 ? 'mp3' : extension;
const targetBitrate = quality === 'LOW' ? '96k' : '320k';
let ffmpegModule: typeof import('./ffmpegClient') | null = null;
try {
ffmpegModule = await import('./ffmpegClient');
} catch (error) {
console.warn('Unable to load FFmpeg client module', error);
options?.onFfmpegError?.(error);
return null;
}
if (!ffmpegModule.isFFmpegSupported()) {
return null;
}
if (options?.onFfmpegCountdown) {
try {
const estimatedBytes = await ffmpegModule.estimateFfmpegDownloadSize?.();
options.onFfmpegCountdown({
totalBytes: estimatedBytes,
autoTriggered: options.ffmpegAutoTriggered ?? false
});
} catch (estimateError) {
console.debug('Failed to estimate FFmpeg size', estimateError);
options.onFfmpegCountdown({
totalBytes: undefined,
autoTriggered: options.ffmpegAutoTriggered ?? false
});
}
}
options?.onFfmpegStart?.();
let ffmpeg: Awaited<ReturnType<typeof ffmpegModule.getFFmpeg>>;
let progressHandler: ((data: { progress: number }) => void) | null = null;
try {
const loadOptions: Parameters<typeof ffmpegModule.getFFmpeg>[0] = {
signal: options?.signal,
onProgress: ({
receivedBytes,
totalBytes
}: {
receivedBytes: number;
totalBytes?: number;
}) => {
if (totalBytes && totalBytes > 0) {
options?.onFfmpegProgress?.(Math.max(0, Math.min(1, receivedBytes / totalBytes)));
} else if (receivedBytes > 0) {
options?.onFfmpegProgress?.(0);
}
}
};
ffmpeg = await ffmpegModule.getFFmpeg(loadOptions);
// Set up progress tracking for this specific job
progressHandler = ({ progress }: { progress: number }) => {
if (options?.onProgress && progress >= 0) {
options.onProgress({ stage: 'embedding', progress: Math.min(1, progress) });
}
};
ffmpeg.on('progress', progressHandler);
options?.onFfmpegProgress?.(1);
options?.onFfmpegComplete?.();
} catch (loadError) {
options?.onFfmpegError?.(loadError);
throw loadError;
}
const uniqueSuffix =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
const inputName = `source-${uniqueSuffix}.${extension}`;
const outputName = `output-${uniqueSuffix}.${outputExtension}`;
let coverWritten = false;
let coverExtension = 'jpg';
try {
if (options?.onProgress) {
options.onProgress({ stage: 'embedding', progress: 0 });
}
// Convert blob to Uint8Array to ensure proper memory handling
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
await ffmpeg.writeFile(inputName, uint8Array);
const artworkId = lookup.track.album?.cover;
if (artworkId) {
// Try multiple sizes as fallback
const coverSizes: Array<'1280' | '640' | '320'> = ['1280', '640', '320'];
let coverFetchSuccess = false;
for (const size of coverSizes) {
if (coverFetchSuccess) break;
const coverUrl = this.getCoverUrl(artworkId, size);
// Try two fetch strategies: with headers, then without
const fetchStrategies = [
{
name: 'with-headers',
options: {
method: 'GET' as const,
headers: {
Accept: 'image/jpeg,image/jpg,image/png,image/*',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
signal: AbortSignal.timeout(10000)
}
},
{
name: 'simple',
options: {
method: 'GET' as const,
signal: AbortSignal.timeout(10000)
}
}
];
for (const strategy of fetchStrategies) {
if (coverFetchSuccess) break;
try {
const coverResponse = await fetch(coverUrl, strategy.options);
if (!coverResponse.ok) {
continue; // Try next size
}
const contentType = coverResponse.headers.get('Content-Type');
const contentLength = coverResponse.headers.get('Content-Length');
// Check if Content-Length indicates empty response
if (contentLength && parseInt(contentLength, 10) === 0) {
continue; // Try next size
}
if (contentType && !contentType.startsWith('image/')) {
continue; // Try next size
}
// Try arrayBuffer directly instead of blob first (more reliable)
let coverArrayBuffer: ArrayBuffer;
try {
coverArrayBuffer = await coverResponse.arrayBuffer();
} catch {
continue; // Try next size
}
if (!coverArrayBuffer || coverArrayBuffer.byteLength === 0) {
continue; // Try next size
}
const coverUint8Array = new Uint8Array(coverArrayBuffer);
// Detect image format from magic bytes
const imageFormat = this.detectImageFormat(coverUint8Array);
if (!imageFormat) {
continue; // Try next size
}
coverExtension = imageFormat.extension;
const finalCoverName = `cover-${uniqueSuffix}.${coverExtension}`;
await ffmpeg.writeFile(finalCoverName, coverUint8Array);
coverWritten = true;
coverFetchSuccess = true;
break; // Success, exit strategy loop
} catch {
// Continue to next strategy
}
} // End strategy loop
} // End size loop
}
const args: string[] = ['-i', inputName];
if (coverWritten) {
const finalCoverName = `cover-${uniqueSuffix}.${coverExtension}`;
args.push('-i', finalCoverName);
}
// Map streams FIRST (matching working command pattern)
// Working command: -map 0:a -map 1 -codec copy
if (coverWritten) {
args.push('-map', '0:a'); // Map audio stream from first input
args.push('-map', '1'); // Map entire second input (cover image)
} else {
args.push('-map', '0:a');
}
// Codec settings
if (shouldConvertToMp3) {
args.push('-codec:a', 'libmp3lame');
args.push('-b:a', targetBitrate);
} else {
args.push('-codec', 'copy');
}
// Track metadata (title, artist, album, etc.)
for (const [key, value] of this.buildMetadataEntries(lookup)) {
args.push('-metadata', `${key}=${value}`);
}
// Cover-specific metadata and disposition (matching working command)
if (coverWritten) {
args.push('-metadata:s:v', 'title=Album cover');
args.push('-metadata:s:v', 'comment=Cover (front)');
args.push('-disposition:v', 'attached_pic');
}
// MP3-specific settings
if (shouldConvertToMp3) {
args.push('-id3v2_version', '3');
args.push('-write_xing', '0');
}
args.push(outputName);
// Execute FFmpeg with timeout protection (3 minutes for large files)
const timeoutMs = 180000; // 3 minutes
const execPromise = ffmpeg.exec(args);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(
new Error(
`FFmpeg execution timeout - processing took longer than 3 minutes. Try using "Download covers separately" option instead.`
)
);
}, timeoutMs);
});
try {
await Promise.race([execPromise, timeoutPromise]);
} catch (execError) {
// Check if it's a timeout
const errorMessage = execError instanceof Error ? execError.message : String(execError);
if (errorMessage.includes('timeout')) {
throw new Error(
'FFmpeg timeout: Processing took too long. Enable "Download covers separately" option for FLAC files.'
);
}
// Check if it's a memory error
if (
errorMessage.includes('memory access out of bounds') ||
errorMessage.includes('RuntimeError') ||
errorMessage.includes('out of memory')
) {
throw new Error(
'FFmpeg memory error: File may be too large for browser processing. Try a smaller file or download without metadata embedding.'
);
}
throw execError;
}
const outputData = await ffmpeg.readFile(outputName);
if (options?.onProgress) {
options.onProgress({ stage: 'embedding', progress: 1 });
}
let outputArray: Uint8Array;
if (outputData instanceof Uint8Array) {
outputArray = outputData;
} else if (typeof outputData === 'string') {
outputArray = new TextEncoder().encode(outputData);
} else {
outputArray = new Uint8Array((outputData as unknown as ArrayBuffer) ?? new ArrayBuffer(0));
}
const blobArray = new Uint8Array(outputArray);
const mimeType = this.inferMimeFromExtension(
outputExtension,
contentType ?? (blob.type && blob.type.length > 0 ? blob.type : undefined)
);
const resultBlob = new Blob([blobArray], { type: mimeType });
return resultBlob;
} catch (error) {
// Check if it's a memory error and provide helpful message
const errorMessage = error instanceof Error ? error.message : String(error);
if (
errorMessage.includes('memory access out of bounds') ||
errorMessage.includes('RuntimeError') ||
errorMessage.includes('out of memory') ||
errorMessage.includes('memory error')
) {
options?.onFfmpegError?.(
new Error('Memory error: File processed without metadata due to browser limitations')
);
} else {
options?.onFfmpegError?.(error);
}
// Return null to fallback to original blob (handled by caller)
return null;
} finally {
// Remove progress handler
if (progressHandler && ffmpeg) {
ffmpeg.off('progress', progressHandler);
}
// Clean up temporary files
if (ffmpeg) {
try {
await ffmpeg.deleteFile(inputName);
} catch (cleanupErr) {
console.debug('Failed to delete FFmpeg input file', cleanupErr);
}
try {
await ffmpeg.deleteFile(outputName);
} catch (cleanupErr) {
console.debug('Failed to delete FFmpeg output file', cleanupErr);
}
if (coverWritten) {
try {
const finalCoverName = `cover-${uniqueSuffix}.${coverExtension}`;
await ffmpeg.deleteFile(finalCoverName);
} catch (cleanupErr) {
console.debug('Failed to delete FFmpeg cover file', cleanupErr);
}
}
}
}
}
private async resolveTrackLookups(
trackId: number,
quality: AudioQuality
): Promise<{
manifestLookup: TrackLookup;
metadataLookup: TrackLookup;
manifestQuality: AudioQuality;
}> {
const manifestLookup = await this.getTrack(trackId, quality);
const metadataLookup = manifestLookup;
return { manifestLookup, metadataLookup, manifestQuality: quality };
}
async getPreferredTrackMetadata(
trackId: number,
quality: AudioQuality = 'LOSSLESS'
): Promise<TrackLookup> {
const { metadataLookup } = await this.resolveTrackLookups(trackId, quality);
return metadataLookup;
}
async fetchTrackBlob(
trackId: number,
quality: AudioQuality = 'LOSSLESS',
filename: string,
options?: DownloadTrackOptions
): Promise<{ blob: Blob; mimeType?: string }> {
try {
const {
manifestLookup,
metadataLookup: initialMetadataLookup,
manifestQuality
} = await this.resolveTrackLookups(trackId, quality);
let metadataLookup = initialMetadataLookup;
let response: Response | null = null;
let streamUrl: string | null = null;
let downloadBlob: Blob | null = null;
let contentType: string | null = null;
let receivedBytes = 0;
let totalBytes: number | undefined;
streamUrl = manifestLookup.originalTrackUrl || null;
if (streamUrl) {
response = await fetch(streamUrl, { signal: options?.signal });
if (response.status === 429) {
throw new Error(RATE_LIMIT_ERROR_MESSAGE);
}
if (!response.ok) {
console.warn('OriginalTrackUrl download failed, falling back to manifest', {
status: response.status
});
response = null;
}
}
if (!response) {
let manifestSource = manifestLookup;
const decodedManifest = this.decodeBase64Manifest(manifestSource.info.manifest);
// For segmented DASH manifests, go directly to segment download
if (this.isSegmentedDashManifest(decodedManifest)) {
try {
const mpdResult = await this.downloadFlacFromMpd(decodedManifest, options);
if (mpdResult) {
downloadBlob = mpdResult.blob;
contentType = mpdResult.mimeType;
receivedBytes = downloadBlob.size;
totalBytes = downloadBlob.size;
metadataLookup = manifestSource;
}
} catch (mpdError) {
console.warn('Failed to download FLAC from MPD manifest', mpdError);
}
if (!downloadBlob) {
throw new Error('Could not download segmented DASH content');
}
} else {
// Try to extract a direct stream URL
let fallbackUrl = this.extractStreamUrlFromManifest(manifestSource.info.manifest);
if (!fallbackUrl && manifestQuality !== 'LOSSLESS') {
try {
const losslessLookup = await this.getTrack(trackId, 'LOSSLESS');
const candidateUrl = this.extractStreamUrlFromManifest(losslessLookup.info.manifest);
if (candidateUrl) {
fallbackUrl = candidateUrl;
manifestSource = losslessLookup;
}
} catch (manifestError) {
console.warn(
'Failed to fetch lossless manifest for download fallback',
manifestError
);
}
}
if (fallbackUrl) {
streamUrl = fallbackUrl;
response = await fetch(fallbackUrl, { signal: options?.signal });
if (response.status === 429) {
throw new Error(RATE_LIMIT_ERROR_MESSAGE);
}
if (!response.ok) {
throw new Error('Failed to fetch audio stream');
}
metadataLookup = manifestSource;
} else {
throw new Error('Could not extract stream URL from manifest');
}
}
}
if (response) {
const totalHeader = Number(response.headers.get('Content-Length') ?? '0');
totalBytes = Number.isFinite(totalHeader) && totalHeader > 0 ? totalHeader : undefined;
if (!response.body) {
downloadBlob = await response.blob();
receivedBytes = downloadBlob.size;
if (!totalBytes && receivedBytes > 0) {
options?.onProgress?.({
stage: 'downloading',
receivedBytes,
totalBytes: receivedBytes
});
}
} else {
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
receivedBytes += value.byteLength;
chunks.push(value);
options?.onProgress?.({
stage: 'downloading',
receivedBytes,
totalBytes
});
}
}
downloadBlob = new Blob(chunks as BlobPart[], {
type: response.headers.get('Content-Type') ?? 'application/octet-stream'
});
if (receivedBytes === 0) {
receivedBytes = downloadBlob.size;
}
}
contentType = response.headers.get('Content-Type');
}
options?.onProgress?.({
stage: 'downloading',
receivedBytes,
totalBytes: totalBytes ?? downloadBlob?.size
});
if (!downloadBlob) {
throw new Error('Download failed to produce audio payload');
}
const shouldConvertToMp3 =
options?.convertAacToMp3 === true && (quality === 'HIGH' || quality === 'LOW');
const processedBlob = await this.embedMetadataIntoBlob(
downloadBlob,
metadataLookup,
filename,
contentType,
options,
quality,
shouldConvertToMp3
);
const finalBlob = processedBlob ?? downloadBlob;
return { blob: finalBlob, mimeType: contentType ?? undefined };
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
if (error instanceof Error && error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
}
throw new Error(
'Download failed. The stream URL may require a proxy. Please try streaming instead.'
);
}
}
async getTrackStreamUrl(trackId: number, quality: AudioQuality = 'LOSSLESS'): Promise<string> {
if (this.isHiResQuality(quality)) {
quality = 'LOSSLESS';
}
const lookup = await this.getTrack(trackId, quality);
if (lookup.originalTrackUrl) {
return lookup.originalTrackUrl;
}
const fallback = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (!fallback) {
throw new Error('Could not resolve stream URL for track');
}
return fallback;
}
/**
* Download a track
* Fetches the audio stream and triggers a download
*/
async downloadTrack(
trackId: number,
quality: AudioQuality = 'LOSSLESS',
filename: string,
options?: DownloadTrackOptions
): Promise<void> {
try {
const { blob } = await this.fetchTrackBlob(trackId, quality, filename, options);
const url = URL.createObjectURL(blob);
// Trigger download
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Download cover separately if enabled
if (options?.downloadCoverSeperately) {
try {
const metadata = await this.getPreferredTrackMetadata(trackId, quality);
const coverId = metadata.track.album?.cover;
if (coverId) {
console.log('[Cover Download] Fetching cover for separate download...');
// Try multiple sizes as fallback
const coverSizes: Array<'1280' | '640' | '320'> = ['1280', '640', '320'];
let coverDownloadSuccess = false;
for (const size of coverSizes) {
if (coverDownloadSuccess) break;
const coverUrl = this.getCoverUrl(coverId, size);
console.log(`[Cover Download] Attempting size ${size}:`, coverUrl);
// Try two fetch strategies: with headers, then without
const fetchStrategies = [
{
name: 'with-headers',
options: {
method: 'GET' as const,
headers: {
Accept: 'image/jpeg,image/jpg,image/png,image/*',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
signal: AbortSignal.timeout(10000)
}
},
{
name: 'simple',
options: {
method: 'GET' as const,
signal: AbortSignal.timeout(10000)
}
}
];
for (const strategy of fetchStrategies) {
if (coverDownloadSuccess) break;
console.log(`[Cover Download] Trying strategy: ${strategy.name}`);
try {
const coverResponse = await fetch(coverUrl, strategy.options);
console.log(
`[Cover Download] Response status: ${coverResponse.status}, Content-Length: ${coverResponse.headers.get('Content-Length')}`
);
if (!coverResponse.ok) {
console.warn(
`[Cover Download] Failed with status ${coverResponse.status} for size ${size}`
);
continue;
}
const contentType = coverResponse.headers.get('Content-Type');
const contentLength = coverResponse.headers.get('Content-Length');
if (contentLength && parseInt(contentLength, 10) === 0) {
console.warn(`[Cover Download] Content-Length is 0 for size ${size}`);
continue;
}
if (contentType && !contentType.startsWith('image/')) {
console.warn(`[Cover Download] Invalid content type: ${contentType}`);
continue;
}
// Use arrayBuffer directly for more reliable data retrieval
const arrayBuffer = await coverResponse.arrayBuffer();
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
console.warn(`[Cover Download] Empty array buffer for size ${size}`);
continue;
}
const uint8Array = new Uint8Array(arrayBuffer);
console.log(`[Cover Download] Received ${uint8Array.length} bytes`);
console.log(
`[Cover Download] First 16 bytes:`,
Array.from(uint8Array.slice(0, 16))
.map((b) => b.toString(16).padStart(2, '0'))
.join(' ')
);
// Validate image data
if (!this.validateImageData(uint8Array)) {
console.warn(`[Cover Download] Invalid image data for size ${size}`);
continue;
}
// Detect image format
const imageFormat = this.detectImageFormat(uint8Array);
if (!imageFormat) {
console.warn(`[Cover Download] Unknown image format for size ${size}`);
continue;
}
// Create blob with correct MIME type
const coverBlob = new Blob([uint8Array], { type: imageFormat.mimeType });
const coverObjectUrl = URL.createObjectURL(coverBlob);
const coverLink = document.createElement('a');
coverLink.href = coverObjectUrl;
coverLink.download = `cover.${imageFormat.extension}`;
document.body.appendChild(coverLink);
coverLink.click();
document.body.removeChild(coverLink);
URL.revokeObjectURL(coverObjectUrl);
coverDownloadSuccess = true;
console.log(
`[Cover Download] Successfully downloaded (${size}x${size}, format: ${imageFormat.extension}, strategy: ${strategy.name})`
);
break;
} catch (sizeError) {
console.warn(
`[Cover Download] Failed at size ${size} with strategy ${strategy.name}:`,
sizeError
);
}
} // End strategy loop
} // End size loop
if (!coverDownloadSuccess) {
console.warn('[Cover Download] All attempts failed');
}
}
} catch (coverError) {
console.warn('Failed to download cover separately:', coverError);
}
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
console.error('Download failed:', error);
if (error instanceof Error && error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
}
throw new Error(
'Download failed. The stream URL may require a proxy. Please try streaming instead.'
);
}
}
/**
* Format duration from seconds to MM:SS
*/
formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Get cover URL
*/
getCoverUrl(coverId: string, size: '1280' | '640' | '320' | '160' | '80' = '640'): string {
return `https://resources.tidal.com/images/${coverId.replace(/-/g, '/')}/${size}x${size}.jpg`;
}
/**
* Get video cover URL
*/
getVideoCoverUrl(
videoCoverId: string,
size: '1280' | '640' | '320' | '160' | '80' = '640'
): string {
return `https://resources.tidal.com/videos/${videoCoverId.replace(/-/g, '/')}/${size}x${size}.mp4`;
}
/**
* Get artist picture URL
*/
getArtistPictureUrl(pictureId: string, size: '750' = '750'): string {
return `https://resources.tidal.com/images/${pictureId.replace(/-/g, '/')}/${size}x${size}.jpg`;
}
}
export const losslessAPI = new LosslessAPI();