Files
NovaSonic/docs/scrobble-plan.md
HoneyandClaude Opus 4.8 fff6f9e4da feat(scrobble): Android stats screen + cover art in stats (web + android)
Phase 4 (part B): Android statistics screen consuming GET /scrobble/stats —
tiles (plays + listening time), day/month/year play series, top artists/tracks/
albums, 24h listening clock, period tabs; opened from a Settings button
("Meine Statistiken"). New StatsViewModel + StatsScreen + ScrobbleModels;
PlayLogClient.fetchStats() (tolerant JSON decode, null on any failure);
repo.fetchScrobbleStats(); nav route + SettingsScreen(onOpenStats).

Cover art everywhere: top artists show a circular artist image, tracks/albums
a square cover — resolved client-side from Navidrome via artistId/albumId/songId
(no coverArt knowledge in the scrobble service). Endpoint now also returns
albumId for top tracks so track covers resolve reliably. Web stats list gets the
same thumbnails. SW cache -> ns-v67.

Android compileDebugKotlin + web build green. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:41:27 +02:00

235 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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?from=&to=&tz=&bucket=day\|month\|year&limit=` | Aggregate: totals, Top-Artists/Alben/Tracks, Zeit-Serie (day/month/year), Hör-Uhr (byHour) | ✅ Phase 3 (Endpoint) |
| `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. ✅ **Erledigt**: `/scrobble/stats` (Endpoint: totals, Top-Listen, day/month/year-Serie, Hör-Uhr;
on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) **+ Web-Statistik-View**
([src/ui/stats.js](../src/ui/stats.js)) — Overlay aus den Einstellungen („📊 Meine Statistiken"),
Zeitraum-Tabs (Alles / Dieses Jahr / Letzte 30 Tage), Kacheln, Verlaufs-Balken, Top-Listen,
Hör-Uhr. Vanilla/inline, keine Chart-Lib. **Braucht Runtime-Test am Gerät.**
4. 🔨 **In Arbeit**: **Android-Stats-Screen** ✅ (konsumiert `/scrobble/stats`; Kacheln, Verlaufs-
Balken, Top-Listen, Hör-Uhr; Zeitraum-Tabs; aus den Android-Einstellungen). **Cover + Artist-
Bilder** ✅ in Web **und** Android (client-seitig aus Navidrome via `coverUrl`/`coverArtUrl` über
`artistId`/`albumId`/`songId`; Endpoint liefert jetzt auch `albumId` bei Top-Tracks).
**Noch offen: Wrapped** (`/scrobble/wrapped` + Web-Story-Seite). Android + Web build grün;
**braucht Runtime-Test am Gerät.**
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.
```