fix(statistics): library-scoped genre stats, cached fetches, duration i18n

fix(statistics): library-scoped genre stats, cached fetches, duration i18n
This commit is contained in:
Psychotoxical
2026-04-10 01:11:29 +02:00
committed by GitHub
12 changed files with 234 additions and 82 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(),
+2
View File
@@ -361,6 +361,8 @@ export const deTranslation = {
updaterAurHint: 'Update über AUR installieren:',
updaterErrorMsg: 'Download fehlgeschlagen',
updaterRetryBtn: 'Erneut versuchen',
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
durationMinutesOnly: '{{minutes}} Min.',
updaterOpenGitHub: 'Auf GitHub öffnen',
},
settings: {
+2
View File
@@ -362,6 +362,8 @@ export const enTranslation = {
updaterAurHint: 'Install the update via AUR:',
updaterErrorMsg: 'Download failed',
updaterRetryBtn: 'Retry',
durationHoursMinutes: '{{hours}}h {{minutes}}m',
durationMinutesOnly: '{{minutes}}m',
updaterOpenGitHub: 'Open on GitHub',
},
settings: {
+2
View File
@@ -361,6 +361,8 @@ export const frTranslation = {
updaterAurHint: 'Installer la mise à jour via AUR :',
updaterErrorMsg: 'Échec du téléchargement',
updaterRetryBtn: 'Réessayer',
durationHoursMinutes: '{{hours}} h {{minutes}} min',
durationMinutesOnly: '{{minutes}} min',
updaterOpenGitHub: 'Ouvrir sur GitHub',
},
settings: {
+2
View File
@@ -361,6 +361,8 @@ export const nbTranslation = {
updaterAurHint: 'Installer oppdateringen via AUR:',
updaterErrorMsg: 'Nedlasting mislyktes',
updaterRetryBtn: 'Prøv igjen',
durationHoursMinutes: '{{hours}} t {{minutes}} min',
durationMinutesOnly: '{{minutes}} min',
updaterOpenGitHub: 'Åpne på GitHub',
},
settings: {
+2
View File
@@ -361,6 +361,8 @@ export const nlTranslation = {
updaterAurHint: 'Update installeren via AUR:',
updaterErrorMsg: 'Downloaden mislukt',
updaterRetryBtn: 'Opnieuw proberen',
durationHoursMinutes: '{{hours}} u {{minutes}} min',
durationMinutesOnly: '{{minutes}} min',
updaterOpenGitHub: 'Openen op GitHub',
},
settings: {
+7 -5
View File
@@ -21,7 +21,7 @@ export const ruTranslation = {
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
playlists: 'Плейлисты',
mostPlayed: 'Часто слушаемое',
mostPlayed: 'Популярное',
radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
libraryScope: 'Область медиатеки',
@@ -31,7 +31,7 @@ export const ruTranslation = {
hero: 'Подборка',
starred: 'Личное избранное',
recent: 'Недавно добавлено',
mostPlayed: 'Чаще всего',
mostPlayed: 'Популярное',
recentlyPlayed: 'Недавно проиграно',
discover: 'Обзор',
loadMore: 'Ещё',
@@ -375,6 +375,8 @@ export const ruTranslation = {
updaterAurHint: 'Установить обновление через AUR:',
updaterErrorMsg: 'Ошибка загрузки',
updaterRetryBtn: 'Повторить',
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
durationMinutesOnly: '{{minutes}}мин',
updaterOpenGitHub: 'Открыть на GitHub',
},
settings: {
@@ -794,8 +796,8 @@ export const ruTranslation = {
statAlbums: 'Альбомы',
statSongs: 'Треки',
statGenres: 'Жанры',
statPlaytime: 'Всего прослушано',
genreInsights: 'Жанры подробнее',
statPlaytime: 'Время звучания',
genreInsights: 'По жанрам',
formatDistribution: 'Форматы',
formatSample: 'Выборка {{n}} треков',
computing: 'Считаем…',
@@ -933,7 +935,7 @@ export const ruTranslation = {
downloadZip: 'Скачать (ZIP)',
},
mostPlayed: {
title: 'Часто слушаемое',
title: 'Популярное',
topArtists: 'Топ исполнителей',
topAlbums: 'Топ альбомов',
plays: '{{n}} прослушиваний',
+3 -1
View File
@@ -357,6 +357,8 @@ export const zhTranslation = {
updaterAurHint: '通过 AUR 安装更新:',
updaterErrorMsg: '下载失败',
updaterRetryBtn: '重试',
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
durationMinutesOnly: '{{minutes}}分钟',
updaterOpenGitHub: '在 GitHub 上打开',
},
settings: {
@@ -739,7 +741,7 @@ export const zhTranslation = {
statAlbums: '专辑',
statSongs: '歌曲',
statGenres: '流派',
statPlaytime: '总播放时长',
statPlaytime: '音频总时长',
genreInsights: '流派洞察',
formatDistribution: '格式分布',
formatSample: '{{n}} 首曲目的样本',
+2 -3
View File
@@ -23,6 +23,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import StarRating from '../components/StarRating';
function sanitizeFilename(name: string): string {
@@ -41,9 +42,7 @@ function formatDuration(seconds: number): string {
function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
return formatHumanHoursMinutes(total);
}
function codecLabel(song: SubsonicSong): string {
+2 -4
View File
@@ -6,12 +6,10 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
return formatHumanHoursMinutes(seconds);
}
export default function Playlists() {
+48 -69
View File
@@ -1,5 +1,13 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getArtists, getGenres, 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,81 +60,59 @@ 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(() => []),
getGenres().catch(() => []),
]).then(([rc, fr, hi, a, g]) => {
setRecent(rc);
setFrequent(fr);
setHighest(hi);
setArtistCount(a.length);
// Album/song totals come from paginated getAlbumList (see playtime effect) — getGenres is not musicFolder-scoped.
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
setGenres(sorted);
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 (same paginated list as 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);
setTotalAlbums(null);
setTotalSongs(null);
setPlaytimeCapped(false);
setGenres([]);
(async () => {
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
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;
songsCounted += a.songCount ?? 0;
}
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);
}
})();
return () => { cancelled = true; };
}, [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]);
@@ -176,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();
@@ -220,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()}
+11
View File
@@ -0,0 +1,11 @@
import i18n from '../i18n';
/** Totals / statistics: localized "N hours M minutes" (not track mm:ss). */
export function formatHumanHoursMinutes(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) {
return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m });
}
return i18n.t('common.durationMinutesOnly', { minutes: m });
}