# 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. 🔨 **In Arbeit**: `/scrobble/stats` ✅ (Endpoint: totals, Top-Listen, day/month/year-Serie, Hör-Uhr; on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) — **noch offen: Web-Statistik-View** (Kalender-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. ```