const https = require('https'); // ─── DEEZER METADATA SOURCE ─────────────────────────────────────────────────── // Apple's iTunes /search returns 403 from server/datacenter IPs, so all server-side // metadata (single-track enrichment, album track lists, artist discography, the // metadata-editor autofill) comes from Deezer. Free, no key, ~50 req/5s, returns // explicit flags + artist images + durations. Results are mapped to the same // "ItunesItem" shape the download pipeline (downloadAlbumTracks / downloadTrack) // already consumes, so nothing downstream had to change. const DZ = 'https://api.deezer.com'; function dzGet(subpath) { return new Promise((resolve, reject) => { https.get(`${DZ}/${subpath}`, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d) || {}); } catch { reject(new Error('Deezer parse error')); } }); }).on('error', reject); }); } // ─── matching helpers (local copies to avoid a circular require with metadata.js) ─ function normalizeTitle(s) { return (s || '') .toLowerCase().trim() .replace(/\s+-\s+([^-]+)$/, ' ($1)') .replace(/[\[((][^\]))]*[\]))]/g, '') .replace(/[,.'!?]/g, '') .replace(/\s+/g, ' ') .trim(); } const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim(); const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0]; const yearOf = d => { const y = d ? new Date(d).getFullYear() : NaN; return Number.isFinite(y) ? y : undefined; }; // Deezer covers come in fixed sizes; cover_xl is 1000x1000 (matches the iTunes hi-res artwork). const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || a?.cover || ''; // Map one album track (from /album/{id} .tracks.data — these carry no track_position, // so the album's own ordering supplies the number) to the iTunes track shape. function mapAlbumTrack(t, al, index) { return { wrapperType: 'track', trackId: t.id, trackName: t.title, artistName: t.artist?.name || al.artist?.name || '', // per-track artist (mixed albums) collectionName: al.title, collectionArtistName: al.artist?.name || '', // album-level artist (stable) collectionType: al.record_type === 'compilation' ? 'Compilation' : 'Album', artworkUrl100: coverXl(al), releaseDate: al.release_date || undefined, primaryGenreName: al.genres?.data?.[0]?.name || undefined, trackNumber: index + 1, trackCount: al.nb_tracks || undefined, trackTimeMillis: t.duration ? t.duration * 1000 : undefined, trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit', previewUrl: t.preview || '', }; } // Pick the best track from a Deezer /search result list for (artist, title). Pure + exported // so the matching rules are unit-testable. Strict = same title AND same first artist word; // falls back to a title-only match, but never for Japanese titles (same title, different artist // is common for anime/OST). Returns the matched Deezer track or null. function pickTrackMatch(results, artist, title) { if (!Array.isArray(results) || !results.length) return null; const normT = normalizeTitle(title); const normA = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()).toLowerCase().trim(); const strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA)); const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/; const isJp = JP_RE.test(normT) || JP_RE.test(normA); // Loose artist match: same title AND the artist names correspond (equal, or one contains the // other after stripping all non-alphanumerics — covers "Okawari" vs "DJ Okawari"). This REPLACES // the old blind title-only fallback, which grabbed a same-titled song by a completely different // artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and applied its wrong album/cover/year. const _n = s => stripLeadSym((s || '').toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, ''); const wantA = _n(normA); const artistOk = candA => { if (!wantA || !candA) return false; if (candA === wantA) return true; const short = Math.min(candA.length, wantA.length); return short >= 3 && (candA.includes(wantA) || wantA.includes(candA)); }; const loose = results.find(t => normalizeTitle(t.title) === normT && artistOk(_n(t.artist?.name))); return strict || (isJp ? null : loose) || null; } // Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta. // Fills album/year/trackNum/genre/duration/artwork when the caller only has artist+title. // Returns {} when nothing convincing matches (callers treat that as "no enrichment"). async function lookupTrackMeta(artist, title) { try { const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title; const searchTitle = baseTitle !== title ? baseTitle : title; const searchArtist = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()); const q = encodeURIComponent(`${searchArtist} ${searchTitle}`); const results = (await dzGet(`search?q=${q}&limit=10`)).data || []; if (!results.length) return {}; const match = pickTrackMatch(results, artist, title); if (!match) return {}; // The search result lacks genre / track number / release date — the album endpoint carries them. const al = match.album?.id ? await dzGet(`album/${match.album.id}`).catch(() => ({})) : {}; // track_position + disk_number live on the full /track record, not on the search hit. const tr = match.id ? await dzGet(`track/${match.id}`).catch(() => ({})) : {}; return { album: match.album?.title || al.title || '', albumArtist: al.artist?.name || undefined, trackName: match.title || undefined, year: yearOf(tr.release_date || al.release_date), trackNum: tr.track_position || undefined, totalTracks: al.nb_tracks || undefined, discNum: tr.disk_number || undefined, discTotal: undefined, genre: al.genres?.data?.[0]?.name || undefined, artworkUrl: coverXl(al) || coverXl(match.album) || '', durationMs: (tr.duration || match.duration) ? (tr.duration || match.duration) * 1000 : undefined, metaArtistName: match.artist?.name || undefined, }; } catch { return {}; } } // All tracks of a Deezer album id, in the legacy ItunesItem track shape. async function fetchAlbumTracks(albumId) { try { const al = await dzGet(`album/${albumId}`); return (al.tracks?.data || []).map((t, i) => mapAlbumTrack(t, al, i)); } catch { return []; } } // Album fallback when no album id is known: search Deezer for the album, then its tracks. async function searchAlbumTracks(artist, album) { try { const q = encodeURIComponent(`${artist} ${album}`); const results = (await dzGet(`search/album?q=${q}&limit=5`)).data || []; const lc = s => (s || '').toLowerCase(); const match = results.find(r => lc(r.title).includes(lc(album)) || lc(r.artist?.name).includes(lc(artist)) ) || results[0]; return match ? await fetchAlbumTracks(match.id) : []; } catch { return []; } } // Artist discography for the artist-download: search the artist, take real albums // (record_type "album", not singles/EPs), return {collectionId, collectionName, artworkUrl100}. async function fetchArtistAlbums(artistName, limit = 10) { try { const artists = (await dzGet(`search/artist?q=${encodeURIComponent(artistName)}&limit=1`)).data || []; if (!artists.length) return []; const albums = (await dzGet(`artist/${artists[0].id}/albums?limit=100`)).data || []; return albums .filter(a => a.record_type === 'album') .slice(0, limit) .map(a => ({ collectionId: a.id, collectionName: a.title, artistName: artists[0].name, artworkUrl100: coverXl(a), })); } catch { return []; } } module.exports = { dzGet, lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks, fetchArtistAlbums, // exported for unit tests mapAlbumTrack, pickTrackMatch, normalizeTitle, };