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:
1 parent
eb55184118
commit
e782b75bd9
2 files changed
+72
-2
No files matched your search
@@ -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 |
|
||||
| `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ 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 |
|
||||
|
||||
### Routing: eigene Origin, KEIN nginx-Upstream
|
||||
@@ -209,7 +209,9 @@ volumes:
|
||||
Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`),
|
||||
idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden
|
||||
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.
|
||||
5. *(optional)* ListenBrainz-Relay.
|
||||
|
||||
|
||||
@@ -160,6 +160,74 @@ app.get('/scrobble/history', async (req, res) => {
|
||||
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).
|
||||
// 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).
|
||||
|
||||
Reference in new issue
Block a user