diff --git a/docs/scrobble-plan.md b/docs/scrobble-plan.md index 3353e66..e72d49b 100644 --- a/docs/scrobble-plan.md +++ b/docs/scrobble-plan.md @@ -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. diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js index 7dc11f5..40085c4 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -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).