diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt index 00a33f7..bd8fb6b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ScrobbleModels.kt @@ -11,10 +11,17 @@ data class ScrobbleStats( val topArtists: List = emptyList(), val topAlbums: List = emptyList(), val topTracks: List = emptyList(), + val topGenres: List = emptyList(), val series: List = emptyList(), val byHour: List = emptyList(), ) +@Serializable +data class StatGenre( + val name: String? = null, + val plays: Int = 0, +) + @Serializable data class StatArtist( val name: String? = null, diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt index 5b4c80f..06a09ac 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt @@ -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, ) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt index 12ed327..93a10ac 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StatsScreen.kt @@ -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( diff --git a/public/sw.js b/public/sw.js index 93a123d..a26e99c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v71'; +const CACHE = 'ns-v72'; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js index 7c4018a..ea033f1 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -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, }); }); diff --git a/src/player/scrobbleLog.js b/src/player/scrobbleLog.js index 4bc1ff3..e1f550b 100644 --- a/src/player/scrobbleLog.js +++ b/src/player/scrobbleLog.js @@ -41,6 +41,7 @@ export function logPlay(song, source = 'web') { album: song.album ?? null, albumId: song.albumId ?? null, 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, playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC source, diff --git a/src/ui/stats.js b/src/ui/stats.js index 7825a12..b3d8c7a 100644 --- a/src/ui/stats.js +++ b/src/ui/stats.js @@ -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 = `
+ const thumb = idFn + ? `
${cover ? `` : ''} -
`; +
` + : ''; 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 || '')}"`