/** * 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 cover_art TEXT, -- Subsonic coverArt token (NOT the entity id) for real covers genre TEXT -- per-play genre (from the song's tags) for genre stats ); `); // Migrations for DBs created before a column existed (ALTER throws if it's already there). try { db.exec('ALTER TABLE plays ADD COLUMN cover_art TEXT'); } catch {} try { db.exec('ALTER TABLE plays ADD COLUMN genre TEXT'); } catch {} 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, cover_art, genre) 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-Scrobble&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, p.coverArt ?? null, p.genre ?? null ); 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) // Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache // Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald // jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik). // SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu // vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor). const normKey = (col) => `lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`; // 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. Parse `from` directly — do NOT route through _toEpochSec, which maps // 0 → now (so the client's explicit from=0 for "all time" would wrongly become an empty window). const fromN = Number(req.query.from); const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 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); // Earliest play across ALL of the user's history (unfiltered by window) — lets the client cap the // navigator so you can't step before logging began. const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null; // Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte // Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen. const topArtists = db.prepare(` WITH neueste AS ( SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at) FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k ), zaehler AS ( SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k ) SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds FROM neueste n JOIN zaehler z ON z.k = n.k ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit `).all(winL); const topAlbums = db.prepare(` WITH neueste AS ( SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, album, album_id, artist, cover_art, MAX(played_at) FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2 ), zaehler AS ( SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2 ) SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist, n.cover_art AS coverArt, z.plays AS plays FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2 ORDER BY z.plays DESC LIMIT :limit `).all(winL); // Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt // Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist // deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem // JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten // blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums. const topTracks = db.prepare(` WITH neueste AS ( SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at) FROM plays WHERE ${WHERE} GROUP BY k1, k2 ), zaehler AS ( SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays FROM plays WHERE ${WHERE} GROUP BY k1, k2 ) SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId, n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2 ORDER BY z.plays DESC LIMIT :limit `).all(winL); const topGenres = db.prepare(` SELECT MAX(genre) AS name, COUNT(*) AS plays FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> '' GROUP BY ${normKey('genre')} 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 }, firstPlayedAt, totalPlays: totals.plays, totalSeconds: totals.seconds, topArtists, topAlbums, topTracks, topGenres, series, byHour, }); }); // GET /scrobble/users — distinct users in the log, for the Head-to-Head partner picker. // Any valid Navidrome login may list them (so you can pick who to compare against). app.get('/scrobble/users', async (req, res) => { const me = await verifyUser(req); if (!me) return res.status(401).json({ error: 'unauthorized' }); const rows = db.prepare(` SELECT user, COUNT(*) AS plays, MAX(played_at) AS lastPlayedAt FROM plays GROUP BY user ORDER BY plays DESC `).all(); res.json({ me, users: rows }); }); // Gegenstueck zu normKey() in JS — fuer Abgleiche, die nicht in SQL stattfinden. const _norm = (v) => (v || '').toLowerCase().replace(/[\t\s]+/g, ' ').trim(); // GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs // another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100). // Also returns shared artists/tracks and each side's unique top artists. app.get('/scrobble/compare', async (req, res) => { const userA = await verifyUser(req); if (!userA) return res.status(401).json({ error: 'unauthorized' }); const userB = String(req.query.with || '').trim(); if (!userB) return res.status(400).json({ error: 'missing ?with=' }); if (userB === userA) return res.status(400).json({ error: 'same user' }); // Window (default all-time). Parse `from` directly — _toEpochSec maps 0 → now (see /stats). const fromN = Number(req.query.from); const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0; const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1; const WHERE = 'user = :user AND played_at >= :from AND played_at < :to'; const artistsOf = u => db.prepare(` SELECT ${normKey('artist')} AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY ${normKey('artist')} `).all({ user: u, from, to }); const tracksOf = u => db.prepare(` SELECT MAX(song_id) AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId, MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')} `).all({ user: u, from, to }); const totalsOf = u => db.prepare( `SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}` ).get({ user: u, from, to }); const aArtists = artistsOf(userA), bArtists = artistsOf(userB); const aMap = new Map(aArtists.map(r => [r.k, r])); const bMap = new Map(bArtists.map(r => [r.k, r])); // Cosine similarity over the artist-play vectors → a stable 0..100 "match". let dot = 0, nA = 0, nB = 0; for (const r of aArtists) nA += r.plays * r.plays; for (const r of bArtists) nB += r.plays * r.plays; for (const [k, ra] of aMap) { const rb = bMap.get(k); if (rb) dot += ra.plays * rb.plays; } const matchScore = (nA && nB) ? Math.round((dot / (Math.sqrt(nA) * Math.sqrt(nB))) * 100) : 0; const sharedArtists = []; for (const [k, ra] of aMap) { const rb = bMap.get(k); if (rb) sharedArtists.push({ name: ra.name, artistId: ra.artistId || rb.artistId, playsA: ra.plays, playsB: rb.plays }); } sharedArtists.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB)); const onlyTop = (mine, other) => mine .filter(r => !other.has(r.k)) .sort((x, y) => y.plays - x.plays).slice(0, 8) .map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays })); // Gemeinsame Titel ueber Titel+Kuenstler abgleichen, NICHT ueber song_id: haben beide denselben // Song aus verschiedenen Dateien gehoert — oder wurde eine Datei zwischendurch umbenannt, wobei // Navidrome eine neue id vergibt —, galt er sonst faelschlich als nicht gemeinsam. const tKey = (r) => `${_norm(r.title)}\u0000${_norm(r.artist)}`; const aTracks = tracksOf(userA); const bTrackMap = new Map(tracksOf(userB).map(r => [tKey(r), r])); const sharedTracks = []; for (const ta of aTracks) { const tb = bTrackMap.get(tKey(ta)); if (tb) sharedTracks.push({ songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId, albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays, }); } sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB)); // ── Anzeigetafel: Kategorien, die je einen Sieger haben ────────────────────── // Bewusst Kennzahlen, die etwas ueber die HOERWEISE sagen statt ueber schiere Menge — sonst // gewinnt immer, wer laenger dabei ist. Unentschieden ist erlaubt und zaehlt fuer niemanden. const nachtAnteil = u => { const r = db.prepare(` SELECT COUNT(*) AS n, SUM(CASE WHEN CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) < 5 THEN 1 ELSE 0 END) AS nacht FROM plays WHERE ${WHERE} `).get({ user: u, from, to, tz: Math.trunc(Number(req.query.tz) || 0) }); return r.n ? (r.nacht || 0) / r.n : 0; }; const tA = totalsOf(userA), tB = totalsOf(userB); const exklusivAnteil = (mine, other) => mine.length ? mine.filter(r => !other.has(r.k)).length / mine.length : 0; const tiefe = (t, artists) => (artists ? t.plays / artists : 0); const kat = (schluessel, label, a, b, format, hoeherGewinnt = true) => { const gleich = Math.abs(a - b) < 1e-9; return { key: schluessel, label, a, b, textA: format(a), textB: format(b), sieger: gleich ? null : ((a > b) === hoeherGewinnt ? 'A' : 'B'), }; }; const std = v => `${(v / 3600).toFixed(1)} h`; const proz = v => `${Math.round(v * 100)} %`; const zahl = v => v.toLocaleString('de-DE'); const komma = v => v.toFixed(1); const kategorien = [ kat('hoerzeit', 'Hörzeit', tA.seconds, tB.seconds, std), kat('vielfalt', 'Vielfalt', aArtists.length, bArtists.length, zahl), kat('tiefe', 'Tiefe', tiefe(tA, aArtists.length), tiefe(tB, bArtists.length), komma), kat('nachteule', 'Nachteule', nachtAnteil(userA), nachtAnteil(userB), proz), kat('entdecker', 'Entdeckerdrang', exklusivAnteil(aArtists, bMap), exklusivAnteil(bArtists, aMap), proz), ]; const punkteA = kategorien.filter(k => k.sieger === 'A').length; const punkteB = kategorien.filter(k => k.sieger === 'B').length; res.json({ userA, userB, range: { from, to }, matchScore, battle: { kategorien, punkteA, punkteB }, totalsA: { ...totalsOf(userA), artists: aArtists.length }, totalsB: { ...totalsOf(userB), artists: bArtists.length }, sharedArtists: sharedArtists.slice(0, 30), sharedTracks: sharedTracks.slice(0, 30), onlyA: onlyTop(aArtists, bMap), onlyB: onlyTop(bArtists, aMap), }); }); // 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})`));