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:
1 parent
14c0dea7e4
commit
86eb893f40
8 files changed
+184
-63
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user