feat: move the whole metadata/search stack from iTunes to Deezer (1.3)
Apple's iTunes /search returns 403 from server/datacenter IPs, which had broken global search, charts, album/artist/single downloads and the metadata-editor autofill in production. Everything now sources metadata from Deezer (free, no key, more requests, returns explicit versions + real artist images + durations). Web + Android share one source, so an artist resolves to one consistent name everywhere. Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged. Backend: - New backend/lib/deezer.js: lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks, fetchArtistAlbums (+ pure pickTrackMatch/mapAlbumTrack for tests). - metadata.js drops the iTunes lookups; novasonic-server download paths (track, album, artist, playlist enrich, spotify artwork) + autofill now use Deezer. - Artist download uses Deezer discography (real albums only), not the iTunes album search. - Editor autofill endpoint /api/itunes/lookup -> /api/meta/lookup; removed the dead iTunes /search proxy and the Apple charts RSS proxy. - backend/test/deezer.test.js: unit tests for numbering, mixed-artist album-artist stability, explicit flags, the Japanese-title match guard (npm test; RUN_LIVE=1 for live). Web: - api.js search/lookups already on Deezer; charts now use Deezer /chart. Artist cards show the real Deezer photo. Visible "iTunes"/"Apple Music" labels -> "Deezer" (search badge, charts badge, preview bar, editor button, artist heading). SW ns-v51. Android: - New DeezerModels.kt with Dz* models + mappers to ItunesItem/ItunesRssEntry. - ItunesClient is Deezer-backed (search = tracks+albums+artists in parallel; album/artist lookups; /chart charts), same method signatures. Visible strings -> Deezer. Docs: README, CLAUDE.md and CHANGELOG (1.3) updated; Platform Parity Matrix reflects the shared Deezer source + Android non-local artist page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a65613b5a1
commit
1e69831dd8
19 files changed
+628
-350
No files matched your search
+25
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
}.body()
|
||||||
parameter("country", country)
|
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
|
||||||
}.body()
|
}
|
||||||
|
return coroutineScope {
|
||||||
|
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
|
||||||
|
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
|
||||||
|
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
|
||||||
|
val results = artistsD.await().map { it.toItunesItem() } +
|
||||||
|
albumsD.await().map { it.toItunesItem() } +
|
||||||
|
tracksD.await().map { it.toItunesItem() }
|
||||||
|
ItunesSearchResponse(results = results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All tracks of a Deezer album, in the ItunesItem song shape. */
|
||||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
|
||||||
parameter("id", collectionId)
|
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
|
||||||
parameter("entity", "song")
|
|
||||||
}.body()
|
|
||||||
// First result is the album itself; rest are tracks
|
|
||||||
return resp.results.filter { it.kind == "song" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All albums for an iTunes artist */
|
/** 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("limit", 200)
|
||||||
parameter("entity", "album")
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
};
|
};
|
||||||
+70
-102
@@ -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,51 +283,36 @@ 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,
|
||||||
trackNum: meta.trackNum || undefined,
|
trackNum: meta.trackNum || undefined,
|
||||||
totalTracks: meta.totalTracks || undefined,
|
totalTracks: meta.totalTracks || undefined,
|
||||||
discNum: meta.discNum || undefined,
|
discNum: meta.discNum || undefined,
|
||||||
genre: meta.genre || undefined,
|
genre: meta.genre || undefined,
|
||||||
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,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
|
||||||
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
|
||||||
if(!r.ok) continue;
|
|
||||||
const d = await r.json();
|
|
||||||
if(d.feed?.results?.length) return d.feed.results;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_itunesBase()}/search?term=new+music&media=music&entity=album&limit=50&country=de`);
|
const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
return (d.results||[]).map(a => ({
|
return (d.data || []).map(a => ({
|
||||||
name: a.collectionName, artistName: a.artistName,
|
name: a.title, artistName: a.artist?.name || '',
|
||||||
artworkUrl: a.artworkUrl100?.replace('100x100','300x300'), artworkUrl100: a.artworkUrl100,
|
artworkUrl: a.cover_big || a.cover_medium || '', artworkUrl100: a.cover_medium || '',
|
||||||
}));
|
}));
|
||||||
} 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 {
|
|
||||||
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
|
||||||
if(!r.ok) continue;
|
|
||||||
const d = await r.json();
|
|
||||||
if(d.feed?.results?.length) return d.feed.results;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${_itunesBase()}/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de`);
|
const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
return (d.results||[]).map(t => ({ name: t.trackName, artistName: t.artistName, artworkUrl: t.artworkUrl100 }));
|
return (d.data || []).map(t => ({
|
||||||
|
name: t.title, artistName: t.artist?.name || '',
|
||||||
|
artworkUrl: t.album?.cover_medium || t.album?.cover_big || '',
|
||||||
|
previewUrl: t.preview || '',
|
||||||
|
}));
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user