docs: v2.0 — document the scrobble / listening-stats system

- CHANGELOG: new [2.0] entry (own timestamped play-log service, stats view on
  web + android with cover art, remix-dedup fix, user-select UI fix, node 20->22);
  Platform Parity Matrix gains play-log / stats / stats-cover rows (+ Wrapped 🔜).
- README: Listening stats feature, scrobble/ in the project tree, SCROBBLE_URL
  env + scrobble container table, port 3710 + SSO-exclusion note in setup.
- CLAUDE.md: new 'Listening stats / scrobble system' section (isolation rationale,
  node:sqlite gotchas, ping.view multi-user auth, capture point, cover resolution,
  SSO deploy gotcha).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-07 19:33:46 +02:00
1 parent 906b3b48cb
commit 561014c83f
3 files changed
+93 -3

No files matched your search

+39
View File
@@ -29,6 +29,7 @@ plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies
- `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).
@@ -115,6 +116,44 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
`albumArtist` param, else track artist). Never use `rawArtist` for `album_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 on `node:22-slim` with the **built-in `node:sqlite`** (run with
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in 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 Navidrome **`ping.view`** (cached 5 min) → the stored
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
`_toEpochSec`, which maps `0`→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's `scrobbleMode`, 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()` in `api.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.
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
cover-agnostic; the stats endpoint just returns the IDs.
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
`scrobbleUrl` pref (Android), mirroring `backendUrl`. 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 (breaks `fetch` → "nicht erreichbar").
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
`docs/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.