Files
NovaSonic/scrobble/scrobble-server.js
T
HoneyandClaude Opus 4.8 fff6f9e4da feat(scrobble): Android stats screen + cover art in stats (web + android)
Phase 4 (part B): Android statistics screen consuming GET /scrobble/stats —
tiles (plays + listening time), day/month/year play series, top artists/tracks/
albums, 24h listening clock, period tabs; opened from a Settings button
("Meine Statistiken"). New StatsViewModel + StatsScreen + ScrobbleModels;
PlayLogClient.fetchStats() (tolerant JSON decode, null on any failure);
repo.fetchScrobbleStats(); nav route + SettingsScreen(onOpenStats).

Cover art everywhere: top artists show a circular artist image, tracks/albums
a square cover — resolved client-side from Navidrome via artistId/albumId/songId
(no coverArt knowledge in the scrobble service). Endpoint now also returns
albumId for top tracks so track covers resolve reliably. Web stats list gets the
same thumbnails. SW cache -> ns-v67.

Android compileDebugKotlin + web build green. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:41:27 +02:00

249 lines
11 KiB
JavaScript

/**
* 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 });
});
// GET /scrobble/stats — aggregated listening stats for the authed user.
// ?from=&to= Unix seconds window (default: all time)
// ?tz= seconds east of UTC for local-day bucketing (default 0)
// ?bucket= day | month | year — granularity of the time series (default day)
// ?limit= top-list size (default 20, max 100)
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
app.get('/scrobble/stats', async (req, res) => {
const user = await verifyUser(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
const tz = Math.trunc(Number(req.query.tz) || 0);
// Default window = all time. Route through _toEpochSec only when provided (it maps 0 → now).
const from = req.query.from ? _toEpochSec(req.query.from) : 0;
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
const bucket = _STATS_FMT[req.query.bucket] ? req.query.bucket : 'day';
const fmt = _STATS_FMT[bucket];
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const win = { user, from, to }; // node:sqlite throws on extra named params —
const winL = { user, from, to, limit }; // so each query gets exactly the params it uses.
const winT = { user, from, to, tz };
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
const totals = db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
).get(win);
const topArtists = db.prepare(`
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit
`).all(winL);
const topAlbums = db.prepare(`
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> ''
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit
`).all(winL);
const topTracks = db.prepare(`
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, song_id AS songId, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
`).all(winL);
const series = db.prepare(`
SELECT strftime('${fmt}', played_at + :tz, 'unixepoch') AS bucket, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} GROUP BY bucket ORDER BY bucket
`).all(winT);
const hourRows = db.prepare(`
SELECT CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) AS hr, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY hr
`).all(winT);
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, plays: 0 }));
for (const r of hourRows) if (r.hr >= 0 && r.hr < 24) byHour[r.hr].plays = r.plays;
res.json({
user,
range: { from, to, tz, bucket },
totalPlays: totals.plays,
totalSeconds: totals.seconds,
topArtists, topAlbums, topTracks, series, byHour,
});
});
// 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})`));