From 86eb893f409c0fa12623d95d6e35592a9bca113e Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 8 Aug 2026 13:11:34 +0200 Subject: [PATCH] feat(scrobble): time-travel navigator (year/month) + fix stats cover art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigator (web + android): browse Alles / Jahr / Monat with a ‹ › stepper — see top plays for a specific month or year (e.g. March 2024). Steps into the future are disabled. Uses the existing /scrobble/stats endpoint with computed from/to + bucket (month for year view, day for month view). All windows verified. Cover art fix: the app resolves covers from the Subsonic **coverArt token**, not the raw entity id — so stats covers (which used albumId/songId) never loaded. Now the play-log stores each play's coverArt (web + android capture), the stats endpoint returns it for top tracks/albums, and both clients render coverUrl(coverArt) with an id fallback. Old plays lack coverArt (blank until re-listened); artist images still use artistId (may need getArtistInfo2 later if blank). Schema migrates existing DBs via ALTER TABLE. SW cache -> ns-v69. Android compileDebugKotlin + web build green; server windows + cover storage unit-tested. Co-Authored-By: Claude Opus 4.8 --- .../novasonic/app/api/model/ScrobbleModels.kt | 2 + .../com/novasonic/app/data/PlayLogClient.kt | 2 + .../novasonic/app/ui/screen/StatsScreen.kt | 49 ++++++++-- .../novasonic/app/viewmodel/StatsViewModel.kt | 79 +++++++++++----- public/sw.js | 2 +- scrobble/scrobble-server.js | 18 ++-- src/player/scrobbleLog.js | 1 + src/ui/stats.js | 94 ++++++++++++++----- 8 files changed, 184 insertions(+), 63 deletions(-) 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 3d48a65..7d2ab50 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 @@ -27,6 +27,7 @@ data class StatAlbum( val name: String? = null, val albumId: String? = null, val artist: String? = null, + val coverArt: String? = null, val plays: Int = 0, ) @@ -37,6 +38,7 @@ data class StatTrack( val album: String? = null, val albumId: String? = null, val artistId: String? = null, + val coverArt: String? = null, val songId: String? = null, val plays: Int = 0, ) 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 1f697ee..5b4c80f 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 @@ -59,6 +59,7 @@ class PlayLogClient(context: Context) { val artistId: String? = null, val album: String? = null, val albumId: String? = null, + val coverArt: String? = null, val duration: Int? = null, val playedAt: Long, val source: String = "android", @@ -79,6 +80,7 @@ class PlayLogClient(context: Context) { artistId = song.artistId, album = song.album, albumId = song.albumId, + coverArt = song.coverArt, 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 5cdeb55..b2accc7 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 @@ -29,9 +29,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.novasonic.app.api.model.ScrobbleStats import com.novasonic.app.ui.theme.* -import com.novasonic.app.viewmodel.StatsPeriod +import com.novasonic.app.viewmodel.StatsView import com.novasonic.app.viewmodel.StatsViewModel +private val MONTHS = listOf( + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +) + @Composable fun StatsScreen( coverUrl: (String?) -> String?, @@ -56,24 +61,35 @@ fun StatsScreen( ) } - // ── Period chips ── + // ── Navigator: scope chips + year/month stepper ── Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), ) { - StatsPeriod.entries.forEach { p -> - val active = p == state.period + listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) -> + val active = v == state.view Text( - p.label, + l, fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = if (active) Color.White else NsOnSurface2, modifier = Modifier + .padding(end = 8.dp) .clip(RoundedCornerShape(999.dp)) .background(if (active) NsAccent else Color.Transparent) - .clickable { vm.setPeriod(p) } + .clickable { vm.setView(v) } .padding(horizontal = 14.dp, vertical = 7.dp), ) } + if (state.view != StatsView.ALL) { + Spacer(Modifier.weight(1f)) + StepArrow("‹", enabled = true) { vm.step(-1) } + Text( + if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}", + fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface, + modifier = Modifier.padding(horizontal = 8.dp), + ) + StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) } + } } when { @@ -120,7 +136,7 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) { if (d.topTracks.isNotEmpty()) { SectionHeader("Top-Songs") d.topTracks.forEachIndexed { i, t -> - TopRow(i + 1, coverUrl(t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays) + TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays) } Spacer(Modifier.height(20.dp)) } @@ -128,7 +144,7 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) { if (d.topAlbums.isNotEmpty()) { SectionHeader("Top-Alben") d.topAlbums.forEachIndexed { i, a -> - TopRow(i + 1, coverUrl(a.albumId), a.name ?: "—", a.artist, a.plays) + TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays) } Spacer(Modifier.height(20.dp)) } @@ -140,6 +156,19 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) { } } +@Composable +private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) { + Text( + glyph, + fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold, + color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + @Composable private fun Tile(value: String, label: String, modifier: Modifier = Modifier) { Column( diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt index 72bd29a..6d3c6b8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/StatsViewModel.kt @@ -12,17 +12,15 @@ import kotlinx.coroutines.launch import java.util.Calendar import java.util.TimeZone -enum class StatsPeriod(val key: String, val label: String, val bucket: String) { - ALL("all", "Alles", "month"), - YEAR("year", "Dieses Jahr", "month"), - LAST30("30d", "Letzte 30 Tage", "day"), -} +enum class StatsView { ALL, YEAR, MONTH } data class StatsUiState( val loading: Boolean = true, val configured: Boolean = true, val error: String? = null, - val period: StatsPeriod = StatsPeriod.ALL, + val view: StatsView = StatsView.ALL, + val year: Int = Calendar.getInstance().get(Calendar.YEAR), + val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11 val stats: ScrobbleStats? = null, ) @@ -32,37 +30,70 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) { private val _state = MutableStateFlow(StatsUiState()) val state: StateFlow = _state - init { load(StatsPeriod.ALL) } + init { load() } - fun setPeriod(p: StatsPeriod) { - if (p == _state.value.period && _state.value.stats != null) return - load(p) + fun setView(v: StatsView) { + if (v == _state.value.view) return + _state.value = _state.value.copy(view = v) + load() } - private fun periodRange(p: StatsPeriod): Triple { - val now = System.currentTimeMillis() / 1000 - return when (p) { - StatsPeriod.ALL -> Triple(0L, now + 1, p.bucket) - StatsPeriod.LAST30 -> Triple(now - 30L * 86400, now + 1, p.bucket) - StatsPeriod.YEAR -> { - val c = Calendar.getInstance().apply { - set(Calendar.MONTH, 0); set(Calendar.DAY_OF_MONTH, 1) - set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0) - } - Triple(c.timeInMillis / 1000, now + 1, p.bucket) + /** Step the current year/month window by ±1 (no-op in ALL). */ + fun step(dir: Int) { + val s = _state.value + when (s.view) { + StatsView.YEAR -> _state.value = s.copy(year = s.year + dir) + StatsView.MONTH -> { + var m = s.month + dir + var y = s.year + if (m < 0) { m = 11; y-- } + if (m > 11) { m = 0; y++ } + _state.value = s.copy(year = y, month = m) } + else -> return + } + load() + } + + /** True when the shown window is the current one — used to disable stepping into the future. */ + fun atCurrent(): Boolean { + val s = _state.value + val now = Calendar.getInstance() + return when (s.view) { + StatsView.YEAR -> s.year >= now.get(Calendar.YEAR) + StatsView.MONTH -> s.year > now.get(Calendar.YEAR) || + (s.year == now.get(Calendar.YEAR) && s.month >= now.get(Calendar.MONTH)) + else -> true } } - private fun load(period: StatsPeriod) { + private fun startOf(year: Int, month: Int, day: Int): Long { + val c = Calendar.getInstance().apply { + clear() + set(year, month, day, 0, 0, 0) + } + return c.timeInMillis / 1000 + } + + private fun range(): Triple { + val s = _state.value + val nowSec = System.currentTimeMillis() / 1000 + return when (s.view) { + StatsView.ALL -> Triple(0L, nowSec + 1, "month") + StatsView.YEAR -> Triple(startOf(s.year, 0, 1), startOf(s.year + 1, 0, 1), "month") + StatsView.MONTH -> Triple(startOf(s.year, s.month, 1), startOf(s.year, s.month + 1, 1), "day") + } + } + + private fun load() { viewModelScope.launch { - _state.value = _state.value.copy(loading = true, error = null, period = period) + _state.value = _state.value.copy(loading = true, error = null) val settings = repo.appSettingsFlow().first() if (settings.scrobbleUrl.isBlank()) { _state.value = _state.value.copy(loading = false, configured = false) return@launch } - val (from, to, bucket) = periodRange(period) + val (from, to, bucket) = range() val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000 val stats = repo.fetchScrobbleStats(from, to, tz, bucket, 10) _state.value = if (stats == null) diff --git a/public/sw.js b/public/sw.js index 03ec6c8..3e4e46b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v68'; +const CACHE = 'ns-v69'; 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 1839e0b..4e01121 100644 --- a/scrobble/scrobble-server.js +++ b/scrobble/scrobble-server.js @@ -52,17 +52,20 @@ db.exec(` duration INTEGER, played_at INTEGER NOT NULL, -- Unix epoch, UTC 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 ); `); +// Migration for DBs created before cover_art existed (ALTER throws if the column is already there). +try { db.exec('ALTER TABLE plays ADD COLUMN cover_art 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const _history = db.prepare(` SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source @@ -141,7 +144,8 @@ app.post('/scrobble/plays', async (req, res) => { Number.isFinite(Number(p.duration)) ? Math.floor(Number(p.duration)) : null, _toEpochSec(p.playedAt), p.source === 'android' ? 'android' : 'web', - uuid + uuid, + p.coverArt ?? null ); if (r.changes > 0) accepted++; else duplicates++; } catch { invalid++; } @@ -198,14 +202,16 @@ app.get('/scrobble/stats', async (req, res) => { `).all(winL); const topAlbums = db.prepare(` - SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist, COUNT(*) AS plays + SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist, + MAX(cover_art) AS coverArt, COUNT(*) AS plays FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit `).all(winL); const topTracks = db.prepare(` SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album, - MAX(album_id) AS albumId, MAX(artist_id) AS artistId, song_id AS songId, COUNT(*) AS plays + MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt, + song_id AS songId, COUNT(*) AS plays FROM plays WHERE ${WHERE} GROUP BY song_id ORDER BY plays DESC LIMIT :limit `).all(winL); diff --git a/src/player/scrobbleLog.js b/src/player/scrobbleLog.js index 637807a..4bc1ff3 100644 --- a/src/player/scrobbleLog.js +++ b/src/player/scrobbleLog.js @@ -40,6 +40,7 @@ export function logPlay(song, source = 'web') { artistId: song.artistId ?? null, album: song.album ?? null, albumId: song.albumId ?? null, + coverArt: song.coverArt ?? null, // Subsonic coverArt token — what getCoverArt actually needs 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 f0d8bb0..5b5a894 100644 --- a/src/ui/stats.js +++ b/src/ui/stats.js @@ -9,13 +9,14 @@ import { esc, formatDur } from '../utils.js'; import { subsonicAuthParams, coverUrl } from '../api.js'; let _overlay = null; -let _period = 'all'; // all | year | 30d -const PERIODS = [ - { key: 'all', label: 'Alles', bucket: 'month' }, - { key: 'year', label: 'Dieses Jahr', bucket: 'month' }, - { key: '30d', label: 'Letzte 30 Tage', bucket: 'day' }, -]; +// Time-travel navigator: browse all-time, a specific year, or a specific month. +let _view = 'all'; // all | year | month +let _year = new Date().getFullYear(); +let _month = new Date().getMonth(); // 0-11 + +const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; // Human listening time: "3h 12m" / "12m" / "45s" function _fmtTime(sec) { @@ -29,14 +30,47 @@ function _fmtTime(sec) { function _tzOffsetSec() { return -new Date().getTimezoneOffset() * 60; } -function _periodRange(key) { +// from/to (epoch seconds) + series bucket for the current navigator state. +function _range() { const now = Math.floor(Date.now() / 1000); - if (key === 'year') { - const from = Math.floor(new Date(new Date().getFullYear(), 0, 1).getTime() / 1000); - return { from, to: now + 1 }; + if (_view === 'year') { + return { + from: Math.floor(new Date(_year, 0, 1).getTime() / 1000), + to: Math.floor(new Date(_year + 1, 0, 1).getTime() / 1000), + bucket: 'month', + }; + } + if (_view === 'month') { + return { + from: Math.floor(new Date(_year, _month, 1).getTime() / 1000), + to: Math.floor(new Date(_year, _month + 1, 1).getTime() / 1000), + bucket: 'day', + }; + } + return { from: 0, to: now + 1, bucket: 'month' }; // all time +} + +function _periodLabel() { + if (_view === 'year') return String(_year); + if (_view === 'month') return `${_MONTHS[_month]} ${_year}`; + return 'Alles'; +} + +// True when the shown period is the current (real) one — used to disable stepping into the future. +function _atCurrent() { + const n = new Date(); + if (_view === 'year') return _year >= n.getFullYear(); + if (_view === 'month') return _year > n.getFullYear() || (_year === n.getFullYear() && _month >= n.getMonth()); + return true; +} + +function _step(dir) { + if (_view === 'year') { _year += dir; } + else if (_view === 'month') { + _month += dir; + if (_month < 0) { _month = 11; _year--; } + if (_month > 11) { _month = 0; _year++; } } - if (key === '30d') return { from: now - 30 * 86400, to: now + 1 }; - return { from: 0, to: now + 1 }; // all time } export function openStats() { @@ -73,14 +107,31 @@ function _buildOverlay() { function _renderTabs() { const tabs = _overlay.querySelector('#stats-tabs'); - tabs.innerHTML = PERIODS.map(p => ` - `).join(''); - tabs.querySelectorAll('.stats-tab').forEach(b => - b.addEventListener('click', () => { _period = b.dataset.period; _renderTabs(); _load(); })); + background:${k === _view ? 'var(--accent)' : 'transparent'}; + color:${k === _view ? '#fff' : 'var(--text2)'}">${l}`; + const stepBtn = (dir, label, disabled) => ` + `; + const stepper = _view === 'all' ? '' : ` +
+ ${stepBtn(-1, '‹', false)} +
${esc(_periodLabel())}
+ ${stepBtn(1, '›', _atCurrent())} +
`; + tabs.innerHTML = `
+ ${scopes.map(([k, l]) => chip(k, l)).join('')}${stepper} +
`; + tabs.querySelectorAll('[data-scope]').forEach(b => + b.addEventListener('click', () => { _view = b.dataset.scope; _renderTabs(); _load(); })); + tabs.querySelectorAll('[data-step]').forEach(b => + b.addEventListener('click', () => { if (!b.disabled) { _step(+b.dataset.step); _renderTabs(); _load(); } })); } async function _load() { @@ -96,8 +147,7 @@ async function _load() { body.innerHTML = `
Lade…
`; - const bucket = PERIODS.find(p => p.key === _period)?.bucket || 'month'; - const { from, to } = _periodRange(_period); + const { from, to, bucket } = _range(); const qs = `${auth}&from=${from}&to=${to}&tz=${_tzOffsetSec()}&bucket=${bucket}&limit=10`; try { const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`); @@ -123,8 +173,8 @@ function _render(body, d) { _tiles(d), _seriesChart(d.series, d.range.bucket), _topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true), - _topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.albumId || t.songId), - _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.albumId), + _topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId), + _topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId), _hourClock(d.byHour), ].join(''); }