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 topAlbums: List<StatAlbum> = emptyList(),
|
||||
val topTracks: List<StatTrack> = emptyList(),
|
||||
val topGenres: List<StatGenre> = emptyList(),
|
||||
val series: List<StatBucket> = emptyList(),
|
||||
val byHour: List<StatHour> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatGenre(
|
||||
val name: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatArtist(
|
||||
val name: String? = null,
|
||||
|
||||
@@ -60,6 +60,7 @@ class PlayLogClient(context: Context) {
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val genre: String? = null,
|
||||
val duration: Int? = null,
|
||||
val playedAt: Long,
|
||||
val source: String = "android",
|
||||
@@ -81,6 +82,7 @@ class PlayLogClient(context: Context) {
|
||||
album = song.album,
|
||||
albumId = song.albumId,
|
||||
coverArt = song.coverArt,
|
||||
genre = song.genre,
|
||||
duration = song.duration,
|
||||
playedAt = System.currentTimeMillis() / 1000,
|
||||
)
|
||||
|
||||
@@ -159,6 +159,12 @@ private fun StatsContent(
|
||||
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 }) {
|
||||
SectionHeader("Hör-Uhr (Stunde)")
|
||||
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
|
||||
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
|
||||
Reference in new issue
Block a user