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>
This commit is contained in:
1 parent
a65613b5a1
commit
1e69831dd8
19 files changed
+628
-350
No files matched your search
+4
-126
@@ -17,7 +17,7 @@ function navidromeArtist(name) {
|
||||
.replace(/,\s*/g, ';');
|
||||
}
|
||||
|
||||
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
||||
// Download hi-res cover artwork (1000x1000) to cover.jpg, return path or null
|
||||
async function downloadArtwork(url, dir) {
|
||||
if (!url) return null;
|
||||
const dest = path.join(dir, 'cover.jpg');
|
||||
@@ -112,133 +112,11 @@ async function fetchLyrics(artist, title, album) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Normalize a track title for comparison: unify variant suffixes so that
|
||||
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal.
|
||||
function normalizeTitle(s) {
|
||||
return (s || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
// convert "title - variant" → "title (variant)" so both forms compare equal
|
||||
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
|
||||
// strip all remaining parens/brackets content for the base comparison
|
||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
||||
// strip punctuation so "Mr. Lawrence" == "Mr Lawrence" and comma variants match
|
||||
.replace(/[,.'!?]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// iTunes JP store returns localized genre names — map them to English equivalents
|
||||
const JP_GENRE_MAP = {
|
||||
'アニメ': 'Anime', 'イージーリスニング': 'Easy Listening', 'ポップ': 'Pop',
|
||||
'J-Pop': 'J-Pop', 'ロック': 'Rock', 'クラシック': 'Classical', 'ジャズ': 'Jazz',
|
||||
'エレクトロニック': 'Electronic', 'R&B/ソウル': 'R&B/Soul', 'ヒップホップ/ラップ': 'Hip-Hop/Rap',
|
||||
'オルタナティブ': 'Alternative', 'サウンドトラック': 'Soundtrack', 'ボーカル': 'Vocal',
|
||||
'インストゥルメンタル': 'Instrumental', 'ワールド': 'World', 'フォーク': 'Folk',
|
||||
'カントリー': 'Country', 'レゲエ': 'Reggae', 'ソウル': 'Soul', 'ブルース': 'Blues',
|
||||
'メタル': 'Metal', 'パンク': 'Punk', 'ダンス': 'Dance', 'コメディ': 'Comedy',
|
||||
'スピーチ': 'Speech', 'キッズ/ファミリー': 'Kids/Family',
|
||||
};
|
||||
function toEnglishGenre(g) { return JP_GENRE_MAP[g] || g; }
|
||||
|
||||
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
|
||||
async function lookupItunesTrackMeta(artist, title) {
|
||||
try {
|
||||
// Search with base title (strip variant suffix) for better iTunes hit rate —
|
||||
// e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single
|
||||
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
||||
const searchTitle = baseTitle !== title ? baseTitle : title;
|
||||
// Strip leading aesthetic punctuation many artists use (".diedlonely", "†", "✝", "_x")
|
||||
// so the iTunes search actually surfaces them instead of a same-titled track by someone else.
|
||||
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
const searchArtist = stripLeadSym(artist.split(/[;,&]/)[0].trim());
|
||||
const fetchItunes = (country) => new Promise((resolve, reject) => {
|
||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchArtist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=${country}&explicit=Yes`;
|
||||
https.get(url, res => {
|
||||
let d = ''; res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
|
||||
}).on('error', reject);
|
||||
});
|
||||
const normT = normalizeTitle(title);
|
||||
const normA = (searchArtist || '').toLowerCase().trim();
|
||||
// Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain,"
|
||||
const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0];
|
||||
const strictMatch = results => results.find(t =>
|
||||
normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA)
|
||||
);
|
||||
const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT);
|
||||
|
||||
// US store only — one consistent naming source (English/romaji) across search + download, so the
|
||||
// same artist always resolves the same way instead of "sometimes Japanese, sometimes romaji". A
|
||||
// single request instead of JP+US also halves the download's iTunes calls (helps the rate limit).
|
||||
const usResults = await fetchItunes('us');
|
||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
|
||||
// titleOnlyMatch (no artist check) is too permissive when the title is Japanese — common anime/
|
||||
// OST titles exist under different artists — so only use it as a fallback for non-Japanese titles.
|
||||
const match = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults));
|
||||
if (!match) return {};
|
||||
const naming = match;
|
||||
const rawGenre = match.primaryGenreName;
|
||||
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
|
||||
return {
|
||||
album: naming.collectionName || '',
|
||||
albumArtist: naming.collectionArtistName || undefined,
|
||||
trackName: naming.trackName || undefined,
|
||||
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
|
||||
trackNum: match.trackNumber || undefined,
|
||||
totalTracks: match.trackCount || undefined,
|
||||
discNum: match.discNumber || undefined,
|
||||
discTotal: match.discCount || undefined,
|
||||
genre: genre,
|
||||
artworkUrl: (naming.artworkUrl100 || match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
|
||||
durationMs: match.trackTimeMillis || undefined,
|
||||
itunesArtistName: naming.artistName || undefined,
|
||||
};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
// Fetch individual tracks for an iTunes collectionId
|
||||
async function fetchItunesTracks(collectionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
|
||||
https.get(url, res => {
|
||||
let data = '';
|
||||
res.on('data', d => data += d);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
|
||||
} catch { reject(new Error('iTunes API parse error')); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
|
||||
async function searchItunesAlbumTracks(artist, album) {
|
||||
return new Promise(resolve => {
|
||||
const q = encodeURIComponent(`${artist} ${album}`);
|
||||
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const results = JSON.parse(d).results || [];
|
||||
const match = results.find(r =>
|
||||
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
|
||||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
|
||||
);
|
||||
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
|
||||
else resolve([]);
|
||||
} catch { resolve([]); }
|
||||
});
|
||||
}).on('error', () => resolve([]));
|
||||
});
|
||||
}
|
||||
// Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
|
||||
// iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
|
||||
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
|
||||
|
||||
module.exports = {
|
||||
sanitize, navidromeArtist,
|
||||
downloadArtwork, findExistingTrack,
|
||||
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
|
||||
};
|
||||
Reference in new issue
Block a user