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:
HoneyandClaude Opus 4.8 committed 2026-07-31 12:48:33 +02:00
1 parent a65613b5a1
commit 1e69831dd8
19 files changed
+619 -341

No files matched your search

+25 -1
View File
@@ -24,7 +24,8 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Multi-artist navigation | ✅ | ✅ | | Multi-artist navigation | ✅ | ✅ |
| Album detail page | ✅ | ✅ | | Album detail page | ✅ | ✅ |
| Artist page (Navidrome) | ✅ | ✅ | | Artist page (Navidrome) | ✅ | ✅ |
| Artist page (iTunes fallback) | ✅ | ❌ | | Artist page (Deezer fallback) | ✅ | ✅ |
| Global search source | Deezer | Deezer |
| Concert events (Ticketmaster) | ✅ | ✅ | | Concert events (Ticketmaster) | ✅ | ✅ |
| Genre browsing | ✅ | ✅ | | Genre browsing | ✅ | ✅ |
| Global search | ✅ | ✅ | | Global search | ✅ | ✅ |
@@ -55,6 +56,29 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
--- ---
## [1.3] — 2026-07-31
Big change: **the entire metadata/search stack moved from Apple's iTunes to Deezer** — on web, backend downloads *and* Android. Apple's iTunes `/search` returns **403 from server/datacenter IPs**, which had broken global search, charts, artist/album/single downloads and the metadata-editor autofill in production. Deezer is free, needs no key, allows more requests, and returns explicit versions + real artist images + track durations. Web and Android now share the **same** source, so an artist resolves to one consistent name everywhere.
### Changed
- **Web + Android + Backend** — Global search, album/artist lookups, artist top tracks and the "Neu & Beliebt" charts all run on **Deezer** (`api.deezer.com`). Results are mapped to the existing item shape, so the UI is unchanged; artist cards now show real photos (iTunes artist entities had none)
- **Backend (download)** — Single-track, album and **artist** downloads, plus the metadata-editor autofill, now enrich from Deezer:
- `backend/lib/deezer.js` — new module: `lookupTrackMeta`, `fetchAlbumTracks`, `searchAlbumTracks`, `fetchArtistAlbums`
- Album/artist `album_artist` still comes from the album-level artist (Deezer `album.artist`), keeping soundtracks from splitting
- Genre comes from the Deezer album (one album-level genre); per-track artist is preserved for mixed-artist albums
- The autofill endpoint moved `/api/itunes/lookup` → **`/api/meta/lookup`**
- **Web + Android** — All user-visible "iTunes" / "Apple Music" labels are now "Deezer" (search badge, charts badge, preview bar, editor autofill button, artist "populär" heading)
- **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup
### Added
- **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1`
### Notes
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v51`
- Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer
---
## [1.2] — 2026-07-25 ## [1.2] — 2026-07-25
Follow-up to 1.1: web history navigation, correct download tagging for mixed-artist albums, and UI polish. Follow-up to 1.1: web history navigation, correct download tagging for mixed-artist albums, and UI polish.
+27 -17
View File
@@ -20,14 +20,15 @@ plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies
## Layout ## Layout
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object); - `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
Subsonic/iTunes API in `src/api.js`; views in `src/views/`, UI in `src/ui/`. Subsonic/Deezer API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
- **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}` - **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}`
only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and
forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack
(that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path). (that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path).
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy). - `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`, - `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
`metadata.js` (iTunes lookup, file resolution helpers), `config.js`, `jobs.js`, `romaji.js`. `deezer.js` (Deezer metadata: track/album/artist lookups + matching),
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
- `android/` — native Kotlin app (separate; backend-dependent web features like the - `android/` — native Kotlin app (separate; backend-dependent web features like the
metadata editor are **not** in the Android app). metadata editor are **not** in the Android app).
@@ -46,12 +47,19 @@ 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` / - **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
`searchItunesAlbumTracks`). Was JP-first, which localized Western artists to katakana ("DJスネイク") server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
on some tracks and kept kanji on others → the *same* artist resolved differently between downloads. All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
US-only gives one consistent name per artist and halves the download's iTunes calls. Titles come `src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
from the source, so a Japanese title the user picked stays Japanese; only iTunes-derived fields explicit versions + real artist images + durations. Deezer generally gives one consistent name per
(album\_artist, genre, year, artwork) follow the US store. artist, so the old JP/US-store inconsistency (Western artists as katakana "DJスネイク" on some tracks,
kanji on others) is gone. Titles come from the source, so a Japanese title the user picked stays
Japanese; only Deezer-derived fields (album\_artist, genre, year, artwork) follow Deezer. The
Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
`lookupTrackMeta`/`fetchAlbumTracks`/`searchAlbumTracks`/`fetchArtistAlbums` replace the old
`lookupItunesTrackMeta`/`fetchItunesTracks`/`searchItunesAlbumTracks`. Editor autofill endpoint:
`/api/meta/lookup` (was `/api/itunes/lookup`). Matching logic (`pickTrackMatch`) is unit-tested in
`backend/test/deezer.test.js` (`npm test`).
- **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
@@ -74,8 +82,8 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2× - **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through. expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
- **Leading aesthetic characters:** artists often style their name with leading punctuation - **Leading aesthetic characters:** artists often style their name with leading punctuation
(`.diedlonely`, `†…`). `metadata.js` strips leading non-alphanumerics (`stripLeadSym`) before the (`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
iTunes lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only Deezer lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only
match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the
YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim
runs client-side in Android's `ExploreViewModel.search()`. runs client-side in Android's `ExploreViewModel.search()`.
@@ -85,10 +93,11 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
so without this every track on a soundtrack searched the album-artist blob + its channel filter and so without this every track on a soundtrack searched the album-artist blob + its channel filter and
matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag
still uses `albumArtist`, so album grouping is unaffected. still uses `albumArtist`, so album grouping is unaffected.
- **Album/artist download `album_artist`** comes from the iTunes tracks' `collectionArtistName` - **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
(album-level, identical across the album), derived in `novasonic-server.js` — **not** the request's (= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
`artist`, which can be the lead single's performer (a KPop soundtrack got tagged `album_artist=TWICE` **not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
from track 1 "TAKEDOWN"). Falls back to the request artist only when iTunes omits it. tagged `album_artist=TWICE` from track 1 "TAKEDOWN"). Falls back to the request artist only when
Deezer omits it.
- **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known. - **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known.
Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the
metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed. metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed.
@@ -103,7 +112,7 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan. (tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download - **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`. `albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
## Android offline system ## Android offline system
@@ -138,8 +147,9 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
`HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag. `HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag.
- **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon - **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails. (song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
- **iTunes artists have no artwork.** The Search API returns no `artworkUrl100` for artist entities; - **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
borrow the cover of one of their albums (`entity=album&limit=1`) — otherwise you get grey circles. album-cover borrowing needed. (The old iTunes artist entities had no `artworkUrl100`, which is why
the `entity=album&limit=1` album-cover fallback exists; it now only runs if a picture is missing.)
## UI / references ## UI / references
+6 -5
View File
@@ -10,9 +10,9 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row. - **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling. - **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session. - **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks. - **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks.
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android). - **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with iTunes metadata enrichment and duplicate detection. - **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save. - **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen. - **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync. - **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
@@ -51,20 +51,21 @@ NovaSonic/
│ │ └── settings.js # Settings page │ │ └── settings.js # Settings page
│ ├── state.js # Shared mutable state (S object) │ ├── state.js # Shared mutable state (S object)
│ ├── utils.js # Helpers (md5, esc, formatDur, toast) │ ├── utils.js # Helpers (md5, esc, formatDur, toast)
│ ├── api.js # Subsonic API, iTunes API, cover art │ ├── api.js # Subsonic API, Deezer API, cover art
│ └── main.js # App init, audio wiring, window globals │ └── main.js # App init, audio wiring, window globals
├── backend/ ├── backend/
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy │ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
│ └── lib/ │ └── lib/
│ ├── config.js # Env var → config object │ ├── config.js # Env var → config object
│ ├── jobs.js # In-memory download job store │ ├── jobs.js # In-memory download job store
│ ├── metadata.js # iTunes lookup, artwork, filename helpers │ ├── deezer.js # Deezer metadata: track/album/artist lookups
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding │ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks │ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
│ └── romaji.js # Japanese → romaji search index │ └── romaji.js # Japanese → romaji search index
├── android/ # Native Android app (Jetpack Compose + Media3) ├── android/ # Native Android app (Jetpack Compose + Media3)
│ └── app/src/main/kotlin/com/novasonic/app/ │ └── app/src/main/kotlin/com/novasonic/app/
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient │ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState │ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads) │ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
│ ├── player/ # PlayerController, MusicService (Media3) │ ├── player/ # PlayerController, MusicService (Media3)
@@ -8,64 +8,81 @@ import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
/**
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
* are kept for the same reason.
*/
object ItunesClient { object ItunesClient {
private const val BASE = "https://api.deezer.com"
private val http = HttpClient(Android) { private val http = HttpClient(Android) {
install(ContentNegotiation) { install(ContentNegotiation) {
// iTunes returns text/javascript instead of application/json — accept any content type
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any) json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
} }
} }
/**
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
*/
suspend fun search( suspend fun search(
query: String, query: String,
entity: String = "musicTrack,album,musicArtist", entity: String = "musicTrack,album,musicArtist",
limit: Int = 30, limit: Int = 30,
country: String = "us", country: String = "us",
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") { ): ItunesSearchResponse {
parameter("term", query) if (entity == "album") {
parameter("media", "music") val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
parameter("entity", entity) parameter("q", query); parameter("limit", limit)
parameter("limit", limit)
parameter("country", country)
}.body() }.body()
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> { }
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") { return coroutineScope {
parameter("id", collectionId) val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
parameter("entity", "song") val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
}.body() val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
// First result is the album itself; rest are tracks val results = artistsD.await().map { it.toItunesItem() } +
return resp.results.filter { it.kind == "song" } albumsD.await().map { it.toItunesItem() } +
tracksD.await().map { it.toItunesItem() }
ItunesSearchResponse(results = results)
}
} }
/** All albums for an iTunes artist */ /** All tracks of a Deezer album, in the ItunesItem song shape. */
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
}
/** A Deezer artist's real albums (not singles/EPs). */
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> { suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") { val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
parameter("id", artistId)
parameter("entity", "album")
parameter("limit", 200) parameter("limit", 200)
}.body() }.body()
return resp.results.filter { it.wrapperType == "collection" } return albums.data.filter { it.record_type == "album" }.map { it.toItunesItem() }
} }
/** Worldwide top tracks for an iTunes artist */ /** Top tracks for a Deezer artist. */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> { suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") { val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
parameter("id", artistId) parameter("limit", limit)
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
parameter("limit", limit + 1)
parameter("explicit", "Yes")
}.body() }.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit) return tracks.data.map { it.toItunesItem() }
} }
/** Top albums RSS feed for a given country */ /** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> { suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
val resp: ItunesRssResponse = val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body() parameter("limit", limit)
return resp.feed?.entry ?: emptyList() }.body()
return albums.data.map { it.toRssEntry() }
} }
} }
@@ -0,0 +1,129 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
// unchanged. See ItunesClient for the endpoint calls.
@Serializable
data class DzList<T>(val data: List<T> = emptyList())
@Serializable
data class DzArtist(
val id: Long? = null,
val name: String? = null,
val picture_medium: String? = null,
val picture_big: String? = null,
val picture_xl: String? = null,
)
@Serializable
data class DzGenre(val name: String? = null)
@Serializable
data class DzGenres(val data: List<DzGenre> = emptyList())
@Serializable
data class DzAlbum(
val id: Long? = null,
val title: String? = null,
val cover_medium: String? = null,
val cover_big: String? = null,
val cover_xl: String? = null,
val release_date: String? = null,
val nb_tracks: Int? = null,
val record_type: String? = null,
val genres: DzGenres? = null,
val artist: DzArtist? = null,
val tracks: DzList<DzTrack>? = null,
val position: Int? = null,
)
@Serializable
data class DzTrack(
val id: Long? = null,
val title: String? = null,
val duration: Long? = null,
val track_position: Int? = null,
val disk_number: Int? = null,
val explicit_lyrics: Boolean = false,
val preview: String? = null,
val artist: DzArtist? = null,
val album: DzAlbum? = null,
)
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id,
collectionId = album?.id,
artistName = artist?.name,
collectionName = album?.title,
trackName = title,
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position,
)
/** A Deezer album → ItunesItem collection. */
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "collection",
kind = "album",
collectionId = id,
artistId = artist?.id,
artistName = artist?.name,
collectionName = title,
artworkUrl100 = coverXl(),
trackCount = nb_tracks,
releaseDate = release_date,
primaryGenreName = genres?.data?.firstOrNull()?.name,
)
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "artist",
artistId = id,
artistName = name,
artworkUrl100 = pictureXl(),
)
/**
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
* track_position, so the album ordering supplies the number; genre/release come from the album.
*/
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id ?: album.artist?.id,
collectionId = album.id,
artistName = artist?.name ?: album.artist?.name,
collectionName = album.title,
trackName = title,
artworkUrl100 = album.coverXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position ?: (index + 1),
trackCount = album.nb_tracks,
releaseDate = album.release_date,
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
)
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
name = ItunesLabel(title),
artist = ItunesLabel(artist?.name),
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
)
@@ -364,7 +364,7 @@ fun ArtistDetailScreen(
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
) )
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text("iTunes Daten werden geladen…", fontFamily = Outfit, Text("Deezer Daten werden geladen…", fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2) fontSize = 12.sp, color = NsOnSurface2)
} }
} }
@@ -231,7 +231,7 @@ fun ItunesArtistScreen(
// iTunes top tracks // iTunes top tracks
if (artistState.iTracks.isNotEmpty()) { if (artistState.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär (iTunes)") } item { ArtistSectionHeader("Weltweit populär (Deezer)") }
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track -> itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
ItunesArtistTrackRow( ItunesArtistTrackRow(
track = track, track = track,
+163
View File
@@ -0,0 +1,163 @@
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,
};
+4 -126
View File
@@ -17,7 +17,7 @@ function navidromeArtist(name) {
.replace(/,\s*/g, ';'); .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) { async function downloadArtwork(url, dir) {
if (!url) return null; if (!url) return null;
const dest = path.join(dir, 'cover.jpg'); const dest = path.join(dir, 'cover.jpg');
@@ -112,133 +112,11 @@ async function fetchLyrics(artist, title, album) {
} catch { return null; } } catch { return null; }
} }
// Normalize a track title for comparison: unify variant suffixes so that // Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal. // iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
function normalizeTitle(s) { // fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
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([]));
});
}
module.exports = { module.exports = {
sanitize, navidromeArtist, sanitize, navidromeArtist,
downloadArtwork, findExistingTrack, downloadArtwork, findExistingTrack,
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
}; };
+61 -93
View File
@@ -17,7 +17,8 @@ const crypto = require('crypto');
const CONFIG = require('./lib/config'); const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs'); const { jobs, updateJob, log } = require('./lib/jobs');
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks, findExistingTrack } = require('./lib/metadata'); const { sanitize, findExistingTrack } = require('./lib/metadata');
const dz = require('./lib/deezer');
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji'); const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
@@ -274,7 +275,7 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
const batch = tracks.slice(i, i + CONC); const batch = tracks.slice(i, i + CONC);
const batchRes = await Promise.all(batch.map(async t => { const batchRes = await Promise.all(batch.map(async t => {
const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim(); const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
const meta = await lookupItunesTrackMeta(searchArtist, t.trackName || ''); const meta = await dz.lookupTrackMeta(searchArtist, t.trackName || '');
return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' }; return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' };
})); }));
results.push(...batchRes); results.push(...batchRes);
@@ -282,18 +283,18 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
res.json({ results }); res.json({ results });
}); });
// ─── ITUNES METADATA LOOKUP (must be before the proxy route) ───────────────── // ─── METADATA-EDITOR AUTOFILL ────────────────────────────────────────────────
app.get('/api/itunes/lookup', async (req, res, next) => { // Fills the editor from Deezer given artist + title (replaces the old iTunes autofill).
app.get('/api/meta/lookup', async (req, res) => {
const { artist, title } = req.query; const { artist, title } = req.query;
// ID-based lookups (?id=...) are not for track meta — let them fall through to the proxy. if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
if (!artist || !title) return next();
try { try {
const meta = await lookupItunesTrackMeta(artist, title); const meta = await dz.lookupTrackMeta(artist, title);
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false }); if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
res.json({ res.json({
found: true, found: true,
title: meta.trackName || undefined, title: meta.trackName || undefined,
artist: meta.itunesArtistName || undefined, artist: meta.metaArtistName || undefined,
albumArtist: meta.albumArtist || undefined, albumArtist: meta.albumArtist || undefined,
album: meta.album || undefined, album: meta.album || undefined,
year: meta.year || undefined, year: meta.year || undefined,
@@ -304,29 +305,14 @@ app.get('/api/itunes/lookup', async (req, res, next) => {
coverUrl: meta.artworkUrl || undefined, coverUrl: meta.artworkUrl || undefined,
}); });
} catch (e) { } catch (e) {
console.error('[itunes/lookup]', e.message); console.error('[meta/lookup]', e.message);
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });
} }
}); });
// ─── ITUNES PROXY ───────────────────────────────────────────────────────────── // Deezer API proxy — the metadata source for search, charts, album/artist lookups (Apple's /search
// Browsers can't call itunes.apple.com directly (no CORS headers). Proxy it here. // returns 403 from server IPs). Free, no key, returns explicit versions + artist images + durations.
// Note: /api/itunes/lookup is handled above — proxy only covers /search and id-based /lookup. // Covers /search, /search/album, /search/artist, /album/{id}, /artist/{id}/albums, /chart/…, etc.
app.get('/api/itunes/:endpoint', (req, res) => {
const allowed = new Set(['search', 'lookup']);
if (!allowed.has(req.params.endpoint)) return res.status(404).json({ error: 'not found' });
const qs = new URLSearchParams(req.query).toString();
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
res.setHeader('Content-Type', 'application/json');
res.status(upstream.statusCode || 200);
upstream.pipe(res);
}).on('error', e => res.status(502).json({ error: e.message }));
});
// Deezer API proxy — the search source (Apple's /search returns 403 from server IPs). Deezer is
// free, needs no key, and returns explicit versions + artist images + track durations. Covers
// /search, /search/album, /search/artist, /album/{id}, /album/{id}/tracks, /artist/{id}/albums, …
// Regex route (not '/api/deezer/*') — a bare "*" is an invalid path pattern in Express 5 and // Regex route (not '/api/deezer/*') — a bare "*" is an invalid path pattern in Express 5 and
// crashes the server at startup, taking every endpoint down with it. // crashes the server at startup, taking every endpoint down with it.
app.get(/^\/api\/deezer\/(.+)/, (req, res) => { app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
@@ -340,19 +326,6 @@ app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
}).on('error', e => res.status(502).json({ error: e.message })); }).on('error', e => res.status(502).json({ error: e.message }));
}); });
// Apple "most-played" charts RSS — the browser can't fetch rss.applemarketingtools.com directly
// (no CORS header), so proxy it. kind = albums | songs; country defaults to de.
app.get('/api/itunes-charts/:kind', (req, res) => {
const kind = req.params.kind === 'songs' ? 'songs' : 'albums';
const country = String(req.query.country || 'de').replace(/[^a-z]/gi, '').slice(0, 2).toLowerCase() || 'de';
const target = `https://rss.applemarketingtools.com/api/v2/${country}/music/most-played/50/${kind}.json`;
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
res.setHeader('Content-Type', 'application/json');
res.status(upstream.statusCode || 200);
upstream.pipe(res);
}).on('error', e => res.status(502).json({ error: e.message }));
});
app.get('/api/events/:artist', (req, res) => { app.get('/api/events/:artist', (req, res) => {
const key = CONFIG.ticketmasterKey; const key = CONFIG.ticketmasterKey;
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); } if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
@@ -545,27 +518,27 @@ app.post('/api/youtube/resolve', async (req, res) => {
artworkUrl: entries[0]?.thumbnail || '', tracks }); artworkUrl: entries[0]?.thumbnail || '', tracks });
} }
// Single video — enrich with iTunes // Single video — enrich with Deezer
const e = entries[0]; const e = entries[0];
const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || ''); const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || '');
const title = e.title || ''; const title = e.title || '';
const sourceUrl = e.webpage_url || url; const sourceUrl = e.webpage_url || url;
let itunes = {}; let meta = {};
try { itunes = await lookupItunesTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {} try { meta = await dz.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
const artist = itunes.artist || rawArtist; const artist = meta.artist || rawArtist;
const artworkUrl = itunes.artworkUrl || e.thumbnail || ''; const artworkUrl = meta.artworkUrl || e.thumbnail || '';
return res.json({ return res.json({
type: 'track', name: title, artist, artworkUrl, type: 'track', name: title, artist, artworkUrl,
tracks: [{ tracks: [{
trackName: title, trackName: title,
artistName: artist, artistName: artist,
artworkUrl: artworkUrl, artworkUrl: artworkUrl,
collectionName: itunes.album || '', collectionName: meta.album || '',
releaseDate: itunes.year ? String(itunes.year) : '', releaseDate: meta.year ? String(meta.year) : '',
trackNumber: itunes.trackNum || undefined, trackNumber: meta.trackNum || undefined,
durationMs: itunes.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined), durationMs: meta.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: itunes.genre || '', genre: meta.genre || '',
sourceUrl, sourceUrl,
}], }],
}); });
@@ -609,19 +582,19 @@ app.post('/api/download', async (req, res) => {
}; };
const searchArtist = artist.split(',')[0].trim(); const searchArtist = artist.split(',')[0].trim();
if (!meta.album) { if (!meta.album) {
log(jobId, '📋 Suche Album-Metadaten von iTunes…'); log(jobId, '📋 Suche Album-Metadaten von Deezer…');
const extra = await lookupItunesTrackMeta(searchArtist, title); const extra = await dz.lookupTrackMeta(searchArtist, title);
if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`); if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`);
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined }; meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
} else { } else {
log(jobId, `📋 Album: "${meta.album}"`); log(jobId, `📋 Album: "${meta.album}"`);
// Always fetch iTunes when any field is missing — durationMs is required for the yt-dlp // Always fetch Deezer when any field is missing — durationMs is required for the yt-dlp
// duration filter; without it any unrelated video that matches the title text gets downloaded. // duration filter; without it any unrelated video that matches the title text gets downloaded.
if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum || !meta.year) { if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum || !meta.year) {
// Strip release-type suffixes before iTunes lookup so "recognize - Single" → "recognize" // Strip release-type suffixes before the Deezer lookup so "recognize - Single" → "recognize"
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i; const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const itunesTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title; const lookupTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
const genreMeta = await lookupItunesTrackMeta(searchArtist, itunesTitle).catch(() => ({})); const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
if (!meta.genre && genreMeta.genre) { meta.genre = genreMeta.genre; log(jobId, `📋 Genre: "${genreMeta.genre}"`); } if (!meta.genre && genreMeta.genre) { meta.genre = genreMeta.genre; log(jobId, `📋 Genre: "${genreMeta.genre}"`); }
if (!meta.year && genreMeta.year) { meta.year = genreMeta.year; log(jobId, `📋 Jahr: ${genreMeta.year}`); } if (!meta.year && genreMeta.year) { meta.year = genreMeta.year; log(jobId, `📋 Jahr: ${genreMeta.year}`); }
if (!meta.totalTracks && genreMeta.totalTracks) { meta.totalTracks = genreMeta.totalTracks; } if (!meta.totalTracks && genreMeta.totalTracks) { meta.totalTracks = genreMeta.totalTracks; }
@@ -631,7 +604,7 @@ app.post('/api/download', async (req, res) => {
if (!meta.trackNum && genreMeta.trackNum) { meta.trackNum = genreMeta.trackNum; } if (!meta.trackNum && genreMeta.trackNum) { meta.trackNum = genreMeta.trackNum; }
} }
} }
const finalArtist = meta.itunesArtistName || artist; const finalArtist = meta.metaArtistName || artist;
const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined }); const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
if (result?.skipped) { if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
@@ -643,26 +616,26 @@ app.post('/api/download', async (req, res) => {
} else if (type === 'album') { } else if (type === 'album') {
// Prefer a track list supplied in the request body (Deezer albums send their tracks directly, // Prefer a track list supplied in the request body (Deezer albums send their tracks directly,
// pre-mapped to the iTunes track shape). Fall back to the iTunes lookup for legacy callers. // pre-mapped to the ItunesItem shape). Fall back to a Deezer lookup for legacy callers.
let tracks = Array.isArray(req.body.tracks) ? req.body.tracks : []; let tracks = Array.isArray(req.body.tracks) ? req.body.tracks : [];
if (tracks.length) log(jobId, `📋 ${tracks.length} Tracks aus Anfrage (Deezer)`); if (tracks.length) log(jobId, `📋 ${tracks.length} Tracks aus Anfrage (Deezer)`);
if (!tracks.length && collectionId) { if (!tracks.length && collectionId) {
log(jobId, '📋 Lade Track-Liste von iTunes…'); log(jobId, '📋 Lade Track-Liste von Deezer…');
tracks = await fetchItunesTracks(collectionId); tracks = await dz.fetchAlbumTracks(collectionId);
} }
if (!tracks.length) { if (!tracks.length) {
log(jobId, '🔍 Suche Album auf iTunes…'); log(jobId, '🔍 Suche Album auf Deezer…');
tracks = await searchItunesAlbumTracks(artist, album || title); tracks = await dz.searchAlbumTracks(artist, album || title);
} }
if (!tracks.length) { if (!tracks.length) {
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`); throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die Global-Suche herunterladen.`);
} }
// Album artist: prefer iTunes' album-level collectionArtistName (identical across every track) // Album artist: prefer the album-level collectionArtistName (identical across every track)
// 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.
const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist; const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist;
if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (iTunes)`); if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (Deezer)`);
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;
const someSkipped = dlSkipped > 0 && !allSkipped; const someSkipped = dlSkipped > 0 && !allSkipped;
@@ -675,19 +648,14 @@ app.post('/api/download', async (req, res) => {
} else if (type === 'artist') { } else if (type === 'artist') {
log(jobId, `🎤 Künstler-Download: ${artist}`); log(jobId, `🎤 Künstler-Download: ${artist}`);
const artistData = await new Promise((resolve, reject) => { // Deezer discography: search the artist, take their real albums (not singles/EPs).
https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => { const artistAlbums = await dz.fetchArtistAlbums(artist, 10).catch(() => []);
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 artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
log(jobId, `📀 ${artistAlbums.length} Alben gefunden`); log(jobId, `📀 ${artistAlbums.length} Alben gefunden`);
let done = 0; let done = 0;
for (const alb of artistAlbums) { for (const alb of artistAlbums) {
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []); const tracks = await dz.fetchAlbumTracks(alb.collectionId).catch(() => []);
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 the 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.
const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || 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 }); await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
@@ -708,46 +676,46 @@ app.post('/api/download', async (req, res) => {
const batchResults = await Promise.allSettled(batch.map(async track => { const batchResults = await Promise.allSettled(batch.map(async track => {
const trackTitle = track.trackName || ''; const trackTitle = track.trackName || '';
const trackArtist = track.artistName || artist || ''; const trackArtist = track.artistName || artist || '';
// Strip release-type suffixes before iTunes lookup ("Song - Single" → "Song") // Strip release-type suffixes before the Deezer lookup ("Song - Single" → "Song")
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i; const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const itunesTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle; const lookupTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle;
// If the frontend already enriched this track with iTunes data (collectionName + // If the frontend already enriched this track with Deezer data (collectionName +
// trackNumber + durationMs all present), skip the re-lookup to avoid a different // trackNumber + durationMs all present), skip the re-lookup to avoid a different
// iTunes result overriding the already-correct enriched metadata. // Deezer result overriding the already-correct enriched metadata.
const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs); const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs);
const searchArtist = trackArtist.split(',')[0].trim(); const searchArtist = trackArtist.split(',')[0].trim();
let itunes = {}; let meta = {};
if (alreadyEnriched) { if (alreadyEnriched) {
log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`); log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`);
// Spotify enrichment copies album/trackNum/duration but not genre/totalTracks/disc — fetch those // Spotify enrichment copies album/trackNum/duration but not genre/totalTracks/disc — fetch those
const genreMeta = await lookupItunesTrackMeta(searchArtist, itunesTitle).catch(() => ({})); const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
itunes = { meta = {
genre: genreMeta.genre, genre: genreMeta.genre,
totalTracks: genreMeta.totalTracks, totalTracks: genreMeta.totalTracks,
discNum: genreMeta.discNum, discNum: genreMeta.discNum,
discTotal: genreMeta.discTotal, discTotal: genreMeta.discTotal,
}; };
} else { } else {
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`); log(jobId, `📋 Deezer: "${trackArtist} – ${trackTitle}"`);
itunes = await lookupItunesTrackMeta(searchArtist, itunesTitle); meta = await dz.lookupTrackMeta(searchArtist, lookupTitle);
} }
// Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders. // Prefer the resolved name (from server lookup or frontend enrichment) for sort tags / folders.
// trackArtist stays as rawArtist for main tags and YTM search. // trackArtist stays as rawArtist for main tags and YTM search.
const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist; const resolvedArtist = meta.metaArtistName || track.metaArtistName || trackArtist;
return downloadTrack({ return downloadTrack({
jobId, jobId,
artist: resolvedArtist, // iTunes English name → used for sort tags + folder names artist: resolvedArtist, // resolved (Deezer) name → used for sort tags + folder names
rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search
albumArtist: resolvedArtist, albumArtist: resolvedArtist,
title: trackTitle, title: trackTitle,
album: itunes.album || track.collectionName || track.album || '', album: meta.album || track.collectionName || track.album || '',
year: itunes.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year, year: meta.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
trackNum: itunes.trackNum || track.trackNumber || undefined, trackNum: meta.trackNum || track.trackNumber || undefined,
totalTracks: itunes.totalTracks || undefined, totalTracks: meta.totalTracks || undefined,
genre: itunes.genre || track.primaryGenreName || track.genre || undefined, genre: meta.genre || track.primaryGenreName || track.genre || undefined,
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '', artworkUrl: meta.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit', explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.durationMs || track.durationMs || undefined, durationMs: meta.durationMs || track.durationMs || undefined,
sourceUrl: track.sourceUrl || undefined, sourceUrl: track.sourceUrl || undefined,
}); });
})); }));
+3
View File
@@ -1,6 +1,9 @@
{ {
"name": "novasonic-backend", "name": "novasonic-backend",
"private": true, "private": true,
"scripts": {
"test": "node --test"
},
"dependencies": { "dependencies": {
"express": "^4.18.0", "express": "^4.18.0",
"kuromoji": "^0.1.2" "kuromoji": "^0.1.2"
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// Unit tests for the Deezer metadata mapping/matching (backend/lib/deezer.js).
// Pure logic only — no network. Run: `node --test` from backend/ (or `npm test`).
// A live smoke test against api.deezer.com is included but only runs with RUN_LIVE=1.
const test = require('node:test');
const assert = require('node:assert/strict');
const dz = require('../lib/deezer');
// ── Fixtures: the exact Deezer shapes the mappers consume ─────────────────────
const ALBUM = {
id: 302127,
title: 'Discovery',
cover_xl: 'https://img/cover_xl.jpg',
cover_big: 'https://img/cover_big.jpg',
release_date: '2001-03-07',
nb_tracks: 14,
record_type: 'album',
genres: { data: [{ id: 113, name: 'Dance' }] },
artist: { id: 27, name: 'Daft Punk' },
};
const TRACK = {
id: 3135556,
title: 'Harder, Better, Faster, Stronger',
duration: 224,
explicit_lyrics: false,
preview: 'https://img/preview.mp3',
artist: { id: 27, name: 'Daft Punk' },
};
test('mapAlbumTrack: numbers by album order and inherits album-level fields', () => {
const item = dz.mapAlbumTrack(TRACK, ALBUM, 3); // 4th track
assert.equal(item.wrapperType, 'track');
assert.equal(item.kind, undefined); // shape has no kind here; downloadAlbumTracks doesn't read it
assert.equal(item.trackNumber, 4); // index + 1 (Deezer album tracks carry no track_position)
assert.equal(item.trackName, 'Harder, Better, Faster, Stronger');
assert.equal(item.collectionName, 'Discovery');
assert.equal(item.collectionArtistName, 'Daft Punk');
assert.equal(item.primaryGenreName, 'Dance');
assert.equal(item.releaseDate, '2001-03-07');
assert.equal(item.trackCount, 14);
assert.equal(item.trackTimeMillis, 224000); // seconds → ms
assert.equal(item.trackExplicitness, 'notExplicit');
assert.equal(item.artworkUrl100, 'https://img/cover_xl.jpg');
});
test('mapAlbumTrack: per-track artist wins over the album artist (mixed-artist albums)', () => {
const feat = { ...TRACK, artist: { id: 99, name: 'MeloMance' } };
const item = dz.mapAlbumTrack(feat, ALBUM, 0);
assert.equal(item.artistName, 'MeloMance'); // drives the per-track YTM search
assert.equal(item.collectionArtistName, 'Daft Punk'); // album_artist stays stable → no album split
});
test('mapAlbumTrack: explicit flag maps through', () => {
const item = dz.mapAlbumTrack({ ...TRACK, explicit_lyrics: true }, ALBUM, 0);
assert.equal(item.trackExplicitness, 'explicit');
});
test('pickTrackMatch: strict match on artist + title', () => {
const results = [
{ title: 'Some Other Song', artist: { name: 'Daft Punk' } },
{ title: 'Harder, Better, Faster, Stronger', artist: { name: 'Daft Punk' } },
];
const m = dz.pickTrackMatch(results, 'Daft Punk', 'Harder, Better, Faster, Stronger');
assert.equal(m, results[1]);
});
test('pickTrackMatch: title-only fallback for non-Japanese when artist differs', () => {
const results = [{ title: 'Levitating', artist: { name: 'Some Cover Act' } }];
const m = dz.pickTrackMatch(results, 'Dua Lipa', 'Levitating');
assert.equal(m, results[0]); // ASCII title → title-only fallback allowed
});
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
const results = [{ title: '残酷な天使のテーゼ', artist: { name: 'Someone Else' } }];
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
assert.equal(m, null); // JP title + wrong artist must not match a same-titled cover
});
test('pickTrackMatch: Japanese title DOES match when the artist matches', () => {
const results = [{ title: '残酷な天使のテーゼ', artist: { name: '高橋洋子' } }];
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
assert.equal(m, results[0]);
});
test('pickTrackMatch: leading aesthetic punctuation is ignored on the artist', () => {
const results = [{ title: 'nostalgia', artist: { name: '.diedlonely' } }];
const m = dz.pickTrackMatch(results, '.diedlonely', 'nostalgia');
assert.equal(m, results[0]);
});
test('pickTrackMatch: empty / no match returns null', () => {
assert.equal(dz.pickTrackMatch([], 'x', 'y'), null);
assert.equal(dz.pickTrackMatch([{ title: 'A', artist: { name: 'B' } }], 'C', 'D'), null);
});
test('normalizeTitle: unifies variant suffixes and punctuation', () => {
assert.equal(dz.normalizeTitle("don't rush - slowed"), dz.normalizeTitle("don't rush (slowed)"));
assert.equal(dz.normalizeTitle('Mr. Lawrence'), dz.normalizeTitle('Mr Lawrence'));
});
// ── Live smoke test (opt-in): confirms the real Deezer shape still maps ────────
test('LIVE: lookupTrackMeta returns enriched fields', { skip: process.env.RUN_LIVE !== '1' }, async () => {
const meta = await dz.lookupTrackMeta('Daft Punk', 'Harder, Better, Faster, Stronger');
assert.ok(meta.album, 'album present');
assert.ok(meta.genre, 'genre present');
assert.ok(meta.year, 'year present');
assert.ok(meta.durationMs > 0, 'duration present');
});
+7 -7
View File
@@ -172,7 +172,7 @@
<div class="album-header"> <div class="album-header">
<div class="album-cover" id="itunes-album-cover-wrap"><div class="no-art">💿</div></div> <div class="album-cover" id="itunes-album-cover-wrap"><div class="no-art">💿</div></div>
<div class="album-meta"> <div class="album-meta">
<div class="album-type">iTunes</div> <div class="album-type">Deezer</div>
<div class="album-name" id="itunes-album-detail-name">—</div> <div class="album-name" id="itunes-album-detail-name">—</div>
<div class="album-artist-name" id="itunes-album-detail-artist"></div> <div class="album-artist-name" id="itunes-album-detail-artist"></div>
<div class="album-stats" id="itunes-album-detail-stats"></div> <div class="album-stats" id="itunes-album-detail-stats"></div>
@@ -333,7 +333,7 @@
<div class="page" id="page-discover"> <div class="page" id="page-discover">
<div class="page-header"> <div class="page-header">
<div class="page-title"><span class="blue">Global</span> Suche</div> <div class="page-title"><span class="blue">Global</span> Suche</div>
<span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">iTunes Store</span> <span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">Deezer</span>
</div> </div>
<div class="search-wrap" style="padding:0 0 20px"> <div class="search-wrap" style="padding:0 0 20px">
<div class="search-box" style="background:var(--surface);border-color:var(--border2)"> <div class="search-box" style="background:var(--surface);border-color:var(--border2)">
@@ -350,7 +350,7 @@
<div class="page" id="page-new-releases"> <div class="page" id="page-new-releases">
<div class="page-header"> <div class="page-header">
<div class="page-title">Neue <em>Releases</em></div> <div class="page-title">Neue <em>Releases</em></div>
<span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">iTunes Charts</span> <span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">Deezer Charts</span>
</div> </div>
<div class="section-header" style="margin-top:0"><span class="section-title">Top Alben weltweit</span></div> <div class="section-header" style="margin-top:0"><span class="section-title">Top Alben weltweit</span></div>
<div id="nr-albums" class="grid grid-4"></div> <div id="nr-albums" class="grid grid-4"></div>
@@ -545,12 +545,12 @@
</button> </button>
</div> </div>
<!-- PREVIEW BAR (iTunes 30s) --> <!-- PREVIEW BAR (Deezer 30s) -->
<div class="preview-bar" id="preview-bar"> <div class="preview-bar" id="preview-bar">
<div class="player-art" id="preview-art" style="width:42px;height:42px;border-radius:6px"><div class="no-art" style="font-size:16px">♪</div></div> <div class="player-art" id="preview-art" style="width:42px;height:42px;border-radius:6px"><div class="no-art" style="font-size:16px">♪</div></div>
<div class="preview-info"> <div class="preview-info">
<div class="preview-title" id="preview-title">—<span class="preview-badge">30s Preview</span></div> <div class="preview-title" id="preview-title">—<span class="preview-badge">30s Preview</span></div>
<div class="preview-sub" id="preview-sub">iTunes Store</div> <div class="preview-sub" id="preview-sub">Deezer</div>
</div> </div>
<div class="preview-prog"><div class="preview-fill" id="preview-fill"></div></div> <div class="preview-prog"><div class="preview-fill" id="preview-fill"></div></div>
<button class="preview-close" onclick="stopPreview()">✕</button> <button class="preview-close" onclick="stopPreview()">✕</button>
@@ -996,9 +996,9 @@
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
MusicBrainz MusicBrainz
</button> </button>
<button class="btn-outline blue" style="font-size:11px;padding:6px 10px;height:fit-content" onclick="autofillItunes()"> <button class="btn-outline blue" style="font-size:11px;padding:6px 10px;height:fit-content" onclick="autofillDeezer()">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Apple Music Deezer
</button> </button>
</div> </div>
<div class="info-edit-fields"> <div class="info-edit-fields">
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v50'; const CACHE = 'ns-v51';
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.
+17 -41
View File
@@ -267,23 +267,11 @@ export async function romajiConvert(texts) {
} catch { return texts; } } catch { return texts; }
} }
// ─── ITUNES ─── // ─── DEEZER (search + charts source) ───
// Route through backend proxy to avoid CORS; fall back to direct if no backend configured. // Apple's iTunes /search returns 403 from server IPs, so Deezer is the metadata backend for
function _itunesBase() { // everything: search, album/artist lookups AND the "Neu & Beliebt" charts. Free, no key, returns
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/itunes` : 'https://itunes.apple.com'; // explicit versions + artist images + track durations. Proxied through the backend to avoid any
} // CORS surprises. All results are mapped to the legacy "ItunesItem" shape so the UI + download
// Apple charts RSS — proxied through the backend (browsers can't fetch rss.applemarketingtools.com;
// no CORS header). kind = albums | songs.
function _chartsUrl(country, kind) {
return S.cfg.backendUrl
? `${S.cfg.backendUrl}/api/itunes-charts/${kind}?country=${country}`
: `https://rss.applemarketingtools.com/api/v2/${country}/music/most-played/50/${kind}.json`;
}
// ─── DEEZER (search source) ───
// Apple's iTunes /search returns 403 from server IPs, so Deezer is the search backend. Free, no key,
// returns explicit versions + artist images + track durations. Proxied through the backend to avoid
// any CORS surprises. All results are mapped to the iTunes "ItunesItem" shape so the UI + download
// flow stay unchanged. // flow stay unchanged.
function _deezerBase() { function _deezerBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com'; return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com';
@@ -380,39 +368,27 @@ export async function deezerArtistImage(artistId) {
} catch { return null; } } catch { return null; }
} }
// "Neu & Beliebt" — Deezer's global chart (Apple's charts RSS needed a proxy and the search
// fallback is 403 from server IPs). Mapped to { name, artistName, artworkUrl, artworkUrl100 }.
export async function itunesTopCharts() { export async function itunesTopCharts() {
const urls = [_chartsUrl('de', 'albums'), _chartsUrl('us', 'albums')];
for(const url of urls) {
try { try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) }); const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`);
if(!r.ok) continue;
const d = await r.json(); const d = await r.json();
if(d.feed?.results?.length) return d.feed.results; return (d.data || []).map(a => ({
} catch {} name: a.title, artistName: a.artist?.name || '',
} artworkUrl: a.cover_big || a.cover_medium || '', artworkUrl100: a.cover_medium || '',
try {
const r = await fetch(`${_itunesBase()}/search?term=new+music&media=music&entity=album&limit=50&country=de`);
const d = await r.json();
return (d.results||[]).map(a => ({
name: a.collectionName, artistName: a.artistName,
artworkUrl: a.artworkUrl100?.replace('100x100','300x300'), artworkUrl100: a.artworkUrl100,
})); }));
} catch { return []; } } catch { return []; }
} }
export async function itunesTopSongs() { export async function itunesTopSongs() {
const urls = [_chartsUrl('de', 'songs'), _chartsUrl('us', 'songs')];
for(const url of urls) {
try { try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) }); const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`);
if(!r.ok) continue;
const d = await r.json(); const d = await r.json();
if(d.feed?.results?.length) return d.feed.results; return (d.data || []).map(t => ({
} catch {} name: t.title, artistName: t.artist?.name || '',
} artworkUrl: t.album?.cover_medium || t.album?.cover_big || '',
try { previewUrl: t.preview || '',
const r = await fetch(`${_itunesBase()}/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de`); }));
const d = await r.json();
return (d.results||[]).map(t => ({ name: t.trackName, artistName: t.artistName, artworkUrl: t.artworkUrl100 }));
} catch { return []; } } catch { return []; }
} }
+2 -2
View File
@@ -46,7 +46,7 @@ import {
toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleMobileSearch, closeMobileSearch, switchSetupTab,
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate, toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
_pickTheme, _pickTheme,
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong,
addRomajiOverrideUI, removeRomajiOverrideUI, addRomajiOverrideUI, removeRomajiOverrideUI,
openRomajiOverridesModal, closeRomajiOverridesModal, openRomajiOverridesModal, closeRomajiOverridesModal,
} from './ui/ui.js'; } from './ui/ui.js';
@@ -334,7 +334,7 @@ Object.assign(window, {
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack, loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload, executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong,
openDlModal, closeDlModal, selectDlType, executeDownload, openDlModal, closeDlModal, selectDlType, executeDownload,
openEditPlModal, closeEditPlModal, saveEditPlaylist, openEditPlModal, closeEditPlModal, saveEditPlaylist,
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
+6 -6
View File
@@ -207,18 +207,18 @@ export async function autofillMusicBrainz() {
} }
} }
export async function autofillItunes() { export async function autofillDeezer() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong; const s = S.infoSong;
if (!s) return; if (!s) return;
const artist = document.getElementById('ie-artist').value || s.artist || ''; const artist = document.getElementById('ie-artist').value || s.artist || '';
const title = document.getElementById('ie-title').value || s.title || ''; const title = document.getElementById('ie-title').value || s.title || '';
const status = document.getElementById('info-edit-status'); const status = document.getElementById('info-edit-status');
status.textContent = '🔍 Apple Music…'; status.textContent = '🔍 Deezer…';
try { try {
const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`); const r = await fetch(`${S.cfg.backendUrl}/api/meta/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const d = await r.json(); const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; } if (!d.found) { status.textContent = '❌ Kein Treffer auf Deezer'; return; }
if (d.title) document.getElementById('ie-title').value = d.title; if (d.title) document.getElementById('ie-title').value = d.title;
if (d.artist) document.getElementById('ie-artist').value = _fmtArtists(d.artist, '; '); if (d.artist) document.getElementById('ie-artist').value = _fmtArtists(d.artist, '; ');
if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', '); if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', ');
@@ -232,9 +232,9 @@ export async function autofillItunes() {
S._infoArtData = { url: d.coverUrl }; S._infoArtData = { url: d.coverUrl };
document.getElementById('info-edit-art-preview').innerHTML = document.getElementById('info-edit-art-preview').innerHTML =
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`; `<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
status.textContent = '✅ Metadaten + Cover von Apple Music'; status.textContent = '✅ Metadaten + Cover von Deezer';
} else { } else {
status.textContent = '✅ Metadaten von Apple Music (kein Cover)'; status.textContent = '✅ Metadaten von Deezer (kein Cover)';
} }
} catch (e) { } catch (e) {
status.textContent = '❌ Fehler: ' + e.message; status.textContent = '❌ Fehler: ' + e.message;
+1 -1
View File
@@ -210,7 +210,7 @@ export async function loadArtistDetail(artist) {
} }
} }
if(iTracks.length) { if(iTracks.length) {
const heading = topSongs.length ? 'Weltweit populär (iTunes)' : 'Populäre Titel'; const heading = topSongs.length ? 'Weltweit populär (Deezer)' : 'Populäre Titel';
const sec = document.createElement('div'); const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-top:${topSongs.length?'8px':'0'};margin-bottom:10px">${heading}</div>`; sec.innerHTML = `<div class="section-title" style="margin-top:${topSongs.length?'8px':'0'};margin-bottom:10px">${heading}</div>`;
const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px'; const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px';
+8 -8
View File
@@ -247,7 +247,7 @@ function _setEnrichStatus(done, current, total) {
if (!el) return; if (!el) return;
if (done) { if (done) {
el.className = 'done'; el.className = 'done';
el.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> iTunes-Daten geladen'; el.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> Deezer-Daten geladen';
el.style.display = 'flex'; el.style.display = 'flex';
el.style.opacity = '1'; el.style.opacity = '1';
setTimeout(() => { setTimeout(() => {
@@ -256,7 +256,7 @@ function _setEnrichStatus(done, current, total) {
}, 2000); }, 2000);
} else { } else {
el.className = ''; el.className = '';
el.innerHTML = `<div class="enrich-spinner"></div> Lade iTunes-Daten… (${current}/${total})`; el.innerHTML = `<div class="enrich-spinner"></div> Lade Deezer-Daten… (${current}/${total})`;
el.style.display = 'flex'; el.style.display = 'flex';
el.style.opacity = '1'; el.style.opacity = '1';
} }
@@ -274,7 +274,7 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
_setArt(el, url); _setArt(el, url);
}); });
// Enrich each track with iTunes data — same call as global search // Enrich each track with Deezer data — same call as global search
const CONC = 4; const CONC = 4;
let enriched = 0; let enriched = 0;
_setEnrichStatus(false, 0, tracks.length); _setEnrichStatus(false, 0, tracks.length);
@@ -287,15 +287,15 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
const results = await itunesSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1); const results = await itunesSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1);
const match = results[0]; const match = results[0];
if (!match) return; if (!match) return;
// Always safe to enrich: artwork, preview, explicit, iTunes artist name // Always safe to enrich: artwork, preview, explicit, canonical artist name
if (match.artworkUrl100) t.artworkUrl100 = match.artworkUrl100; if (match.artworkUrl100) t.artworkUrl100 = match.artworkUrl100;
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness; if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
if (match.previewUrl) t.previewUrl = match.previewUrl; if (match.previewUrl) t.previewUrl = match.previewUrl;
// Store iTunes canonical name separately — keeps original Spotify/Japanese in artistName // Store the Deezer canonical name separately — keeps original Spotify/Japanese in artistName
// so the UI shows the original and YTM search uses the original channel name. // so the UI shows the original and YTM search uses the original channel name.
if (match.artistName) t.itunesArtistName = match.artistName; if (match.artistName) t.metaArtistName = match.artistName;
// Only enrich album/duration if we're confident the iTunes match is the correct song. // Only enrich album/duration if we're confident the Deezer match is the correct song.
// For Japanese titles we can't word-match, so we skip — the backend's lookupItunesTrackMeta // For Japanese titles we can't word-match, so we skip — the backend's lookupTrackMeta
// handles it with better matching. For ASCII titles, require at least one shared word. // handles it with better matching. For ASCII titles, require at least one shared word.
const JP_RE = /[ぁ-んァ-ン一-龯]/; const JP_RE = /[ぁ-んァ-ン一-龯]/;
const isJapanese = JP_RE.test(t.trackName); const isJapanese = JP_RE.test(t.trackName);