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>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-06 22:54:25 +02:00
1 parent e69fbaf75e
commit 71190488ca
19 files changed
+754 -16

No files matched your search

+15 -7
View File
@@ -4,6 +4,7 @@ import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtis
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js';
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
@@ -20,14 +21,15 @@ let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ign
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
let _scrobbleId = null;
function _scheduleScrobble(id, durSec) {
if (_scrobbleId === String(id)) return;
function _scheduleScrobble(s) {
const id = s?.id;
if (!id || _scrobbleId === String(id)) return;
_cancelScrobble();
_scrobbleId = String(id);
const mode = S.cfg.scrobbleMode || '15s';
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
let delay;
const dur = (durSec || 0) * 1000;
const dur = (s?.duration || 0) * 1000;
switch (mode) {
case '30s': delay = 30_000; break;
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
@@ -35,7 +37,10 @@ function _scheduleScrobble(id, durSec) {
default: delay = 15_000; break;
}
_scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
if (_scrobbleId) {
api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
_scrobbleId = null;
}, delay);
}
@@ -45,7 +50,10 @@ function _cancelScrobble() {
_scrobbleId = null;
}
function _scrobbleNow(s) {
if (s) api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
if (s) {
api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
}
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
@@ -214,7 +222,7 @@ window.NSPlayer = {
try {
const s = S.queue[S.qIdx];
if (!s) return;
_scheduleScrobble(s.id, s.duration);
_scheduleScrobble(s);
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {}
},
@@ -411,7 +419,7 @@ export function playCurrent() {
onNpSongChange(s.id);
renderQueue();
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
_scheduleScrobble(s.id, s.duration);
_scheduleScrobble(s);
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
+85
View File
@@ -0,0 +1,85 @@
// ─── NovaSonic play-log client ──────────────────────────────────────────────
// Records a timestamped play into our own scrobble service (separate origin,
// S.cfg.scrobbleUrl) — the foundation for day/month/year stats + Wrapped.
//
// This runs IN ADDITION to the existing Navidrome scrobble (which feeds playCount
// and, if configured, Last.fm). It never replaces it. We fire logPlay() at the
// exact moment NovaSonic decides a play "counts" (see player.js), so the play-log
// and Last.fm stay consistent and both honour the user's scrobbleMode setting.
//
// Offline-safe: entries are queued in localStorage and flushed as an idempotent
// batch (server dedups by uuid), so a play made offline is delivered later exactly
// once.
import { S } from '../state.js';
import { subsonicAuthParams } from '../api.js';
const _QUEUE_KEY = 'ns_play_queue';
const _MAX_QUEUE = 500; // guard against unbounded growth if the service stays down
let _flushing = false;
function _uuid() {
try { return crypto.randomUUID(); }
catch { return 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10); }
}
function _load() {
try { return JSON.parse(localStorage.getItem(_QUEUE_KEY) || '[]'); } catch { return []; }
}
function _save(q) {
try { localStorage.setItem(_QUEUE_KEY, JSON.stringify(q.slice(-_MAX_QUEUE))); } catch {}
}
// Record one counted play. `song` is a Subsonic song object; source 'web' | 'android'.
export function logPlay(song, source = 'web') {
if (!S.cfg?.scrobbleUrl || !song?.id) return;
const entry = {
uuid: _uuid(),
songId: String(song.id),
title: song.title ?? null,
artist: song.artist ?? null,
artistId: song.artistId ?? null,
album: song.album ?? null,
albumId: song.albumId ?? null,
duration: Number.isFinite(Number(song.duration)) ? Math.floor(Number(song.duration)) : null,
playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC
source,
};
const q = _load();
q.push(entry);
_save(q);
flushPlayQueue();
}
// Deliver everything queued as one idempotent batch. Safe to call repeatedly.
export async function flushPlayQueue() {
if (_flushing || !S.cfg?.scrobbleUrl) return;
const batch = _load();
if (!batch.length) return;
const auth = subsonicAuthParams();
if (!auth) return;
_flushing = true;
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/plays?${auth}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plays: batch }),
});
if (!res.ok) return; // 401/5xx/network → keep the queue, retry later
// Success: the batch is handled (accepted + duplicates are both durable; invalid
// entries are malformed and will never succeed). Remove exactly what we sent by
// uuid, preserving any plays queued while the request was in flight.
const sent = new Set(batch.map(e => e.uuid));
_save(_load().filter(e => !sent.has(e.uuid)));
} catch {
// network error → keep queue for the next flush
} finally {
_flushing = false;
}
}
// Retry on app start and whenever connectivity returns.
export function initPlayLog() {
flushPlayQueue();
window.addEventListener('online', flushPlayQueue);
}