import { isValidSpotifyUrl } from '../../utils/network/validation.util.js'; import { secureFetch } from '../../utils/network/security.util.js'; import { recordFailure } from '../../utils/infra/metrics.util.js'; interface DeezerTrack { id: number; title: string; duration: number; preview: string; isrc?: string; artist: { name: string; }; } interface DeezerSearchResponse { data: DeezerTrack[]; total: number; } interface ExternalLookupResult { isrc: string | null; preview: string | null; } async function searchDeezer(query: string): Promise { const response = await secureFetch( `https://api.deezer.com/search?q=${encodeURIComponent(query)}` ); return response.json(); } export async function fetchIsrcFromDeezer( title: string, artist: string, isrc: string | null = null, targetDurationMs = 0 ): Promise { try { if (isrc) { const response = await secureFetch( `https://api.deezer.com/track/isrc:${isrc}` ); const data: unknown = await response.json(); if (typeof data === 'object' && data !== null) { const track = data as { error?: unknown; preview?: string; isrc?: string; }; if (!track.error && track.preview) { return { isrc: track.isrc || isrc, preview: track.preview }; } } } let searchData = await searchDeezer(`artist:"${artist}" track:"${title}"`); if (!searchData.data?.length) searchData = await searchDeezer(`${title} ${artist}`); const cleanTitle = title.replace(/\s*[[()].*?[)\]]/gu, '').trim(); if (!searchData.data?.length && cleanTitle !== title) searchData = await searchDeezer(`${cleanTitle} ${artist}`); if (searchData.data?.length) { const best = searchData.data.find((track) => { const artistMatch = track.artist.name.toLowerCase().includes(artist.toLowerCase()) || artist.toLowerCase().includes(track.artist.name.toLowerCase()); const isTargetValid = targetDurationMs > 30000; const durationMatch = isTargetValid ? Math.abs(track.duration * 1000 - targetDurationMs) < 10000 : true; return artistMatch && durationMatch; }) || searchData.data[0]; if ( targetDurationMs > 30000 && Math.abs(best.duration * 1000 - targetDurationMs) > 20000 ) return null; const detailRes = await secureFetch( `https://api.deezer.com/track/${best.id}` ); const detailData: unknown = await detailRes.json(); if (typeof detailData === 'object' && detailData !== null) { const detail = detailData as { isrc?: string }; return { isrc: detail.isrc || null, preview: best.preview || null }; } } } catch (error: unknown) { recordFailure('resolve:deezer'); console.debug('[SpotifyExternal] Deezer error:', (error as Error).message); } return null; } interface ItunesResult { trackTimeMillis: number; isrc?: string; previewUrl?: string; } interface ItunesResponse { results: ItunesResult[]; } export async function fetchIsrcFromItunes( title: string, artist: string, isrc: string | null = null, targetDurationMs = 0 ): Promise { try { const query = isrc || `${title} ${artist}`; const response = await secureFetch( `https://itunes.apple.com/search?term=${encodeURIComponent(query)}&limit=5&entity=song` ); const data: ItunesResponse = await response.json(); if (data.results?.length) { const isTargetValid = targetDurationMs > 30000; const best = isTargetValid ? data.results.sort( (first, second) => Math.abs(first.trackTimeMillis - targetDurationMs) - Math.abs(second.trackTimeMillis - targetDurationMs) )[0] : data.results[0]; if ( isTargetValid && Math.abs(best.trackTimeMillis - targetDurationMs) > 20000 ) return null; return { isrc: best.isrc || null, preview: best.previewUrl || null }; } } catch (error: unknown) { recordFailure('resolve:itunes'); console.debug('[SpotifyExternal] iTunes error:', (error as Error).message); } return null; } interface OdesliEntity { title?: string; artistName?: string; thumbnailUrl?: string; durationSeconds?: number; isrc?: string; platforms?: string[]; } interface OdesliResponse { entitiesByUniqueId: Record; linksByPlatform: Record; entityUniqueId: string; } export interface OdesliResult { id?: string; targetUrl: string | null; title: string; artist: string; thumbnailUrl: string; duration: number; isrc: string | null; source: string; } export async function fetchFromOdesli( spotifyUrl: string, signal?: AbortSignal ): Promise { if (!isValidSpotifyUrl(spotifyUrl)) return null; try { const parsed = new URL(spotifyUrl); const target = `${parsed.protocol}//${parsed.hostname}${parsed.pathname}${parsed.search}`; const response = await secureFetch( `https://api.odesli.co/v1-alpha.1/links?url=${encodeURIComponent(target)}`, { signal } ); if (!response.ok) return null; const data: OdesliResponse = await response.json(); return processOdesliData(data); } catch (error) { recordFailure('resolve:odesli'); console.debug('Ignored:', (error as Error).message); return null; } } function _getSpotifyEntity(data: OdesliResponse) { return Object.values(data.entitiesByUniqueId).find((ent) => ent.platforms?.includes('spotify') ); } function _getYoutubeLink(data: OdesliResponse) { return ( data.linksByPlatform?.youtube?.url || data.linksByPlatform?.youtubeMusic?.url ); } function _getTargetEntity(data: OdesliResponse) { const targetId = data.linksByPlatform?.youtube?.entityUniqueId || data.linksByPlatform?.youtubeMusic?.entityUniqueId || data.entityUniqueId; return data.entitiesByUniqueId[targetId]; } // process odesli data function processOdesliData(data: OdesliResponse): OdesliResult | null { const spotifyEntity = _getSpotifyEntity(data); const youtubeLink = _getYoutubeLink(data); if (!youtubeLink && !spotifyEntity) return null; const entity = _getTargetEntity(data); return { targetUrl: youtubeLink || null, title: entity?.title || spotifyEntity?.title || 'Unknown Title', artist: entity?.artistName || spotifyEntity?.artistName || 'Unknown Artist', thumbnailUrl: entity?.thumbnailUrl || spotifyEntity?.thumbnailUrl || '', duration: (spotifyEntity?.durationSeconds || 0) * 1000, isrc: spotifyEntity?.isrc || null, source: 'odesli', }; }