With NowPlaying (or an ABS player) open, backgrounding and resuming the app made the next back press pop the NavHost invisibly to Home before dismissing the overlay. Returning from the background re-registers the NavController's own OnBackPressedCallback on top of the dispatcher, stealing back-priority from the overlays. AppNavigation now bumps a resumeTick on Lifecycle.Event.ON_RESUME and keys every overlay DisposableEffect (NowPlaying, ABS player, ABS item, Erkunden overlay) on resumeTick + currentBackEntry, so each overlay re-adds its callback above the NavController after a resume or navigation. Needs on-device verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
13 KiB
Markdown
164 lines
13 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`.
|
||
- `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
|
||
```
|
||
|
||
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
|
||
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
|
||
(this also re-pulls the latest yt-dlp).
|
||
|
||
## 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.
|
||
- **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.
|
||
- **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`.
|
||
|
||
## 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)`.
|