getYtmVideoUrls wertete jedes /browse/UC in der Ausgabe als "yt-dlp hat einen Artist-Channel aufgeklappt" und gab [] zurueck — samt der bereits gesammelten Song-URLs. Bei "DAT ADAM UFO" stand der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf Platz 1; uebrig blieb der YouTube-Fallback mit einem Live-Mitschnitt. An 8 echten Suchen gemessen feuerte das in 7 Faellen, Schritt 1 der Kaskade war also praktisch tot. Mit --flat-playlist kann gar nichts aufgeklappt werden, ein Kanal ist EINE Zeile. pickYtmVideoUrls() behaelt daher alle watch?v=-Zeilen und ueberspringt browse/UC (Kanal) wie browse/MPREb_ (Album). Ueber 20k Zufallsausgaben geprueft: die neue Funktion ist eine Obermenge der alten, kann also nur Kandidaten hinzufuegen. Drei echte Downloads liefen end-to-end sauber durch (Dauer sekundengenau auf Deezer, korrekte Tags), alle aus Schritt 1 statt aus dem Fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
21 KiB
Markdown
252 lines
21 KiB
Markdown
# CLAUDE.md
|
||
|
||
Guidance for Claude Code when working in this repository.
|
||
|
||
## What this is
|
||
|
||
NovaSonic — a self-hosted music streaming **PWA** (Vite + vanilla ES modules) and
|
||
**Android app** (Jetpack Compose + Media3/ExoPlayer) for Navidrome / Subsonic servers,
|
||
plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies APIs.
|
||
|
||
## Critical rules
|
||
|
||
- **Never hardcode or mock frontend data.** All library data comes from the Navidrome
|
||
(Subsonic) API. No placeholder songs/albums/artists, ever.
|
||
- **Don't commit or push unless asked.** When asked, push to the Gitea remote.
|
||
- **Two-part versioning** `MAJOR.MINOR` (e.g. `0.8`) — no patch component. Each release
|
||
bumps the minor; features and fixes go in the same release. See `CHANGELOG.md`.
|
||
- Keep the `CHANGELOG.md` Platform Parity Matrix accurate when adding cross-platform features.
|
||
|
||
## Layout
|
||
|
||
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
|
||
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}`
|
||
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
|
||
(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/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
||
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
||
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
|
||
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
|
||
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
||
metadata editor are **not** in the Android app).
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
npm run dev # Vite dev server (HMR)
|
||
npm run build # production build → dist/
|
||
npm run preview # preview the production build
|
||
```
|
||
|
||
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
|
||
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
|
||
`docker compose pull && docker compose up -d`. Ein einfacher Restart ist `docker compose restart
|
||
novasonic-backend` (das Backend zieht bei jedem Start das neueste yt-dlp nach).
|
||
|
||
Gepusht wird von der Dev-Maschine mit `npm run push-images` (`scripts/build-push-images.mjs`, liest
|
||
`REGISTRY`/`TAG` aus `.env`); ohne Node tut es direkt `docker build -t $REGISTRY/<name>:latest
|
||
<context>` + `docker push` mit den Kontexten `.`, `backend/`, `scrobble/`. Registry ist die
|
||
Gitea-Registry (`…/docker`, Owner klein geschrieben).
|
||
|
||
**`docker-compose.dev.yml`** ist die Variante, die stattdessen lokal aus den Dockerfiles baut
|
||
(`backend/Dockerfile` + `scrobble/Dockerfile`), ohne Registry: `docker compose -f
|
||
docker-compose.dev.yml up -d --build`. Die schweren Deps (ffmpeg, Deno, yt-dlp, node libs) sind in
|
||
den Images gebacken, deshalb sind **Restarts sofort da** und Rebuilds nach `git pull` cache-schnell.
|
||
|
||
On the deploy host the music lives at `/opt/navidrome/music`.
|
||
|
||
## Download pipeline (hard-won knowledge)
|
||
|
||
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
|
||
- **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
|
||
server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
|
||
All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
|
||
`src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
|
||
explicit versions + real artist images + durations. Deezer generally gives one consistent name per
|
||
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
|
||
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
|
||
for Japanese tracks. Romaji goes **only** into sort tags (TITLESORT / ARTISTSORT / etc.).
|
||
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
|
||
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
|
||
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
|
||
- **Official audio via `artist` metadata (not just channel).** The strict steps accept a candidate
|
||
if the channel name matches the artist **OR** yt-dlp's music `artist` field does. YouTube "Art
|
||
Tracks" (label-provided official audio) carry `artist=<Artist>` even when the channel is
|
||
`BANGTANTV`/`HYBE LABELS`/… (no artist name). Confirmed on "BTS Pied Piper": only the official
|
||
upload had `artist=BTS`; every fan/live/lyric reupload had `artist=NA`. `channel_is_verified` was
|
||
rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two
|
||
`--match-filter` args (yt-dlp OR's them) in `mf()`.
|
||
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
|
||
relax only the duration constraint while **still requiring the artist channel**. If nothing
|
||
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
|
||
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
|
||
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
|
||
- **Numbers in a title are always required keywords.** `buildTitleKeywords` kept only the two
|
||
LONGEST words, and numbers are short — so "700 Main Street" was filtered on Street+Main only and
|
||
the cascade happily saved **"777 Main Street" by a different artist** (step 3 drops the channel
|
||
check, so nothing else caught it). Numeric tokens are now emitted as `\b<n>\b` in addition to the
|
||
two longest words. Same fix carries a small abbreviation map (street/st, saint/st, mount/mt,
|
||
doctor/dr, versus/vs, number/no): uploaders write "700 Main St" where the release says "Street",
|
||
which had rejected the correct official Art Track in every step. One exception: a **re-release year
|
||
next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in
|
||
the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999",
|
||
"Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`.
|
||
- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running
|
||
stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it
|
||
from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`,
|
||
209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a
|
||
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
|
||
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
|
||
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
|
||
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
|
||
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
|
||
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
|
||
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
|
||
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
|
||
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
|
||
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
|
||
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
|
||
- **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.
|
||
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
||
(`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
|
||
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
|
||
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()`.
|
||
- **Mixed-artist albums search per track.** `downloadAlbumTracks` passes `rawArtist = track.artistName`
|
||
so the YTM/YouTube search query and the channel filter use each track's *own* performer, not the
|
||
album artist. `ytdlp.js` derives both from `rawArtist || mainArtist`, and `mainArtist = albumArtist`,
|
||
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
|
||
still uses `albumArtist`, so album grouping is unaffected.
|
||
- **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
|
||
(= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
|
||
**not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
|
||
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.
|
||
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.
|
||
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
||
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
||
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
|
||
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
|
||
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
|
||
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
|
||
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
|
||
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
|
||
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
|
||
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
|
||
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
|
||
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
|
||
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
|
||
(`deezer.test.js`, `musicbrainz.test.js`).
|
||
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
|
||
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
|
||
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
||
(`sourceUrl` path) is the reliable route.
|
||
- **File resolution for edit/delete:** Navidrome's stored path may use a different mount root
|
||
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.
|
||
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
|
||
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
|
||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
||
|
||
## Listening stats / scrobble system (v2.0)
|
||
|
||
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
|
||
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
|
||
records every counted play into its **own** timestamped, multi-user log.
|
||
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
|
||
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
|
||
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
|
||
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
|
||
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
|
||
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
|
||
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
|
||
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
|
||
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
|
||
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
|
||
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
|
||
`_toEpochSec`, which maps `0`→now).
|
||
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
|
||
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
|
||
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
|
||
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
|
||
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
|
||
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
|
||
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
|
||
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
|
||
batch (server dedups by uuid) on start / when connectivity returns.
|
||
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
|
||
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
|
||
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
|
||
cover-agnostic; the stats endpoint just returns the IDs.
|
||
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
|
||
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
|
||
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
|
||
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
|
||
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
|
||
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
|
||
`docs/scrobble-plan.md`.
|
||
|
||
## Android offline system
|
||
|
||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||
- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt).
|
||
- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download.
|
||
- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work.
|
||
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
|
||
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
|
||
|
||
## Android navigation, state & insets (hard-won)
|
||
|
||
- **A `BackHandler` inside a NavHost destination loses back priority to the NavHost.** The Erkunden
|
||
(Apple) artist/album are in-screen *overlays* on the `erkunden` destination, not routes — their
|
||
`BackHandler` never fired, so one back press popped the whole tab to Home. Fix: the overlay reports
|
||
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
||
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
|
||
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
|
||
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
|
||
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
|
||
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
|
||
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
|
||
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
|
||
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
|
||
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
|
||
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
|
||
destinations. Hoist it into the ViewModel (see `ExploreViewModel.artistAllAlbums`).
|
||
- **`bottomBar` insets:** the tab `NavigationBar` absorbs the system-navigation inset. Detail screens
|
||
have no tab bar, so the MiniPlayer needs `navigationBarsPadding()` itself — otherwise it sits behind
|
||
the phone's gesture/navigation bar.
|
||
- **`ModalBottomSheet`:** use `rememberModalBottomSheetState(skipPartiallyExpanded = true)` *and*
|
||
`navigationBarsPadding()` on the content, or the bottom-most action (e.g. "Herunterladen") opens
|
||
hidden behind the navigation bar and needs an extra drag-up.
|
||
- **Reuse the Ktor client.** `MusicRepository.coverArtUrl()/streamUrl()` must go through
|
||
`ClientHolder.get(creds)`. Constructing a `SubsonicClient` per call spins up (and never closes) an
|
||
`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
|
||
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
|
||
- **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
|
||
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
|
||
|
||
When referencing files in chat, use clickable markdown links, not backticks:
|
||
`[ytdlp.js](backend/lib/ytdlp.js)`.
|