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>
235 lines
11 KiB
Markdown
235 lines
11 KiB
Markdown
# 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.
|
||
```
|