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
+151
View File
@@ -425,6 +425,157 @@ export async function prefetchAlbumUserRatings(
return out;
}
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
export interface StatisticsLibraryAggregates {
playtimeSec: number;
albumsCounted: number;
songsCounted: number;
capped: boolean;
genres: SubsonicGenre[];
}
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch).
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const maxPages = 10;
let capped = false;
let offset = 0;
let nextPage = getAlbumList('newest', pageSize, 0);
for (let page = 0; page < maxPages; page++) {
try {
const albums = await nextPage;
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() : '';
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
if (albums.length < pageSize) break;
if (page === maxPages - 1) {
capped = true;
break;
}
offset += pageSize;
nextPage = getAlbumList('newest', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
export interface StatisticsOverviewData {
recent: SubsonicAlbum[];
frequent: SubsonicAlbum[];
highest: SubsonicAlbum[];
artistCount: number;
}
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
export interface StatisticsFormatSample {
rows: { format: string; count: number }[];
sampleSize: number;
}
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),