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

+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),
_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),
_topList('Top-Genres', d.topGenres, g => g.name, g => g.plays),
_hourClock(d.byHour),
].join('');
}
@@ -249,9 +250,11 @@ function _topList(title, items, labelFn, valFn, idFn = null, circular = false, n
const rows = items.map((it, i) => {
const val = valFn(it);
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'">` : ''}
</div>`;
</div>`
: '';
const nav = navFn ? navFn(it) : null;
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 || '')}"`