feat(scrobble): own timestamped play-log service (stats/Wrapped foundation)
Phase 1+2 of a self-hosted, multi-user listening archive — the basis for day/month/year stats and a yearly "Wrapped". Runs IN ADDITION to the existing Navidrome/Last.fm scrobble; never replaces it. Service (isolated, own container): - scrobble/scrobble-server.js — node:sqlite play-log (own volume), routes POST /scrobble/plays (idempotent by uuid), GET /scrobble/history (archive), DELETE /scrobble/plays?user= (test cleanup), GET /scrobble/health. Multi-user auth via Navidrome ping.view (cached) -> user not spoofable. - docker-compose: novasonic-scrobble service (node:22, port 3710) + volume; backend bumped node:20->22 (EOL); SCROBBLE_URL plumbing (entrypoint, .env.example). Capture (both platforms, offline-safe queue, idempotent batch flush): - Fires at the existing scrobbleMode counted-play moment — consistent with Last.fm, respects the user's setting (no separate 50%/4min rule). - Web: src/player/scrobbleLog.js (localStorage queue) + subsonicAuthParams() in api.js + hooks in player.js + flush init in main.js; S.cfg.scrobbleUrl. - Android: data/PlayLogClient.kt (filesDir queue, reused Ktor client) + repo.logPlay/flushPlayLog + PlayerController hook + NovaSonicApp flush + scrobbleUrl in PrefsStore/SettingsViewModel/SettingsScreen (with test button). Plan + design decisions documented in docs/scrobble-plan.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
e69fbaf75e
commit
71190488ca
19 files changed
+754
-16
No files matched your search
@@ -0,0 +1,225 @@
|
||||
# NovaSonic Scrobble & Listening-Archive — Plan
|
||||
|
||||
> Status: **Planung** (kein Code). Eigenes, zeitgestempeltes Play-Log als Grundlage für
|
||||
> Tages-/Monats-/Jahres-Statistiken und ein „Wrapped", **multi-user**, Web + Android.
|
||||
> Ersetzt Koito/Spindle-Ideen (beide effektiv single-user).
|
||||
|
||||
## Warum überhaupt eigenes Log
|
||||
|
||||
- Navidrome speichert nur `playCount` + `playDate` (letztes Datum) — **keine Historie mit
|
||||
Zeitstempeln**. Damit sind „Plays pro Tag/Monat/Jahr" und ein Jahres-Wrapped **nicht** möglich.
|
||||
- **Koito** und **Spindle** sind praktisch **single-user** (ein Admin/DEFAULT_USER, eine Historie).
|
||||
Für mehrere Navidrome-Nutzer im Haushalt passt keins.
|
||||
- NovaSonic **ist der Player** → kennt bei jedem Abspielen: **wer + Song + wann + wie lange**.
|
||||
Das ist der natürliche Erfassungspunkt, pro Nutzer.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Entscheidung: eigener Container statt im Download-Backend
|
||||
|
||||
**Empfehlung: separater, schlanker Service `novasonic-scrobble`.**
|
||||
|
||||
Die zwei Workloads haben **gegensätzliche Profile**:
|
||||
|
||||
| | Download-Backend | Scrobble-Service |
|
||||
|---|---|---|
|
||||
| Last | schwer, bursty (yt-dlp/ffmpeg/spotdl-Subprozesse) | leicht, konstant (wenige Writes/Song) |
|
||||
| Uptime-Bedarf | egal, darf abstürzen/neustarten | **hoch** — darf keine Plays verlieren |
|
||||
| Neustart-Kadenz | **regelmäßig** (zieht yt-dlp bei jedem Start neu) | selten |
|
||||
| Kritikalität der Daten | reproduzierbar (erneut downloaden) | **Archiv, nicht reproduzierbar** |
|
||||
| Deps | python3, ffmpeg, git, kuromoji … | nur `node:sqlite` (0 native Deps) |
|
||||
|
||||
Ein `yt-dlp`/`ffmpeg`-OOM oder der Routine-Neustart „yt-dlp neu ziehen" darf **nie** in-flight
|
||||
Scrobbles verlieren oder die DB gefährden. Isolation = Datensicherheit. Der Scrobble-Service
|
||||
startet in Sekunden (winziges Image), läuft stabil durch, besitzt sein eigenes DB-Volume.
|
||||
|
||||
**Kosten:** eine zusätzliche Compose-Service + ein Upstream in der nginx-Config. Gering, weil der
|
||||
Service trivial ist (Express + SQLite + ~4 Routen).
|
||||
|
||||
### Alternative (verworfen): alles im Download-Backend
|
||||
Weniger Dateien, aber koppelt das **Archiv** an den instabilsten/am häufigsten neugestarteten
|
||||
Prozess. Für einen reinen MVP machbar, aber die Datenintegrität ist es nicht wert.
|
||||
|
||||
### Node-/SQLite-Wahl für den neuen Service
|
||||
Neues Image auf **`node:22-slim`** → eingebautes **`node:sqlite`** (stabil ab Node 22), **keine
|
||||
native Build-Kette**, kein `better-sqlite3`-Kompilat. Das bestehende Download-Backend bleibt auf
|
||||
`node:20-slim` unangetastet.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell (SQLite, im eigenen Volume)
|
||||
|
||||
DB-Datei: `/data/novasonic-plays.db` (eigenes `novasonic-scrobble-data`-Volume).
|
||||
**Denormalisiert** — Titel/Artist/Album werden mitgespeichert, damit das Archiv überlebt, wenn ein
|
||||
Song später aus Navidrome verschwindet (Kern eines Archivs).
|
||||
|
||||
```sql
|
||||
CREATE TABLE plays (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user TEXT NOT NULL, -- Navidrome-Username → Mandanten-Trennung
|
||||
song_id TEXT NOT NULL, -- Subsonic id (kann später ungültig werden)
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
artist_id TEXT,
|
||||
album TEXT,
|
||||
album_id TEXT,
|
||||
duration INTEGER, -- Track-Länge in Sekunden
|
||||
played_at INTEGER NOT NULL, -- Unix-Epoch, UTC
|
||||
source TEXT, -- 'web' | 'android'
|
||||
uuid TEXT UNIQUE -- Idempotenz (Offline-Retry doppelt-sicher)
|
||||
);
|
||||
CREATE INDEX ix_plays_user_time ON plays(user, played_at);
|
||||
CREATE INDEX ix_plays_user_artist ON plays(user, artist_id);
|
||||
CREATE INDEX ix_plays_user_song ON plays(user, song_id);
|
||||
```
|
||||
|
||||
- **`uuid UNIQUE`**: Web/Android queuen Plays offline und flushen später → ohne Idempotenz zählt ein
|
||||
Retry doppelt. Client erzeugt die UUID; Ingest ignoriert Duplikate (`INSERT OR IGNORE`).
|
||||
- **Zeitzone**: immer **UTC** speichern; beim Aggregieren die lokale TZ des Users anwenden
|
||||
(Client schickt Offset/IANA-TZ), sonst rutscht ein Play um Mitternacht in den falschen Tag.
|
||||
- **Rollups**: bewusst **keine** materialisierten Tabellen zu Beginn — On-the-fly `GROUP BY` reicht
|
||||
für persönliche Datenmengen locker (100k Zeilen sind für SQLite nichts). Nachrüstbar.
|
||||
|
||||
---
|
||||
|
||||
## Was zählt als „Play"
|
||||
|
||||
**Umgesetzt (besser als der ursprüngliche 50%/4-min-Plan):** Wir docken am **bestehenden
|
||||
`scrobbleMode`-Moment** an — genau dann, wenn NovaSonic ohnehin den echten Navidrome-Scrobble
|
||||
absendet (nicht das `submission=false`-„läuft gerade"), feuert **zusätzlich** `logPlay()`.
|
||||
|
||||
Vorteile: (1) **konsistent mit Last.fm** — beide zählen denselben Play zur selben Zeit;
|
||||
(2) respektiert die **vom User gewählte** Scrobble-Schwelle (15s / 30s / 1/3 / 2/3 / end) statt
|
||||
einer fest verdrahteten Regel; (3) eine einzige Wahrheit, weniger Code. Der Play-Log ersetzt den
|
||||
Navidrome/Last.fm-Scrobble **nie** — er läuft parallel.
|
||||
|
||||
Web: [src/player/player.js](../src/player/player.js) `_scheduleScrobble`/`_scrobbleNow`.
|
||||
Android: [PlayerController.kt](../android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt) `scheduleScrobble`.
|
||||
|
||||
---
|
||||
|
||||
## API (Service `novasonic-scrobble`)
|
||||
|
||||
| Endpoint | Zweck | Status |
|
||||
|---|---|---|
|
||||
| `POST /scrobble/plays` | Batch-Ingest `{plays:[…]}`, idempotent per `uuid` | ✅ Phase 1 |
|
||||
| `GET /scrobble/history?limit=&before=` | Roh-Verlauf chronologisch = **das Archiv** | ✅ Phase 1 |
|
||||
| `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ Phase 1 |
|
||||
| `GET /scrobble/health` | Liveness + Zeilen-Zahl | ✅ Phase 1 |
|
||||
| `GET /scrobble/stats?range=day\|month\|year&from=&to=&tz=` | Aggregate: Top-Artists/Alben/Tracks, Summe Plays + Hörzeit, Zeit-Buckets für Charts | Phase 3 |
|
||||
| `GET /scrobble/wrapped?year=YYYY&tz=` | Fertiger Wrapped-Payload | Phase 4 |
|
||||
|
||||
### Routing: eigene Origin, KEIN nginx-Upstream
|
||||
Der Frontend-nginx serviert nur Static und **proxyt nichts** — der Client spricht das Download-Backend
|
||||
direkt über `S.cfg.backendUrl` (`window.NS_BACKEND_URL`) an. Der Scrobble-Service folgt exakt diesem
|
||||
Muster: eigene Origin `S.cfg.scrobbleUrl` (`window.NS_SCROBBLE_URL`, aus `SCROBBLE_URL` in der
|
||||
`.env` → `docker-entrypoint.sh`). CORS `*` wie im Backend. Der User routet Port **3710** über seinen
|
||||
Reverse-Proxy (wie 8080/3700).
|
||||
|
||||
### Auth / Multi-User (Trust-Modell)
|
||||
Jeder Call trägt die Subsonic-Token-Creds, die NovaSonic ohnehin nutzt (`u`, `t=md5(pass+salt)`,
|
||||
`s=salt`). Der Service verifiziert den User einmal per günstigem Subsonic **`ping.view`** gegen
|
||||
Navidrome (5 min gecacht) → der `user`-Wert im Play ist damit echt, nicht spoofbar. Kein neues
|
||||
Login, kein neues Passwort.
|
||||
|
||||
### Aggregation Tag / Monat / Jahr (das Archiv-Herzstück)
|
||||
```sql
|
||||
-- Plays pro Tag im Zeitraum (User + lokale TZ via Offset-Sekunden)
|
||||
SELECT strftime('%Y-%m-%d', played_at + :tzOffset, 'unixepoch') AS bucket,
|
||||
COUNT(*) AS plays, SUM(duration) AS seconds
|
||||
FROM plays
|
||||
WHERE user = :user AND played_at BETWEEN :from AND :to
|
||||
GROUP BY bucket ORDER BY bucket;
|
||||
```
|
||||
Gleiche Query mit `%Y-%m` → Monat, `%Y` → Jahr. Top-Listen analog via `GROUP BY artist_id` etc.
|
||||
|
||||
### Wrapped-Payload (Beispiel-Felder)
|
||||
`year`, `totalPlays`, `totalMinutes`, `topArtists[]`, `topAlbums[]`, `topTracks[]`,
|
||||
`mostActiveDay`, `listeningClock` (Plays je Stunde 0–23), `monthlyBreakdown[12]`,
|
||||
`newArtistsDiscovered` (erste je Artist im Jahr), `streakLongest`.
|
||||
|
||||
---
|
||||
|
||||
## Erfassung im Client
|
||||
|
||||
- **Web** ([src/api.js](../src/api.js) / Player): im `timeupdate`/`ended`-Handler die 50 %/4-min-
|
||||
Schwelle prüfen → lokale Queue (localStorage) → Flush an `/scrobble/plays`. Läuft **parallel** zum
|
||||
bestehenden Navidrome-Scrobble (Play-Count bleibt unangetastet).
|
||||
- **Android** (Media3-Listener): gleiche Schwelle über Position/State → Room-Queue → Flush.
|
||||
Nutzt dieselbe Offline-Queue-Denke wie das bestehende Offline-System.
|
||||
|
||||
Beide: Queue + Retry, damit ein Play auch offline erfasst und später idempotent nachgereicht wird.
|
||||
|
||||
---
|
||||
|
||||
## UI
|
||||
|
||||
- **Web:** neue View „Statistiken" — Kalender-Heatmap (Tage), Monats-Balken, Top-Listen; **Wrapped**
|
||||
als Story-Seite zum Jahresende. Vanilla SVG/Canvas, **keine Chart-Lib** (Bundle schlank halten).
|
||||
- **Android:** Stats-Screen (Compose), spiegelbildlich zur Web-View.
|
||||
|
||||
---
|
||||
|
||||
## Optional später: ListenBrainz-Relay
|
||||
|
||||
Jeden Play zusätzlich an einen **ListenBrainz-kompatiblen** Endpoint schieben (falls konfiguriert)
|
||||
→ NovaSonic wird nebenbei ein Standard-Scrobbler, Koito/LB als externes Ziel andockbar. Nicht
|
||||
Teil des Kerns.
|
||||
|
||||
---
|
||||
|
||||
## docker-compose (Skizze)
|
||||
|
||||
```yaml
|
||||
novasonic-scrobble:
|
||||
image: node:22-slim
|
||||
container_name: novasonic-scrobble
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./scrobble:/app:ro
|
||||
- novasonic-scrobble-data:/data
|
||||
ports:
|
||||
- "3710:3710"
|
||||
environment:
|
||||
- PORT=3710
|
||||
- DATA_PATH=/data
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # für ping.view-Verifikation
|
||||
command: >
|
||||
sh -c "mkdir -p /deps && cd /deps && npm install express &&
|
||||
cd /app && NODE_PATH=/deps/node_modules node scrobble-server.js"
|
||||
|
||||
volumes:
|
||||
novasonic-scrobble-data:
|
||||
```
|
||||
|
||||
`.env`: `SCROBBLE_URL=https://scrobble.deine-domain` ergänzen; der Frontend-Container injiziert das als
|
||||
`window.NS_SCROBBLE_URL` (via `docker-entrypoint.sh`). Kein nginx-Upstream nötig — eigene Origin, CORS `*`.
|
||||
|
||||
---
|
||||
|
||||
## Phasen
|
||||
|
||||
1. ✅ **Service-Fundament** *(erledigt)*: `scrobble/scrobble-server.js` (node:22 + `node:sqlite`),
|
||||
`POST /scrobble/plays` (idempotent) + `GET /scrobble/history` + `DELETE /scrobble/plays` +
|
||||
`GET /scrobble/health`, `ping.view`-Auth (5 min gecacht), eigenes Volume, Compose-Service,
|
||||
`SCROBBLE_URL`-Plumbing (entrypoint + `S.cfg.scrobbleUrl`), Backend auf node:22 hochgezogen.
|
||||
Lokal end-to-end getestet (Ingest/Idempotenz/History/Purge). *Ab hier wird bereits archiviert.*
|
||||
2. ✅ **Erfassung Web + Android** *(erledigt)*: Play-Log am `scrobbleMode`-Moment (s.o.),
|
||||
Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`),
|
||||
idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden
|
||||
Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.*
|
||||
3. **`/scrobble/stats`** + Web-Statistik-View (Heatmap, Monats-Balken, Top-Listen).
|
||||
4. **`/scrobble/wrapped`** + Story-Seite; Android-Stats-Screen.
|
||||
5. *(optional)* ListenBrainz-Relay.
|
||||
|
||||
---
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
- **Container-Netzwerk/Proxy:** eigener Port (3710) hinter nginx-Upstream `/scrobble/` — bestätigen.
|
||||
- **TZ-Übergabe:** fester UTC-Offset pro Request vs. IANA-Name (Sommerzeit-genau). Start: Offset,
|
||||
später IANA falls DST-Grenzfälle stören.
|
||||
- **Schwelle:** 50 %/4 min (Last.fm) übernehmen oder anpassen?
|
||||
- **Aufbewahrung:** unbegrenzt (Archiv). Backup = das SQLite-File im Volume sichern.
|
||||
```
|
||||
Reference in new issue
Block a user