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:
1 parent
249b693936
commit
0c7132a7f2
7 files changed
+56
-9
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user