Files
NovaSonic/docs/scrobble-plan.md
T
HoneyandClaude Opus 4.8 71190488ca 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>
2026-08-06 22:54:25 +02:00

10 KiB
Raw Blame History

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 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 _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

  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.