diff --git a/CLAUDE.md b/CLAUDE.md index 39c3c89..0cba2b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,12 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a ## Download pipeline (hard-won knowledge) - 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 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 diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index 72673dc..0aea128 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -168,24 +168,18 @@ async function lookupItunesTrackMeta(artist, title) { ); 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 - const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]); - let match = strictMatch(jpResults); - // titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists — - // 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. + // 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); - let usMatch = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults)); - if (!match) match = usMatch; + // 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 {}; - // For non-Japanese songs, take the NAMES from the US store: the JP store returns katakana- - // localized artist / album-artist names for Western artists (e.g. "DJスネイク" for DJ Snake), - // 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 naming = match; + const rawGenre = match.primaryGenreName; const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined; return { album: naming.collectionName || '', @@ -225,7 +219,7 @@ async function fetchItunesTracks(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=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 = ''; res.on('data', c => d += c); res.on('end', () => { diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index bec6f82..2eb6a4c 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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 // "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. - let 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; + const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist; 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 allSkipped = dlSkipped === tracks.length; @@ -660,10 +656,7 @@ app.post('/api/download', async (req, res) => { if (tracks.length) { // 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. - let 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; + const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist; await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 }); } done++; diff --git a/public/sw.js b/public/sw.js index b443590..e1771bf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v46'; +const CACHE = 'ns-v47'; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. diff --git a/src/api.js b/src/api.js index 10be670..06eeae1 100644 --- a/src/api.js +++ b/src/api.js @@ -275,12 +275,10 @@ function _itunesBase() { export async function itunesSearch(query, entity='musicTrack', limit=25) { try { - const base = `${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&explicit=Yes`; - const rJp = await fetch(`${base}&country=jp`); - const jpResults = JSON.parse(await rJp.text()).results || []; - if (jpResults.length) return jpResults; - const rUs = await fetch(`${base}&country=us`); - return JSON.parse(await rUs.text()).results || []; + // US store only — one consistent naming source across search + download (matches the Android + // client, which is already US). A single fetch also halves the iTunes request rate. + const r = await fetch(`${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=us&explicit=Yes`); + return JSON.parse(await r.text()).results || []; } catch { return []; } }