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:
1 parent
906b3b48cb
commit
561014c83f
3 files changed
+93
-3
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user