feat(scrobble): time-travel navigator (year/month) + fix stats cover art

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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-08 13:11:34 +02:00
1 parent 14c0dea7e4
commit 86eb893f40
8 files changed
+184 -63

No files matched your search

@@ -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,
)
@@ -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,
)
@@ -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(
@@ -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<StatsUiState> = _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<Long, Long, String> {
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<Long, Long, String> {
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)