Download albums/playlists to private device storage (Room + WorkManager, HttpURLConnection streaming). New Offline tab lists groups with progress; tapping a group opens a detail screen (cover-art rows, play/shuffle, per-song delete from device). Downloaded tracks play from disk on every screen. Album/playlist cards show a downloaded badge; download button resumes missing songs. Song storage is dedup'd by ID and reference-counted on delete. Fixes: Android 15 download crash (dropped banned DATA_SYNC foreground service); offline detail flash-back (initial empty state no longer mistaken for deleted). Docs: CHANGELOG 0.10 + parity matrix; condensed README Features. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
86 lines
6.2 KiB
Markdown
86 lines
6.2 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/iTunes API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
|
||
- `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).
|
||
- **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/…).
|
||
- **"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.
|
||
- **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()`.
|
||
|
||
## UI / references
|
||
|
||
When referencing files in chat, use clickable markdown links, not backticks:
|
||
`[ytdlp.js](backend/lib/ytdlp.js)`.
|