Files
NovaSonic/CLAUDE.md
T
HoneyandClaude Opus 4.8 1e69831dd8 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>
2026-07-31 12:48:33 +02:00

13 KiB
Raw Blame History

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

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 and OfflineDao.kt.
  • OfflineRepository (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): 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.
  • 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).