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

11 KiB
Raw Permalink 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?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)

-- 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. ✅ 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) — 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.