# 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/iTunes 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`, `metadata.js` (iTunes lookup, file resolution helpers), `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). - **iTunes store = US everywhere** (`api.js` search, `metadata.js` `lookupItunesTrackMeta` / `searchItunesAlbumTracks`). Was JP-first, which localized Western artists to katakana ("DJスネイク") on some tracks and kept kanji on others → the *same* artist resolved differently between downloads. US-only gives one consistent name per artist and halves the download's iTunes calls. Titles come from the source, so a Japanese title the user picked stays Japanese; only iTunes-derived fields (album\_artist, genre, year, artwork) follow the US store. - **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=` 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`, `†…`). `metadata.js` strips leading non-alphanumerics (`stripLeadSym`) before the iTunes 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 iTunes tracks' `collectionArtistName` (album-level, 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 iTunes 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 (= iTunes `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. - **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. - **iTunes artists have no artwork.** The Search API returns no `artworkUrl100` for artist entities; borrow the cover of one of their albums (`entity=album&limit=1`) — otherwise you get grey circles. ## UI / references When referencing files in chat, use clickable markdown links, not backticks: `[ytdlp.js](backend/lib/ytdlp.js)`.