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
+7
-3
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user