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

@@ -11,10 +11,17 @@ data class ScrobbleStats(
val topArtists: List<StatArtist> = emptyList(), val topArtists: List<StatArtist> = emptyList(),
val topAlbums: List<StatAlbum> = emptyList(), val topAlbums: List<StatAlbum> = emptyList(),
val topTracks: List<StatTrack> = emptyList(), val topTracks: List<StatTrack> = emptyList(),
val topGenres: List<StatGenre> = emptyList(),
val series: List<StatBucket> = emptyList(), val series: List<StatBucket> = emptyList(),
val byHour: List<StatHour> = emptyList(), val byHour: List<StatHour> = emptyList(),
) )
@Serializable
data class StatGenre(
val name: String? = null,
val plays: Int = 0,
)
@Serializable @Serializable
data class StatArtist( data class StatArtist(
val name: String? = null, val name: String? = null,
@@ -60,6 +60,7 @@ class PlayLogClient(context: Context) {
val album: String? = null, val album: String? = null,
val albumId: String? = null, val albumId: String? = null,
val coverArt: String? = null, val coverArt: String? = null,
val genre: String? = null,
val duration: Int? = null, val duration: Int? = null,
val playedAt: Long, val playedAt: Long,
val source: String = "android", val source: String = "android",
@@ -81,6 +82,7 @@ class PlayLogClient(context: Context) {
album = song.album, album = song.album,
albumId = song.albumId, albumId = song.albumId,
coverArt = song.coverArt, coverArt = song.coverArt,
genre = song.genre,
duration = song.duration, duration = song.duration,
playedAt = System.currentTimeMillis() / 1000, playedAt = System.currentTimeMillis() / 1000,
) )
@@ -159,6 +159,12 @@ private fun StatsContent(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
} }
if (d.topGenres.isNotEmpty()) {
SectionHeader("Top-Genres")
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
Spacer(Modifier.height(20.dp))
}
if (d.byHour.any { it.plays > 0 }) { if (d.byHour.any { it.plays > 0 }) {
SectionHeader("Hör-Uhr (Stunde)") SectionHeader("Hör-Uhr (Stunde)")
HourClock(d.byHour.map { it.plays }) HourClock(d.byHour.map { it.plays })
@@ -166,6 +172,25 @@ private fun StatsContent(
} }
} }
@Composable
private fun GenreRow(rank: Int, name: String, plays: Int) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = NsOnSurface2, modifier = Modifier.width(22.dp),
)
Text(
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
)
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable @Composable
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) { private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
Text( Text(
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v71'; const CACHE = 'ns-v72';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+15 -6
View File
@@ -53,19 +53,21 @@ db.exec(`
played_at INTEGER NOT NULL, -- Unix epoch, UTC played_at INTEGER NOT NULL, -- Unix epoch, UTC
source TEXT, -- 'web' | 'android' 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 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 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_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_artist ON plays(user, artist_id);');
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_song ON plays(user, song_id);'); db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_song ON plays(user, song_id);');
const _insert = db.prepare(` const _insert = db.prepare(`
INSERT OR IGNORE INTO plays INSERT OR IGNORE INTO plays
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art) (user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art, genre)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const _history = db.prepare(` const _history = db.prepare(`
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source 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), _toEpochSec(p.playedAt),
p.source === 'android' ? 'android' : 'web', p.source === 'android' ? 'android' : 'web',
uuid, uuid,
p.coverArt ?? null p.coverArt ?? null,
p.genre ?? null
); );
if (r.changes > 0) accepted++; else duplicates++; if (r.changes > 0) accepted++; else duplicates++;
} catch { invalid++; } } catch { invalid++; }
@@ -220,6 +223,12 @@ app.get('/scrobble/stats', async (req, res) => {
GROUP BY song_id ORDER BY plays DESC LIMIT :limit GROUP BY song_id ORDER BY plays DESC LIMIT :limit
`).all(winL); `).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(` const series = db.prepare(`
SELECT strftime('${fmt}', played_at + :tz, 'unixepoch') AS bucket, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds 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 FROM plays WHERE ${WHERE} GROUP BY bucket ORDER BY bucket
@@ -238,7 +247,7 @@ app.get('/scrobble/stats', async (req, res) => {
firstPlayedAt, firstPlayedAt,
totalPlays: totals.plays, totalPlays: totals.plays,
totalSeconds: totals.seconds, totalSeconds: totals.seconds,
topArtists, topAlbums, topTracks, series, byHour, topArtists, topAlbums, topTracks, topGenres, series, byHour,
}); });
}); });
+1
View File
@@ -41,6 +41,7 @@ export function logPlay(song, source = 'web') {
album: song.album ?? null, album: song.album ?? null,
albumId: song.albumId ?? null, albumId: song.albumId ?? null,
coverArt: song.coverArt ?? null, // Subsonic coverArt token — what getCoverArt actually needs coverArt: song.coverArt ?? null, // Subsonic coverArt token — what getCoverArt actually needs
genre: song.genre ?? null,
duration: Number.isFinite(Number(song.duration)) ? Math.floor(Number(song.duration)) : null, duration: Number.isFinite(Number(song.duration)) ? Math.floor(Number(song.duration)) : null,
playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC
source, source,
+5 -2
View File
@@ -207,6 +207,7 @@ function _render(body, d) {
t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null), t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false, _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false,
a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null), a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null),
_topList('Top-Genres', d.topGenres, g => g.name, g => g.plays),
_hourClock(d.byHour), _hourClock(d.byHour),
].join(''); ].join('');
} }
@@ -249,9 +250,11 @@ function _topList(title, items, labelFn, valFn, idFn = null, circular = false, n
const rows = items.map((it, i) => { const rows = items.map((it, i) => {
const val = valFn(it); const val = valFn(it);
const cover = idFn ? coverUrl(idFn(it), 80) : null; const cover = idFn ? coverUrl(idFn(it), 80) : null;
const thumb = `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden"> const thumb = idFn
? `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''} ${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>`; </div>`
: '';
const nav = navFn ? navFn(it) : null; const nav = navFn ? navFn(it) : null;
const navAttrs = nav const navAttrs = nav
? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"` ? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"`