feat(scrobble): time-travel navigator (year/month) + fix stats cover art

Navigator (web + android): browse Alles / Jahr / Monat with a ‹ › stepper — see
top plays for a specific month or year (e.g. March 2024). Steps into the future
are disabled. Uses the existing /scrobble/stats endpoint with computed from/to +
bucket (month for year view, day for month view). All windows verified.

Cover art fix: the app resolves covers from the Subsonic **coverArt token**, not
the raw entity id — so stats covers (which used albumId/songId) never loaded.
Now the play-log stores each play's coverArt (web + android capture), the stats
endpoint returns it for top tracks/albums, and both clients render coverUrl(coverArt)
with an id fallback. Old plays lack coverArt (blank until re-listened); artist
images still use artistId (may need getArtistInfo2 later if blank). Schema migrates
existing DBs via ALTER TABLE. SW cache -> ns-v69.

Android compileDebugKotlin + web build green; server windows + cover storage unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-08 13:11:34 +02:00
1 parent 14c0dea7e4
commit 86eb893f40
8 files changed
+184 -63

No files matched your search

+12 -6
View File
@@ -52,17 +52,20 @@ db.exec(`
duration INTEGER,
played_at INTEGER NOT NULL, -- Unix epoch, UTC
source TEXT, -- 'web' | 'android'
uuid TEXT UNIQUE -- idempotency for offline-retry
uuid TEXT UNIQUE, -- idempotency for offline-retry
cover_art TEXT -- Subsonic coverArt token (NOT the entity id) for real covers
);
`);
// Migration for DBs created before cover_art existed (ALTER throws if the column is already there).
try { db.exec('ALTER TABLE plays ADD COLUMN cover_art 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const _history = db.prepare(`
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source
@@ -141,7 +144,8 @@ app.post('/scrobble/plays', async (req, res) => {
Number.isFinite(Number(p.duration)) ? Math.floor(Number(p.duration)) : null,
_toEpochSec(p.playedAt),
p.source === 'android' ? 'android' : 'web',
uuid
uuid,
p.coverArt ?? null
);
if (r.changes > 0) accepted++; else duplicates++;
} catch { invalid++; }
@@ -198,14 +202,16 @@ app.get('/scrobble/stats', async (req, res) => {
`).all(winL);
const topAlbums = db.prepare(`
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist, COUNT(*) AS plays
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist,
MAX(cover_art) AS coverArt, 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,
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, song_id AS songId, COUNT(*) AS plays
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt,
song_id AS songId, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
`).all(winL);