Files
NovaSonic/backend/lib/deezer.js
T
HoneyandClaude Opus 4.8 1e69831dd8 feat: move the whole metadata/search stack from iTunes to Deezer (1.3)
Apple's iTunes /search returns 403 from server/datacenter IPs, which had broken global
search, charts, album/artist/single downloads and the metadata-editor autofill in
production. Everything now sources metadata from Deezer (free, no key, more requests,
returns explicit versions + real artist images + durations). Web + Android share one
source, so an artist resolves to one consistent name everywhere. Deezer results are
mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.

Backend:
- New backend/lib/deezer.js: lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks,
  fetchArtistAlbums (+ pure pickTrackMatch/mapAlbumTrack for tests).
- metadata.js drops the iTunes lookups; novasonic-server download paths (track, album,
  artist, playlist enrich, spotify artwork) + autofill now use Deezer.
- Artist download uses Deezer discography (real albums only), not the iTunes album search.
- Editor autofill endpoint /api/itunes/lookup -> /api/meta/lookup; removed the dead
  iTunes /search proxy and the Apple charts RSS proxy.
- backend/test/deezer.test.js: unit tests for numbering, mixed-artist album-artist
  stability, explicit flags, the Japanese-title match guard (npm test; RUN_LIVE=1 for live).

Web:
- api.js search/lookups already on Deezer; charts now use Deezer /chart. Artist cards
  show the real Deezer photo. Visible "iTunes"/"Apple Music" labels -> "Deezer"
  (search badge, charts badge, preview bar, editor button, artist heading). SW ns-v51.

Android:
- New DeezerModels.kt with Dz* models + mappers to ItunesItem/ItunesRssEntry.
- ItunesClient is Deezer-backed (search = tracks+albums+artists in parallel; album/artist
  lookups; /chart charts), same method signatures. Visible strings -> Deezer.

Docs: README, CLAUDE.md and CHANGELOG (1.3) updated; Platform Parity Matrix reflects the
shared Deezer source + Android non-local artist page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 12:48:33 +02:00

164 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
const titleOnly = results.find(t => normalizeTitle(t.title) === normT);
return strict || (!isJp ? titleOnly || null : 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,
};