feat(scrobble stats): genre ranking (Top-Genres) — web + android

Play-log now stores each play's genre (from the song tags; web scrobbleLog +
android PlayLogClient capture song.genre). The stats endpoint returns topGenres
(GROUP BY lower(genre), case-insensitive, blanks ignored); web + android render a
Top-Genres section. Schema migrates via ALTER TABLE. Old plays have no genre
(fills going forward, like cover art). SW cache -> ns-v72.

Android compileDebugKotlin + web build green; genre grouping + null-ignore unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-11 01:35:00 +02:00
1 parent 249b693936
commit 0c7132a7f2
7 files changed
+56 -9

No files matched your search

+15 -6
View File
@@ -53,19 +53,21 @@ db.exec(`
played_at INTEGER NOT NULL, -- Unix epoch, UTC
source TEXT, -- 'web' | 'android'
uuid TEXT UNIQUE, -- idempotency for offline-retry
cover_art TEXT -- Subsonic coverArt token (NOT the entity id) for real covers
cover_art TEXT, -- Subsonic coverArt token (NOT the entity id) for real covers
genre TEXT -- per-play genre (from the song's tags) for genre stats
);
`);
// Migration for DBs created before cover_art existed (ALTER throws if the column is already there).
// Migrations for DBs created before a column existed (ALTER throws if it's already there).
try { db.exec('ALTER TABLE plays ADD COLUMN cover_art TEXT'); } catch {}
try { db.exec('ALTER TABLE plays ADD COLUMN genre 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, cover_art)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art, genre)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const _history = db.prepare(`
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source
@@ -145,7 +147,8 @@ app.post('/scrobble/plays', async (req, res) => {
_toEpochSec(p.playedAt),
p.source === 'android' ? 'android' : 'web',
uuid,
p.coverArt ?? null
p.coverArt ?? null,
p.genre ?? null
);
if (r.changes > 0) accepted++; else duplicates++;
} catch { invalid++; }
@@ -220,6 +223,12 @@ app.get('/scrobble/stats', async (req, res) => {
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
`).all(winL);
const topGenres = db.prepare(`
SELECT MAX(genre) AS name, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
GROUP BY lower(genre) 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
@@ -238,7 +247,7 @@ app.get('/scrobble/stats', async (req, res) => {
firstPlayedAt,
totalPlays: totals.plays,
totalSeconds: totals.seconds,
topArtists, topAlbums, topTracks, series, byHour,
topArtists, topAlbums, topTracks, topGenres, series, byHour,
});
});