Top-Titel gruppierte nach song_id. Wird eine Datei beim Bearbeiten umbenannt, vergibt Navidrome eine neue id — der Titel erschien danach zweimal mit geteilten Zaehlern. Kuenstler/Alben/Genres gruppierten nach lower(), sodass schon ein doppeltes Leerzeichen zwei Eintraege ergab. Schluessel ist jetzt durchgehend normalisiert (lower + trim + Leerzeichen gefaltet), Top-Titel gruppiert nach Titel+Kuenstler statt nach song_id. Die Anzeigefelder kommen aus dem JUENGSTEN Play statt aus MAX(), sonst zeigte die Statistik nach einer Korrektur weiter die alte Schreibweise. Am laufenden Dienst geprueft: 40 Plays vor und 10 nach einer Umbenennung erscheinen als ein Eintrag mit 50 Plays, neuer Schreibweise und neuer songId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
19 KiB
JavaScript
399 lines
19 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
|
|
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&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 });
|
|
});
|
|
|
|
// 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 }));
|
|
|
|
const aTracks = tracksOf(userA);
|
|
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
|
|
const sharedTracks = [];
|
|
for (const ta of aTracks) {
|
|
const tb = bTrackMap.get(ta.songId);
|
|
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));
|
|
|
|
res.json({
|
|
userA, userB, range: { from, to },
|
|
matchScore,
|
|
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})`));
|