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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "novasonic-scrobble",
|
||||
"private": true,
|
||||
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": {
|
||||
"start": "node --experimental-sqlite scrobble-server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* NovaSonic Scrobble Service
|
||||
* ---------------------------
|
||||
* A tiny, isolated service that records a timestamped, per-user play-log — the
|
||||
* foundation for day/month/year listening stats and a yearly "Wrapped".
|
||||
*
|
||||
* Deliberately SEPARATE from the download backend: the download server is heavy
|
||||
* (yt-dlp/ffmpeg subprocesses), may crash, and restarts regularly (to re-pull
|
||||
* yt-dlp). This archive needs high uptime + data integrity, so it runs on its own.
|
||||
*
|
||||
* Storage: node:sqlite (built into Node 22; run with --experimental-sqlite).
|
||||
* DB file: {DATA_PATH}/novasonic-plays.db (own persistent volume)
|
||||
*
|
||||
* Auth / multi-user: every request carries the same Subsonic token creds the PWA
|
||||
* already uses (u, t=md5(pass+salt), s=salt). We verify them against Navidrome's
|
||||
* ping.view once (cached briefly) → the `user` we store is real, not spoofable.
|
||||
* No new login, no new password.
|
||||
*
|
||||
* Phase 1 routes:
|
||||
* POST /scrobble/plays ingest a batch, idempotent by uuid
|
||||
* GET /scrobble/history raw chronological log (= the archive)
|
||||
* DELETE /scrobble/plays?user=NAME purge one user's rows (test cleanup)
|
||||
* GET /scrobble/health liveness + total row count
|
||||
*/
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PORT = process.env.PORT || 3710;
|
||||
const DATA_PATH = process.env.DATA_PATH || '/data';
|
||||
const NAVIDROME_URL = (process.env.NAVIDROME_URL || '').replace(/\/$/, '');
|
||||
|
||||
if (!NAVIDROME_URL) console.warn('⚠ NAVIDROME_URL not set — auth verification will reject everything.');
|
||||
|
||||
// ─── DB ─────────────────────────────────────────────────────────────────────
|
||||
fs.mkdirSync(DATA_PATH, { recursive: true });
|
||||
const db = new DatabaseSync(path.join(DATA_PATH, 'novasonic-plays.db'));
|
||||
db.exec('PRAGMA journal_mode = WAL;'); // survive restarts / concurrent reads
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plays (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user TEXT NOT NULL,
|
||||
song_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
artist_id TEXT,
|
||||
album TEXT,
|
||||
album_id TEXT,
|
||||
duration INTEGER,
|
||||
played_at INTEGER NOT NULL, -- Unix epoch, UTC
|
||||
source TEXT, -- 'web' | 'android'
|
||||
uuid TEXT UNIQUE -- idempotency for offline-retry
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_time ON plays(user, played_at);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_artist ON plays(user, artist_id);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_song ON plays(user, song_id);');
|
||||
|
||||
const _insert = db.prepare(`
|
||||
INSERT OR IGNORE INTO plays
|
||||
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const _history = db.prepare(`
|
||||
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source
|
||||
FROM plays WHERE user = ? AND played_at < ? ORDER BY played_at DESC LIMIT ?
|
||||
`);
|
||||
const _purge = db.prepare('DELETE FROM plays WHERE user = ?');
|
||||
const _count = db.prepare('SELECT COUNT(*) AS n FROM plays');
|
||||
|
||||
// ─── Auth (Navidrome ping.view, cached) ─────────────────────────────────────
|
||||
const _authCache = new Map(); // "u|t|s" -> { ok, exp }
|
||||
const _AUTH_TTL = 5 * 60 * 1000;
|
||||
|
||||
async function verifyUser(req) {
|
||||
const u = req.query.u || req.body?.u;
|
||||
const t = req.query.t || req.body?.t;
|
||||
const s = req.query.s || req.body?.s;
|
||||
if (!u || !t || !s || !NAVIDROME_URL) return null;
|
||||
|
||||
const key = `${u}|${t}|${s}`;
|
||||
const now = Date.now();
|
||||
const cached = _authCache.get(key);
|
||||
if (cached && cached.exp > now) return cached.ok ? u : null;
|
||||
|
||||
try {
|
||||
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`;
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||
const j = await r.json();
|
||||
const ok = j?.['subsonic-response']?.status === 'ok';
|
||||
_authCache.set(key, { ok, exp: now + _AUTH_TTL });
|
||||
return ok ? u : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────────────────────
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||
if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
|
||||
next();
|
||||
});
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
// Normalize a client timestamp to Unix seconds (accepts s or ms; defaults to now)
|
||||
function _toEpochSec(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return Math.floor(Date.now() / 1000);
|
||||
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
|
||||
}
|
||||
|
||||
// POST /scrobble/plays — ingest a batch { plays: [ {...} ] }, idempotent by uuid
|
||||
app.post('/scrobble/plays', async (req, res) => {
|
||||
const user = await verifyUser(req);
|
||||
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||
|
||||
const plays = Array.isArray(req.body?.plays) ? req.body.plays : [];
|
||||
if (!plays.length) return res.json({ accepted: 0, duplicates: 0 });
|
||||
|
||||
let accepted = 0, duplicates = 0, invalid = 0;
|
||||
for (const p of plays) {
|
||||
if (!p || !p.songId) { invalid++; continue; }
|
||||
const uuid = String(p.uuid || crypto.randomUUID());
|
||||
try {
|
||||
const r = _insert.run(
|
||||
user,
|
||||
String(p.songId),
|
||||
p.title ?? null,
|
||||
p.artist ?? null,
|
||||
p.artistId ?? null,
|
||||
p.album ?? null,
|
||||
p.albumId ?? null,
|
||||
Number.isFinite(Number(p.duration)) ? Math.floor(Number(p.duration)) : null,
|
||||
_toEpochSec(p.playedAt),
|
||||
p.source === 'android' ? 'android' : 'web',
|
||||
uuid
|
||||
);
|
||||
if (r.changes > 0) accepted++; else duplicates++;
|
||||
} catch { invalid++; }
|
||||
}
|
||||
res.json({ accepted, duplicates, invalid });
|
||||
});
|
||||
|
||||
// GET /scrobble/history?limit=&before= — raw chronological log (the archive)
|
||||
app.get('/scrobble/history', async (req, res) => {
|
||||
const user = await verifyUser(req);
|
||||
if (!user) return res.status(401).json({ error: 'unauthorized' });
|
||||
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||
const before = _toEpochSec(req.query.before || Date.now());
|
||||
const rows = _history.all(user, before, limit);
|
||||
res.json({ user, count: rows.length, plays: rows });
|
||||
});
|
||||
|
||||
// DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup).
|
||||
// Requires ANY valid Navidrome login (a deleted user can't authenticate as itself,
|
||||
// so cleanup after deletion must be authorized by a currently-valid account).
|
||||
app.delete('/scrobble/plays', async (req, res) => {
|
||||
const caller = await verifyUser(req);
|
||||
if (!caller) return res.status(401).json({ error: 'unauthorized' });
|
||||
const target = req.query.user || caller;
|
||||
const r = _purge.run(String(target));
|
||||
res.json({ purged: r.changes, user: target });
|
||||
});
|
||||
|
||||
// GET /scrobble/health — liveness + total rows (no auth)
|
||||
app.get('/scrobble/health', (_req, res) => {
|
||||
res.json({ ok: true, plays: _count.get().n, navidrome: !!NAVIDROME_URL });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`✅ NovaSonic scrobble service on :${PORT} (data: ${DATA_PATH})`));
|
||||
Reference in new issue
Block a user