fix(statistics): accurate counts for large libraries + album-artist tooltip

Remove the 10-page cap in fetchStatisticsLibraryAggregates — the loop now runs until
the server returns a partial page, so albums/songs/playtime reflect the full library
regardless of size (previously capped at 5,000 albums). Switched sort type from
'newest' to 'alphabeticalByName' for a stable pagination order.

Add a tooltip on the Artists stat card explaining it shows album artists only (Subsonic
API limitation — track-level featured/guest artists without their own album are not
included). Tooltip added in all 8 locales. Labels with a tooltip get a dotted underline
and cursor:help as visual hint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-12 11:47:41 +02:00
parent ae2e1bcb97
commit 8f45f7230e
11 changed files with 21 additions and 11 deletions
+4 -9
View File
@@ -631,11 +631,10 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const maxPages = 10;
let capped = false;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('newest', pageSize, 0);
for (let page = 0; page < maxPages; page++) {
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
@@ -653,12 +652,8 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
g.albumCount += 1;
}
if (albums.length < pageSize) break;
if (page === maxPages - 1) {
capped = true;
break;
}
offset += pageSize;
nextPage = getAlbumList('newest', pageSize, offset);
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
+1
View File
@@ -785,6 +785,7 @@ export const deTranslation = {
genreDistribution: 'Genre-Verteilung (Top 20)',
loadMore: 'Mehr laden',
statArtists: 'Künstler',
statArtistsTooltip: 'Nur Album-Künstler — Künstler, die ausschließlich als Track-Künstler vorkommen (Featured, Gast usw.) und kein eigenes Album haben, werden nicht gezählt.',
statAlbums: 'Alben',
statSongs: 'Songs',
statGenres: 'Genres',
+1
View File
@@ -787,6 +787,7 @@ export const enTranslation = {
genreDistribution: 'Genre Distribution (Top 20)',
loadMore: 'Load more',
statArtists: 'Artists',
statArtistsTooltip: 'Album artists only — artists appearing only as a track-level artist (featured, guest, etc.) without their own album are not included.',
statAlbums: 'Albums',
statSongs: 'Songs',
statGenres: 'Genres',
+1
View File
@@ -774,6 +774,7 @@ export const esTranslation = {
genreDistribution: 'Distribución de Géneros (Top 20)',
loadMore: 'Cargar más',
statArtists: 'Artistas',
statArtistsTooltip: 'Solo artistas de álbum — los artistas que aparecen únicamente como artistas de pista (featuring, invitado, etc.) sin álbum propio no se cuentan.',
statAlbums: 'Álbumes',
statSongs: 'Canciones',
statGenres: 'Géneros',
+1
View File
@@ -783,6 +783,7 @@ export const frTranslation = {
genreDistribution: 'Répartition par genre (Top 20)',
loadMore: 'Charger plus',
statArtists: 'Artistes',
statArtistsTooltip: 'Artistes d\'album uniquement — les artistes apparaissant seulement sur des pistes (featuring, invité, etc.) sans album propre ne sont pas comptés.',
statAlbums: 'Albums',
statSongs: 'Morceaux',
statGenres: 'Genres',
+1
View File
@@ -782,6 +782,7 @@ export const nbTranslation = {
genreDistribution: 'Sjangerfordeling (Topp 20)',
loadMore: 'Last inn mer',
statArtists: 'Artister',
statArtistsTooltip: 'Kun albumartister — artister som bare opptrer som sporartist (featuring, gjest, osv.) uten eget album telles ikke med.',
statAlbums: 'Album',
statSongs: 'Sanger',
statGenres: 'Sjangere',
+1
View File
@@ -783,6 +783,7 @@ export const nlTranslation = {
genreDistribution: 'Genreverdeling (Top 20)',
loadMore: 'Meer laden',
statArtists: 'Artiesten',
statArtistsTooltip: 'Alleen albumartiesten — artiesten die enkel als trackartiest voorkomen (featuring, gast, enz.) zonder eigen album worden niet meegeteld.',
statAlbums: 'Albums',
statSongs: 'Nummers',
statGenres: 'Genres',
+1
View File
@@ -834,6 +834,7 @@ export const ruTranslation = {
genreDistribution: 'Жанры (топ-20)',
loadMore: 'Ещё',
statArtists: 'Исполнители',
statArtistsTooltip: 'Только исполнители альбомов — артисты, присутствующие лишь в треках (фичеринг, гость и т. д.) без собственного альбома, не учитываются.',
statAlbums: 'Альбомы',
statSongs: 'Треки',
statGenres: 'Жанры',
+1
View File
@@ -779,6 +779,7 @@ export const zhTranslation = {
genreDistribution: '流派分布(前 20',
loadMore: '加载更多',
statArtists: '艺术家',
statArtistsTooltip: '仅限专辑艺术家——仅作为单曲艺术家出现(合唱、客串等)且无自己专辑的艺术家不计入此处。',
statAlbums: '专辑',
statSongs: '歌曲',
statGenres: '流派',
+2 -2
View File
@@ -161,7 +161,7 @@ export default function Statistics() {
n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString();
const stats = [
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—', tooltip: t('statistics.statArtistsTooltip') },
{ label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) },
{ label: t('statistics.statSongs'), value: countDisplay(totalSongs) },
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
@@ -183,7 +183,7 @@ export default function Statistics() {
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value}</span>
<span className="stats-card-label">{s.label}</span>
<span className="stats-card-label" data-tooltip={s.tooltip} data-tooltip-wrap="true">{s.label}</span>
</div>
))}
</div>
+7
View File
@@ -3982,6 +3982,13 @@ html.no-compositing .fs-lyrics-rail {
color: var(--text-muted);
}
.stats-card-label[data-tooltip] {
cursor: help;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
/* Genre chart */
.genre-chart {
background: var(--bg-card);