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

+1
View File
@@ -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
View File
@@ -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('');
}