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

+8
View File
@@ -7,6 +7,14 @@ export function apiUrl(method, extra='') {
const token = md5(S.cfg.pass + salt);
return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
}
// Bare Subsonic token-auth params (u=..&t=..&s=..) for talking to non-Navidrome NovaSonic
// services (e.g. the scrobble play-log), which verify the same creds via ping.view.
export function subsonicAuthParams() {
if (!S.cfg.pass || !S.cfg.user) return '';
const salt = Math.random().toString(36).slice(2, 10);
const token = md5(S.cfg.pass + salt);
return `u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}`;
}
export async function api(method, extra='') {
const res = await fetch(apiUrl(method, extra));
if(!res.ok) throw new Error(`HTTP ${res.status}`);
+7 -3
View File
@@ -2,6 +2,7 @@ import { S } from './state.js';
import { toast } from './utils.js';
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
import { injectPWA } from './pwa.js';
import { initPlayLog } from './player/scrobbleLog.js';
import { loadSavedTheme } from './themes.js';
import { initScrollRows } from './ui/components.js';
import {
@@ -59,17 +60,18 @@ async function doConnect() {
const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })();
const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || '';
const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,'');
const scrobUrl = (_prev.scrobbleUrl || window.NS_SCROBBLE_URL?.trim() || '').replace(/\/$/,'');
if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; }
const btn = document.getElementById('btn-connect');
btn.disabled=true; btn.textContent='Verbinde…';
document.getElementById('setup-error').style.display='none';
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl};
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl, scrobbleUrl:scrobUrl};
try {
await api('ping');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
startApp();
} catch(e) {
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''};
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
_showSetupError('Verbindung fehlgeschlagen: '+e.message);
btn.disabled=false; btn.textContent='Verbinden';
}
@@ -80,7 +82,7 @@ function disconnect() {
localStorage.removeItem('ns_cfg');
localStorage.removeItem('ns_last_played');
sessionStorage.removeItem('ns_nav');
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''};
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1;
S.audio.pause(); S.audio.src='';
document.getElementById('app').style.display='none';
@@ -107,6 +109,7 @@ async function startApp() {
navigate('home');
}
initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
// Wait for overrides before restoring player bar so disp* functions have the cache ready
loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); });
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
@@ -132,6 +135,7 @@ window.addEventListener('load', () => {
if(saved) {
S.cfg = JSON.parse(saved);
if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,'');
if(!S.cfg.scrobbleUrl && window.NS_SCROBBLE_URL?.trim()) S.cfg.scrobbleUrl = window.NS_SCROBBLE_URL.trim().replace(/\/$/,'');
startApp();
updateNpColorMode();
}
+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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared mutable state — imported as { S } in every module
export const S = {
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false },
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', scrobbleUrl: '', showPlayCount: false, showRomaji: false },
// Playback
queue: [],