feat(scrobble): /scrobble/stats aggregation endpoint (Phase 3 backend)

Per-user listening stats via on-the-fly GROUP BY strftime (no rollup tables):
totals (plays + seconds), top artists/albums/tracks, a day/month/year time
series, and a 24h listening clock. Local-day bucketing via a tz offset.
Params exactly matched per query (node:sqlite throws on extra named params);
from defaults to all-time (avoiding the _toEpochSec(0)->now trap). Unit-verified.

Next: the web statistics view. Wrapped endpoint is Phase 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-07 17:09:42 +02:00
1 parent eb55184118
commit e782b75bd9
2 files changed
+72 -2

No files matched your search

+4 -2
View File
@@ -106,7 +106,7 @@ Android: [PlayerController.kt](../android/app/src/main/kotlin/com/novasonic/app/
| `GET /scrobble/history?limit=&before=` | Roh-Verlauf chronologisch = **das Archiv** | ✅ Phase 1 | | `GET /scrobble/history?limit=&before=` | Roh-Verlauf chronologisch = **das Archiv** | ✅ Phase 1 |
| `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ Phase 1 | | `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ Phase 1 |
| `GET /scrobble/health` | Liveness + Zeilen-Zahl | ✅ Phase 1 | | `GET /scrobble/health` | Liveness + Zeilen-Zahl | ✅ Phase 1 |
| `GET /scrobble/stats?range=day\|month\|year&from=&to=&tz=` | Aggregate: Top-Artists/Alben/Tracks, Summe Plays + Hörzeit, Zeit-Buckets für Charts | Phase 3 | | `GET /scrobble/stats?from=&to=&tz=&bucket=day\|month\|year&limit=` | Aggregate: totals, Top-Artists/Alben/Tracks, Zeit-Serie (day/month/year), Hör-Uhr (byHour) | ✅ Phase 3 (Endpoint) |
| `GET /scrobble/wrapped?year=YYYY&tz=` | Fertiger Wrapped-Payload | Phase 4 | | `GET /scrobble/wrapped?year=YYYY&tz=` | Fertiger Wrapped-Payload | Phase 4 |
### Routing: eigene Origin, KEIN nginx-Upstream ### Routing: eigene Origin, KEIN nginx-Upstream
@@ -209,7 +209,9 @@ volumes:
Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`), Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`),
idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden
Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.* Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.*
3. **`/scrobble/stats`** + Web-Statistik-View (Heatmap, Monats-Balken, Top-Listen). 3. 🔨 **In Arbeit**: `/scrobble/stats` ✅ (Endpoint: totals, Top-Listen, day/month/year-Serie,
Hör-Uhr; on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) — **noch offen: Web-Statistik-View**
(Kalender-Heatmap, Monats-Balken, Top-Listen).
4. **`/scrobble/wrapped`** + Story-Seite; Android-Stats-Screen. 4. **`/scrobble/wrapped`** + Story-Seite; Android-Stats-Screen.
5. *(optional)* ListenBrainz-Relay. 5. *(optional)* ListenBrainz-Relay.
+68
View File
@@ -160,6 +160,74 @@ app.get('/scrobble/history', async (req, res) => {
res.json({ user, count: rows.length, plays: rows }); 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, 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). // 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, // 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). // so cleanup after deletion must be authorized by a currently-valid account).