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>
10 KiB
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).
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 BYreicht 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 _scheduleScrobble/_scrobbleNow.
Android: 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)
-- 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 / 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)
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
- ✅ 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. - ✅ Erfassung Web + Android (erledigt): Play-Log am
scrobbleMode-Moment (s.o.), Offline-Queue (Web: localStoragens_play_queue; Android:filesDir/play-queue.json), idempotenter Batch-Flush bei Start +online-Event.SCROBBLE_URL/scrobbleUrlin beiden Settings. Web-Build + Android-compileDebugKotlingrün. Ab jetzt wird real erfasst. /scrobble/stats+ Web-Statistik-View (Heatmap, Monats-Balken, Top-Listen)./scrobble/wrapped+ Story-Seite; Android-Stats-Screen.- (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.