fix: use the US iTunes store everywhere for consistent artist naming

Search (web api.js) was JP-first, the download lookups were JP-first, and the Android
client was already US — so the same artist resolved inconsistently: sometimes kanji,
sometimes katakana, sometimes romaji, depending on which path and which store matched.

Switch everything to the US store:
- api.js itunesSearch → country=us (one fetch, matches the Android client)
- metadata.js lookupItunesTrackMeta → US only (drops the JP fetch: consistent names +
  halves the download's iTunes calls, which also eases the rate limit)
- searchItunesAlbumTracks → country=us
- remove the JP-katakana guards in the album/artist download paths (redundant now, and
  they wrongly romanized genuine Japanese artists like 羽岡佳 → "Kei Haneoka")

Titles still come from the source, so a Japanese title the user picked stays Japanese;
only iTunes-derived fields (album_artist, genre, year, artwork) follow the US store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-29 10:37:07 +02:00
1 parent d98978a6c7
commit 9c8b4775ea
5 files changed
+23 -32

No files matched your search

+6
View File
@@ -46,6 +46,12 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
## Download pipeline (hard-won knowledge) ## Download pipeline (hard-won knowledge)
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed). - Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
- **iTunes store = US everywhere** (`api.js` search, `metadata.js` `lookupItunesTrackMeta` /
`searchItunesAlbumTracks`). Was JP-first, which localized Western artists to katakana ("DJスネイク")
on some tracks and kept kanji on others → the *same* artist resolved differently between downloads.
US-only gives one consistent name per artist and halves the download's iTunes calls. Titles come
from the source, so a Japanese title the user picked stays Japanese; only iTunes-derived fields
(album\_artist, genre, year, artwork) follow the US store.
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the - **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
+10 -16
View File
@@ -168,24 +168,18 @@ async function lookupItunesTrackMeta(artist, title) {
); );
const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT); const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT);
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names // US store only — one consistent naming source (English/romaji) across search + download, so the
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]); // same artist always resolves the same way instead of "sometimes Japanese, sometimes romaji". A
let match = strictMatch(jpResults); // single request instead of JP+US also halves the download's iTunes calls (helps the rate limit).
// titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists — const usResults = await fetchItunes('us');
// common anime/OST titles exist on both JP and US stores under different artists.
// Only use it as a fallback when neither title nor artist contains Japanese characters.
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/; const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA); const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
let usMatch = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults)); // titleOnlyMatch (no artist check) is too permissive when the title is Japanese — common anime/
if (!match) match = usMatch; // 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 {}; if (!match) return {};
// For non-Japanese songs, take the NAMES from the US store: the JP store returns katakana- const naming = match;
// localized artist / album-artist names for Western artists (e.g. "DJスネイク" for DJ Snake), const rawGenre = match.primaryGenreName;
// which would otherwise become the album_artist tag and the on-disk folder. Japanese songs keep
// the JP names (correct kanji). Numeric fields (year, track#, duration) are store-agnostic.
const naming = (!isJpSearch && usMatch) ? usMatch : match;
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
const rawGenre = (usMatch || match).primaryGenreName;
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined; const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
return { return {
album: naming.collectionName || '', album: naming.collectionName || '',
@@ -225,7 +219,7 @@ async function fetchItunesTracks(collectionId) {
async function searchItunesAlbumTracks(artist, album) { async function searchItunesAlbumTracks(artist, album) {
return new Promise(resolve => { return new Promise(resolve => {
const q = encodeURIComponent(`${artist} ${album}`); const q = encodeURIComponent(`${artist} ${album}`);
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=jp&explicit=Yes`, res => { https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => {
let d = ''; let d = '';
res.on('data', c => d += c); res.on('data', c => d += c);
res.on('end', () => { res.on('end', () => {
+2 -9
View File
@@ -628,11 +628,7 @@ app.post('/api/download', async (req, res) => {
// over the request's `artist`, which can be a single track's performer — e.g. the lead single // over the request's `artist`, which can be a single track's performer — e.g. the lead single
// "TAKEDOWN" is by TWICE, so `artist` arrives as "TWICE" and would tag the whole soundtrack's // "TAKEDOWN" is by TWICE, so `artist` arrives as "TWICE" and would tag the whole soundtrack's
// album_artist as TWICE. Keeps album_artist consistent so Navidrome doesn't split the album. // album_artist as TWICE. Keeps album_artist consistent so Navidrome doesn't split the album.
let albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist; const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist;
// The JP store localizes Western artists to katakana ("DJスネイク"). If iTunes' album artist is
// Japanese but the request artist isn't, keep the request's English name (tag + folder).
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
if (albumArtistName && artist && JP_RE.test(albumArtistName) && !JP_RE.test(artist)) albumArtistName = artist;
if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (iTunes)`); if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (iTunes)`);
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist: albumArtistName, albumTitle: album || title, tracks, artworkUrl }); const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist: albumArtistName, albumTitle: album || title, tracks, artworkUrl });
const allSkipped = dlSkipped === tracks.length; const allSkipped = dlSkipped === tracks.length;
@@ -660,10 +656,7 @@ app.post('/api/download', async (req, res) => {
if (tracks.length) { if (tracks.length) {
// Same as the album path: album_artist from iTunes' album-level artist, so a collaboration // Same as the album path: album_artist from iTunes' album-level artist, so a collaboration
// or soundtrack in the artist's discography isn't tagged with the searched artist. // or soundtrack in the artist's discography isn't tagged with the searched artist.
let albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist; const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist;
// Don't let the JP store's katakana album-artist override a non-Japanese searched artist.
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
if (albArtist && artist && JP_RE.test(albArtist) && !JP_RE.test(artist)) albArtist = artist;
await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 }); await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
} }
done++; done++;
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v46'; const CACHE = 'ns-v47';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+4 -6
View File
@@ -275,12 +275,10 @@ function _itunesBase() {
export async function itunesSearch(query, entity='musicTrack', limit=25) { export async function itunesSearch(query, entity='musicTrack', limit=25) {
try { try {
const base = `${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&explicit=Yes`; // US store only — one consistent naming source across search + download (matches the Android
const rJp = await fetch(`${base}&country=jp`); // client, which is already US). A single fetch also halves the iTunes request rate.
const jpResults = JSON.parse(await rJp.text()).results || []; const r = await fetch(`${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=us&explicit=Yes`);
if (jpResults.length) return jpResults; return JSON.parse(await r.text()).results || [];
const rUs = await fetch(`${base}&country=us`);
return JSON.parse(await rUs.text()).results || [];
} catch { return []; } } catch { return []; }
} }