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:
1 parent
14c0dea7e4
commit
86eb893f40
8 files changed
+182
-61
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 -> {
|
||||
/** 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 startOf(year: Int, month: Int, day: Int): Long {
|
||||
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)
|
||||
clear()
|
||||
set(year, month, day, 0, 0, 0)
|
||||
}
|
||||
Triple(c.timeInMillis / 1000, now + 1, p.bucket)
|
||||
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(period: StatsPeriod) {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+72
-22
@@ -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 => `
|
||||
<button class="stats-tab${p.key === _period ? ' active' : ''}" data-period="${p.key}"
|
||||
const scopes = [['all', 'Alles'], ['year', 'Jahr'], ['month', 'Monat']];
|
||||
const chip = (k, l) => `
|
||||
<button class="stats-tab" data-scope="${k}"
|
||||
style="padding:7px 14px;border-radius:999px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;
|
||||
border:1px solid var(--border);transition:all .15s;
|
||||
background:${p.key === _period ? 'var(--accent)' : 'transparent'};
|
||||
color:${p.key === _period ? '#fff' : 'var(--text2)'}">${p.label}</button>`).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}</button>`;
|
||||
const stepBtn = (dir, label, disabled) => `
|
||||
<button data-step="${dir}" ${disabled ? 'disabled' : ''}
|
||||
style="width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:transparent;
|
||||
color:var(--text2);cursor:${disabled ? 'default' : 'pointer'};opacity:${disabled ? 0.35 : 1};
|
||||
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
|
||||
const stepper = _view === 'all' ? '' : `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||
${stepBtn(-1, '‹', false)}
|
||||
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
|
||||
${stepBtn(1, '›', _atCurrent())}
|
||||
</div>`;
|
||||
tabs.innerHTML = `<div style="display:flex;align-items:center;gap:6px;width:100%;flex-wrap:wrap">
|
||||
${scopes.map(([k, l]) => chip(k, l)).join('')}${stepper}
|
||||
</div>`;
|
||||
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 = `<div style="text-align:center;color:var(--text2);padding:40px 0">Lade…</div>`;
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user