fix(statistics): cache Subsonic stat fetches and localize duration units

Add 7-minute TTL caches (same idea as rating prefetch) for overview
album strips, random-song format sample, and paginated library
aggregates; key by server and music folder.

Introduce formatHumanHoursMinutes with common.duration* strings in all
locales for Statistics and playlist duration labels.

Refresh RU/ZH statistics and nav wording; fix zh playtime label.
This commit is contained in:
Maxim Isaev
2026-04-10 01:39:08 +03:00
parent fc40d235d0
commit 6f6cb0fd6b
12 changed files with 233 additions and 90 deletions
+48 -78
View File
@@ -1,5 +1,13 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
import {
fetchStatisticsFormatSample,
fetchStatisticsLibraryAggregates,
fetchStatisticsOverview,
getAlbumList,
SubsonicAlbum,
SubsonicGenre,
} from '../api/subsonic';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import AlbumRow from '../components/AlbumRow';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
@@ -15,13 +23,6 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
}
function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
return `${m}m`;
}
const PERIODS: { key: LastfmPeriod; label: string }[] = [
{ key: '7day', label: 'lfmPeriod7day' },
{ key: '1month', label: 'lfmPeriod1month' },
@@ -59,21 +60,18 @@ export default function Statistics() {
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
useEffect(() => {
Promise.all([
getAlbumList('recent', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('highest', 12).catch(() => []),
getArtists().catch(() => []),
]).then(([rc, fr, hi, a]) => {
setRecent(rc);
setFrequent(fr);
setHighest(hi);
setArtistCount(a.length);
setLoading(false);
}).catch(() => setLoading(false));
fetchStatisticsOverview()
.then(d => {
setRecent(d.recent);
setFrequent(d.frequent);
setHighest(d.highest);
setArtistCount(d.artistCount);
setLoading(false);
})
.catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
// Background: playtime, album/song counts, genre insights (paginated getAlbumList — respects library filter; caps at 5000 albums)
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
useEffect(() => {
let cancelled = false;
setTotalPlaytime(null);
@@ -81,68 +79,40 @@ export default function Statistics() {
setTotalSongs(null);
setPlaytimeCapped(false);
setGenres([]);
const unknownGenre = t('statistics.decadeUnknown');
(async () => {
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
let offset = 0;
const pageSize = 500;
const maxPages = 10;
let capped = false;
for (let page = 0; page < maxPages; page++) {
try {
const albums = await getAlbumList('newest', pageSize, offset);
if (cancelled) return;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : unknownGenre;
const cur = genreAgg.get(label) ?? { songCount: 0, albumCount: 0 };
cur.songCount += sc;
cur.albumCount += 1;
genreAgg.set(label, cur);
}
if (albums.length < pageSize) break;
if (page === maxPages - 1) capped = true;
offset += pageSize;
} catch {
break;
try {
const agg = await fetchStatisticsLibraryAggregates();
if (cancelled) return;
setTotalPlaytime(agg.playtimeSec);
setTotalAlbums(agg.albumsCounted);
setTotalSongs(agg.songsCounted);
setPlaytimeCapped(agg.capped);
setGenres(agg.genres);
} catch {
if (!cancelled) {
setTotalPlaytime(0);
setTotalAlbums(0);
setTotalSongs(0);
setPlaytimeCapped(false);
setGenres([]);
}
}
if (!cancelled) {
setTotalPlaytime(playtimeSec);
setTotalAlbums(albumsCounted);
setTotalSongs(songsCounted);
setPlaytimeCapped(capped);
const sorted: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
setGenres(sorted);
}
})();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, t]);
}, [musicLibraryFilterVersion]);
// Background fetch: format distribution (sample of 500 random songs)
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
useEffect(() => {
let cancelled = false;
getRandomSongs(500).then(songs => {
if (cancelled) return;
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const sorted = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
setFormatData(sorted);
setFormatSampleSize(songs.length);
}).catch(() => {});
setFormatData(null);
setFormatSampleSize(0);
fetchStatisticsFormatSample()
.then(s => {
if (cancelled) return;
setFormatData(s.rows);
setFormatSampleSize(s.sampleSize);
})
.catch(() => {});
return () => { cancelled = true; };
}, [musicLibraryFilterVersion]);
@@ -185,7 +155,7 @@ export default function Statistics() {
const playtimeDisplay = totalPlaytime === null
? t('statistics.computing')
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
: (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime);
const countDisplay = (n: number | null) =>
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
@@ -229,10 +199,10 @@ export default function Statistics() {
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{topGenres.map(g => (
<div key={g.value}>
<div key={g.value || '__genre_unknown__'}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{g.value}
{g.value.trim() ? g.value : t('statistics.decadeUnknown')}
</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
{g.songCount.toLocaleString()}