Folgekorrektur zu 02b5905: "… - 2020 Remaster" steht auf der Veroeffentlichung,
aber nie im YouTube-Titel — als Pflicht-Keyword haette die Zahl den richtigen
Upload abgelehnt. Jahreszahlen neben einem Qualifier (remaster/version/edition/
anniversary/mono/stereo) fallen daher wieder raus; ein Titel, der selbst eine
Jahreszahl ist ("1999", "Berlin 2020"), behaelt sie. Gegen 29 Beispieltitel
gemessen verhalten sich 24 exakt wie vorher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19 KiB
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. SeeCHANGELOG.md. - Keep the
CHANGELOG.mdPlatform Parity Matrix accurate when adding cross-platform features.
Layout
src/— web PWA (vanilla ES modules). State insrc/state.js(theSobject); Subsonic/Deezer API insrc/api.js; views insrc/views/, UI insrc/ui/.- Client-side router (
src/views/navigation.js, History API):navigate()pushStates{nsIdx}only on genuine new navigation;popstateapplies the target entry'snsIdxfor both back and forward. NeverpushStateinside thepopstate/back path — it wipes the browser's forward stack (that was the "forward doesn't work" bug). In-app back buttons callhistory.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
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
/searchreturns 403 from server/datacenter IPs, which broke search + downloads + charts + editor autofill in production. All metadata now comes from Deezer (backend/lib/deezer.jsserver-side;_deezerBase()insrc/api.js; AndroidItunesClientis 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/fetchArtistAlbumsreplace the oldlookupItunesTrackMeta/fetchItunesTracks/searchItunesAlbumTracks. Editor autofill endpoint:/api/meta/lookup(was/api/itunes/lookup). Matching logic (pickTrackMatch) is unit-tested inbackend/test/deezer.test.js(npm test). - File naming:
{NN - }Artist - Title.extwith 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_musicplayer 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
artistmetadata (not just channel). The strict steps accept a candidate if the channel name matches the artist OR yt-dlp's musicartistfield does. YouTube "Art Tracks" (label-provided official audio) carryartist=<Artist>even when the channel isBANGTANTV/HYBE LABELS/… (no artist name). Confirmed on "BTS Pied Piper": only the official upload hadartist=BTS; every fan/live/lyric reupload hadartist=NA.channel_is_verifiedwas rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two--match-filterargs (yt-dlp OR's them) inmf(). - "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.
buildTitleKeywordskept 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>\bin 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 inbackend/test/ytdlp.test.js. - Size cap:
--max-filesize 30Mon 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.jsstrips leading non-alphanumerics (stripLeadSym) before the Deezer lookup and infirstWord. 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'sExploreViewModel.search(). - Mixed-artist albums search per track.
downloadAlbumTrackspassesrawArtist = track.artistNameso the YTM/YouTube search query and the channel filter use each track's own performer, not the album artist.ytdlp.jsderives both fromrawArtist || mainArtist, andmainArtist = 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). Thealbum_artisttag still usesalbumArtist, so album grouping is unaffected. - Album/artist download
album_artistcomes from the Deezer tracks'collectionArtistName(= the album-levelalbum.artist, identical across the album), derived innovasonic-server.js— not the request'sartist, which can be the lead single's performer (a KPop soundtrack got taggedalbum_artist=TWICEfrom track 1 "TAKEDOWN"). Falls back to the request artist only when Deezer omits it. - Always write an
albumtag.ytdlp.jsdefaults it toSingleswhen 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'spickTrackMatchrequires 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{}; thenmusicbrainz.js(rate-limited ~1/s,User-Agentrequired, 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 usetrackmeta; 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
(
sourceUrlpath) is the reliable route. - File resolution for edit/delete: Navidrome's stored path may use a different mount root
or a stale stem.
metadata.jsrebases 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_artisttag. All tracks in a download share the samemainArtistvalue (= DeezercollectionArtistNamewhen available, elsealbumArtistparam, else track artist). Never userawArtistforalbum_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 onnode:22-slimwith the built-innode:sqlite(run with--experimental-sqlite; no native build). DB/data/novasonic-plays.dbin 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 Navidromeping.view(cached 5 min) → the storeduseris real, not spoofable.node:sqlitethrows on extra named params, so each query gets exactly the params its SQL references.fromdefaults to all-time (do not route0through_toEpochSec, which maps0→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'sscrobbleMode, 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()inapi.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.
- hook in
- Stats view: Web
src/ui/stats.js(overlay from Settings), Androidui/screen/StatsScreen.kt+StatsViewModel.kt(from a Settings button). Cover art is resolved client-side from Navidrome (coverUrl/coverArtUrlviaartistId/albumId/songId) — the scrobble service stays cover-agnostic; the stats endpoint just returns the IDs. - Config:
SCROBBLE_URLenv →window.NS_SCROBBLE_URL(entrypoint) →S.cfg.scrobbleUrl(web) /scrobbleUrlpref (Android), mirroringbackendUrl. 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 (breaksfetch→ "nicht erreichbar"). - Planned:
/scrobble/wrapped(yearly story) + optional ListenBrainz relay. Seedocs/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 and OfflineDao.kt. - OfflineRepository (OfflineRepository.kt): holds hot
StateFlowmaps (_audioMap,_coverMap) keyed by songId/coverArtId for O(1) lookups inMusicRepository.streamUrl(). CallenqueueGroup()to schedule a download. - DownloadWorker (DownloadWorker.kt):
CoroutineWorkerusing plainHttpURLConnectionstreaming to.tmp→ rename. NosetExpedited()and nosetForeground()—FOREGROUND_SERVICE_TYPE_DATA_SYNCis banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work. - Playback:
MusicRepository.streamUrl()checksofflineRepo.localAudioPath(id)first; if the file exists it returns afile://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 callsofflineVm.songsForGroup()(JOIN query →DownloadedTrackEntity.toSong()) thenplayer.playSongs().
Android navigation, state & insets (hard-won)
- A
BackHandlerinside a NavHost destination loses back priority to the NavHost. The Erkunden (Apple) artist/album are in-screen overlays on theerkundendestination, not routes — theirBackHandlernever fired, so one back press popped the whole tab to Home. Fix: the overlay reports its close action upward viaonOverlayBackChange, andAppNavigationregisters a root-levelOnBackPressedCallbackfor 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 ownOnBackPressedCallbackon 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 inAppNavigation: aresumeTick(LifecycleEventEffect(ON_RESUME)) pluscurrentBackEntryare keys on every overlay'sDisposableEffect, 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.rememberSaveabledoes not save you — it only survives for real nav destinations. Hoist it into the ViewModel (seeExploreViewModel.artistAllAlbums). bottomBarinsets: the tabNavigationBarabsorbs the system-navigation inset. Detail screens have no tab bar, so the MiniPlayer needsnavigationBarsPadding()itself — otherwise it sits behind the phone's gesture/navigation bar.ModalBottomSheet: userememberModalBottomSheetState(skipPartiallyExpanded = true)andnavigationBarsPadding()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 throughClientHolder.get(creds). Constructing aSubsonicClientper call spins up (and never closes) anHttpClientfor 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 noartworkUrl100, which is why theentity=album&limit=1album-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).