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:
1 parent
e69fbaf75e
commit
71190488ca
19 files changed
+754
-16
No files matched your search
+15
-7
@@ -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; };
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user