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; };
|
||||
|
||||
|
||||
Reference in new issue
Block a user