mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -425,6 +425,157 @@ export async function prefetchAlbumUserRatings(
|
|||||||
return out;
|
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[]> {
|
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||||
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
|
||||||
...libraryFilterParams(),
|
...libraryFilterParams(),
|
||||||
|
|||||||
@@ -361,6 +361,8 @@ export const deTranslation = {
|
|||||||
updaterAurHint: 'Update über AUR installieren:',
|
updaterAurHint: 'Update über AUR installieren:',
|
||||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||||
updaterRetryBtn: 'Erneut versuchen',
|
updaterRetryBtn: 'Erneut versuchen',
|
||||||
|
durationHoursMinutes: '{{hours}} Std. {{minutes}} Min.',
|
||||||
|
durationMinutesOnly: '{{minutes}} Min.',
|
||||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -362,6 +362,8 @@ export const enTranslation = {
|
|||||||
updaterAurHint: 'Install the update via AUR:',
|
updaterAurHint: 'Install the update via AUR:',
|
||||||
updaterErrorMsg: 'Download failed',
|
updaterErrorMsg: 'Download failed',
|
||||||
updaterRetryBtn: 'Retry',
|
updaterRetryBtn: 'Retry',
|
||||||
|
durationHoursMinutes: '{{hours}}h {{minutes}}m',
|
||||||
|
durationMinutesOnly: '{{minutes}}m',
|
||||||
updaterOpenGitHub: 'Open on GitHub',
|
updaterOpenGitHub: 'Open on GitHub',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -361,6 +361,8 @@ export const frTranslation = {
|
|||||||
updaterAurHint: 'Installer la mise à jour via AUR :',
|
updaterAurHint: 'Installer la mise à jour via AUR :',
|
||||||
updaterErrorMsg: 'Échec du téléchargement',
|
updaterErrorMsg: 'Échec du téléchargement',
|
||||||
updaterRetryBtn: 'Réessayer',
|
updaterRetryBtn: 'Réessayer',
|
||||||
|
durationHoursMinutes: '{{hours}} h {{minutes}} min',
|
||||||
|
durationMinutesOnly: '{{minutes}} min',
|
||||||
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -361,6 +361,8 @@ export const nbTranslation = {
|
|||||||
updaterAurHint: 'Installer oppdateringen via AUR:',
|
updaterAurHint: 'Installer oppdateringen via AUR:',
|
||||||
updaterErrorMsg: 'Nedlasting mislyktes',
|
updaterErrorMsg: 'Nedlasting mislyktes',
|
||||||
updaterRetryBtn: 'Prøv igjen',
|
updaterRetryBtn: 'Prøv igjen',
|
||||||
|
durationHoursMinutes: '{{hours}} t {{minutes}} min',
|
||||||
|
durationMinutesOnly: '{{minutes}} min',
|
||||||
updaterOpenGitHub: 'Åpne på GitHub',
|
updaterOpenGitHub: 'Åpne på GitHub',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -361,6 +361,8 @@ export const nlTranslation = {
|
|||||||
updaterAurHint: 'Update installeren via AUR:',
|
updaterAurHint: 'Update installeren via AUR:',
|
||||||
updaterErrorMsg: 'Downloaden mislukt',
|
updaterErrorMsg: 'Downloaden mislukt',
|
||||||
updaterRetryBtn: 'Opnieuw proberen',
|
updaterRetryBtn: 'Opnieuw proberen',
|
||||||
|
durationHoursMinutes: '{{hours}} u {{minutes}} min',
|
||||||
|
durationMinutesOnly: '{{minutes}} min',
|
||||||
updaterOpenGitHub: 'Openen op GitHub',
|
updaterOpenGitHub: 'Openen op GitHub',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+7
-5
@@ -21,7 +21,7 @@ export const ruTranslation = {
|
|||||||
offlineLibrary: 'Офлайн-библиотека',
|
offlineLibrary: 'Офлайн-библиотека',
|
||||||
genres: 'Жанры',
|
genres: 'Жанры',
|
||||||
playlists: 'Плейлисты',
|
playlists: 'Плейлисты',
|
||||||
mostPlayed: 'Часто слушаемое',
|
mostPlayed: 'Популярное',
|
||||||
radio: 'Онлайн-радио',
|
radio: 'Онлайн-радио',
|
||||||
folderBrowser: 'Браузер папок',
|
folderBrowser: 'Браузер папок',
|
||||||
libraryScope: 'Область медиатеки',
|
libraryScope: 'Область медиатеки',
|
||||||
@@ -31,7 +31,7 @@ export const ruTranslation = {
|
|||||||
hero: 'Подборка',
|
hero: 'Подборка',
|
||||||
starred: 'Личное избранное',
|
starred: 'Личное избранное',
|
||||||
recent: 'Недавно добавлено',
|
recent: 'Недавно добавлено',
|
||||||
mostPlayed: 'Чаще всего',
|
mostPlayed: 'Популярное',
|
||||||
recentlyPlayed: 'Недавно проиграно',
|
recentlyPlayed: 'Недавно проиграно',
|
||||||
discover: 'Обзор',
|
discover: 'Обзор',
|
||||||
loadMore: 'Ещё',
|
loadMore: 'Ещё',
|
||||||
@@ -375,6 +375,8 @@ export const ruTranslation = {
|
|||||||
updaterAurHint: 'Установить обновление через AUR:',
|
updaterAurHint: 'Установить обновление через AUR:',
|
||||||
updaterErrorMsg: 'Ошибка загрузки',
|
updaterErrorMsg: 'Ошибка загрузки',
|
||||||
updaterRetryBtn: 'Повторить',
|
updaterRetryBtn: 'Повторить',
|
||||||
|
durationHoursMinutes: '{{hours}}ч {{minutes}}мин',
|
||||||
|
durationMinutesOnly: '{{minutes}}мин',
|
||||||
updaterOpenGitHub: 'Открыть на GitHub',
|
updaterOpenGitHub: 'Открыть на GitHub',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@@ -794,8 +796,8 @@ export const ruTranslation = {
|
|||||||
statAlbums: 'Альбомы',
|
statAlbums: 'Альбомы',
|
||||||
statSongs: 'Треки',
|
statSongs: 'Треки',
|
||||||
statGenres: 'Жанры',
|
statGenres: 'Жанры',
|
||||||
statPlaytime: 'Всего прослушано',
|
statPlaytime: 'Время звучания',
|
||||||
genreInsights: 'Жанры подробнее',
|
genreInsights: 'По жанрам',
|
||||||
formatDistribution: 'Форматы',
|
formatDistribution: 'Форматы',
|
||||||
formatSample: 'Выборка {{n}} треков',
|
formatSample: 'Выборка {{n}} треков',
|
||||||
computing: 'Считаем…',
|
computing: 'Считаем…',
|
||||||
@@ -933,7 +935,7 @@ export const ruTranslation = {
|
|||||||
downloadZip: 'Скачать (ZIP)',
|
downloadZip: 'Скачать (ZIP)',
|
||||||
},
|
},
|
||||||
mostPlayed: {
|
mostPlayed: {
|
||||||
title: 'Часто слушаемое',
|
title: 'Популярное',
|
||||||
topArtists: 'Топ исполнителей',
|
topArtists: 'Топ исполнителей',
|
||||||
topAlbums: 'Топ альбомов',
|
topAlbums: 'Топ альбомов',
|
||||||
plays: '{{n}} прослушиваний',
|
plays: '{{n}} прослушиваний',
|
||||||
|
|||||||
+3
-1
@@ -357,6 +357,8 @@ export const zhTranslation = {
|
|||||||
updaterAurHint: '通过 AUR 安装更新:',
|
updaterAurHint: '通过 AUR 安装更新:',
|
||||||
updaterErrorMsg: '下载失败',
|
updaterErrorMsg: '下载失败',
|
||||||
updaterRetryBtn: '重试',
|
updaterRetryBtn: '重试',
|
||||||
|
durationHoursMinutes: '{{hours}}小时{{minutes}}分钟',
|
||||||
|
durationMinutesOnly: '{{minutes}}分钟',
|
||||||
updaterOpenGitHub: '在 GitHub 上打开',
|
updaterOpenGitHub: '在 GitHub 上打开',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@@ -739,7 +741,7 @@ export const zhTranslation = {
|
|||||||
statAlbums: '专辑',
|
statAlbums: '专辑',
|
||||||
statSongs: '歌曲',
|
statSongs: '歌曲',
|
||||||
statGenres: '流派',
|
statGenres: '流派',
|
||||||
statPlaytime: '总播放时长',
|
statPlaytime: '音频总时长',
|
||||||
genreInsights: '流派洞察',
|
genreInsights: '流派洞察',
|
||||||
formatDistribution: '格式分布',
|
formatDistribution: '格式分布',
|
||||||
formatSample: '{{n}} 首曲目的样本',
|
formatSample: '{{n}} 首曲目的样本',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
|||||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
|
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||||
import StarRating from '../components/StarRating';
|
import StarRating from '../components/StarRating';
|
||||||
|
|
||||||
function sanitizeFilename(name: string): string {
|
function sanitizeFilename(name: string): string {
|
||||||
@@ -41,9 +42,7 @@ function formatDuration(seconds: number): string {
|
|||||||
|
|
||||||
function totalDurationLabel(songs: SubsonicSong[]): string {
|
function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||||
const h = Math.floor(total / 3600);
|
return formatHumanHoursMinutes(total);
|
||||||
const m = Math.floor((total % 3600) / 60);
|
|
||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function codecLabel(song: SubsonicSong): string {
|
function codecLabel(song: SubsonicSong): string {
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
|
|||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
return formatHumanHoursMinutes(seconds);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
if (h > 0) return `${h}h ${m}m`;
|
|
||||||
return `${m}m`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Playlists() {
|
export default function Playlists() {
|
||||||
|
|||||||
+48
-69
@@ -1,5 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 AlbumRow from '../components/AlbumRow';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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) });
|
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 }[] = [
|
const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||||
{ key: '7day', label: 'lfmPeriod7day' },
|
{ key: '7day', label: 'lfmPeriod7day' },
|
||||||
{ key: '1month', label: 'lfmPeriod1month' },
|
{ key: '1month', label: 'lfmPeriod1month' },
|
||||||
@@ -59,81 +60,59 @@ export default function Statistics() {
|
|||||||
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
fetchStatisticsOverview()
|
||||||
getAlbumList('recent', 20).catch(() => []),
|
.then(d => {
|
||||||
getAlbumList('frequent', 12).catch(() => []),
|
setRecent(d.recent);
|
||||||
getAlbumList('highest', 12).catch(() => []),
|
setFrequent(d.frequent);
|
||||||
getArtists().catch(() => []),
|
setHighest(d.highest);
|
||||||
getGenres().catch(() => []),
|
setArtistCount(d.artistCount);
|
||||||
]).then(([rc, fr, hi, a, g]) => {
|
setLoading(false);
|
||||||
setRecent(rc);
|
})
|
||||||
setFrequent(fr);
|
.catch(() => setLoading(false));
|
||||||
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));
|
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setTotalPlaytime(null);
|
setTotalPlaytime(null);
|
||||||
setTotalAlbums(null);
|
setTotalAlbums(null);
|
||||||
setTotalSongs(null);
|
setTotalSongs(null);
|
||||||
setPlaytimeCapped(false);
|
setPlaytimeCapped(false);
|
||||||
|
setGenres([]);
|
||||||
(async () => {
|
(async () => {
|
||||||
let playtimeSec = 0;
|
try {
|
||||||
let albumsCounted = 0;
|
const agg = await fetchStatisticsLibraryAggregates();
|
||||||
let songsCounted = 0;
|
if (cancelled) return;
|
||||||
let offset = 0;
|
setTotalPlaytime(agg.playtimeSec);
|
||||||
const pageSize = 500;
|
setTotalAlbums(agg.albumsCounted);
|
||||||
const maxPages = 10;
|
setTotalSongs(agg.songsCounted);
|
||||||
let capped = false;
|
setPlaytimeCapped(agg.capped);
|
||||||
for (let page = 0; page < maxPages; page++) {
|
setGenres(agg.genres);
|
||||||
try {
|
} catch {
|
||||||
const albums = await getAlbumList('newest', pageSize, offset);
|
if (!cancelled) {
|
||||||
if (cancelled) return;
|
setTotalPlaytime(0);
|
||||||
for (const a of albums) {
|
setTotalAlbums(0);
|
||||||
playtimeSec += a.duration ?? 0;
|
setTotalSongs(0);
|
||||||
albumsCounted += 1;
|
setPlaytimeCapped(false);
|
||||||
songsCounted += a.songCount ?? 0;
|
setGenres([]);
|
||||||
}
|
|
||||||
if (albums.length < pageSize) break;
|
|
||||||
if (page === maxPages - 1) capped = true;
|
|
||||||
offset += pageSize;
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
|
||||||
setTotalPlaytime(playtimeSec);
|
|
||||||
setTotalAlbums(albumsCounted);
|
|
||||||
setTotalSongs(songsCounted);
|
|
||||||
setPlaytimeCapped(capped);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion]);
|
||||||
|
|
||||||
// Background fetch: format distribution (sample of 500 random songs)
|
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getRandomSongs(500).then(songs => {
|
setFormatData(null);
|
||||||
if (cancelled) return;
|
setFormatSampleSize(0);
|
||||||
const counts: Record<string, number> = {};
|
fetchStatisticsFormatSample()
|
||||||
for (const song of songs) {
|
.then(s => {
|
||||||
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
|
if (cancelled) return;
|
||||||
counts[fmt] = (counts[fmt] ?? 0) + 1;
|
setFormatData(s.rows);
|
||||||
}
|
setFormatSampleSize(s.sampleSize);
|
||||||
const sorted = Object.entries(counts)
|
})
|
||||||
.map(([format, count]) => ({ format, count }))
|
.catch(() => {});
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
setFormatData(sorted);
|
|
||||||
setFormatSampleSize(songs.length);
|
|
||||||
}).catch(() => {});
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion]);
|
||||||
|
|
||||||
@@ -176,7 +155,7 @@ export default function Statistics() {
|
|||||||
|
|
||||||
const playtimeDisplay = totalPlaytime === null
|
const playtimeDisplay = totalPlaytime === null
|
||||||
? t('statistics.computing')
|
? t('statistics.computing')
|
||||||
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
|
: (playtimeCapped ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime);
|
||||||
|
|
||||||
const countDisplay = (n: number | null) =>
|
const countDisplay = (n: number | null) =>
|
||||||
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
|
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
|
||||||
@@ -220,10 +199,10 @@ export default function Statistics() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
{topGenres.map(g => (
|
{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' }}>
|
<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%' }}>
|
<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>
|
||||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||||
{g.songCount.toLocaleString()}
|
{g.songCount.toLocaleString()}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user