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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 || '')}"`
|
||||||
|
|||||||
Reference in new issue
Block a user