import React, { useEffect, useState } from 'react'; import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic'; import AlbumRow from '../components/AlbumRow'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useNavigate } from 'react-router-dom'; import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string { const diff = Math.floor(Date.now() / 1000) - timestamp; if (diff < 60) return t('statistics.lfmJustNow'); if (diff < 3600) return t('statistics.lfmMinutesAgo', { n: Math.floor(diff / 60) }); if (diff < 86400) return t('statistics.lfmHoursAgo', { n: Math.floor(diff / 3600) }); 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' }, { key: '3month', label: 'lfmPeriod3month' }, { key: '6month', label: 'lfmPeriod6month' }, { key: '12month', label: 'lfmPeriod12month' }, { key: 'overall', label: 'lfmPeriodOverall' }, ]; export default function Statistics() { const { t } = useTranslation(); const navigate = useNavigate(); const { lastfmSessionKey, lastfmUsername } = useAuthStore(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [recent, setRecent] = useState([]); const [frequent, setFrequent] = useState([]); const [highest, setHighest] = useState([]); const [artistCount, setArtistCount] = useState(null); const [totalSongs, setTotalSongs] = useState(null); const [totalAlbums, setTotalAlbums] = useState(null); const [genres, setGenres] = useState([]); const [loading, setLoading] = useState(true); const [totalPlaytime, setTotalPlaytime] = useState(null); const [playtimeCapped, setPlaytimeCapped] = useState(false); const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null); const [formatSampleSize, setFormatSampleSize] = useState(0); const [lfmPeriod, setLfmPeriod] = useState('1month'); const [lfmTopArtists, setLfmTopArtists] = useState([]); const [lfmTopAlbums, setLfmTopAlbums] = useState([]); const [lfmTopTracks, setLfmTopTracks] = useState([]); const [lfmLoading, setLfmLoading] = useState(false); const [lfmRecentTracks, setLfmRecentTracks] = useState([]); 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)); }, [musicLibraryFilterVersion]); // Background: playtime + album/song counts (same paginated list as library filter; caps at 5000 albums) useEffect(() => { let cancelled = false; setTotalPlaytime(null); setTotalAlbums(null); setTotalSongs(null); setPlaytimeCapped(false); (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; } } if (!cancelled) { setTotalPlaytime(playtimeSec); setTotalAlbums(albumsCounted); setTotalSongs(songsCounted); setPlaytimeCapped(capped); } })(); return () => { cancelled = true; }; }, [musicLibraryFilterVersion]); // Background fetch: format distribution (sample of 500 random songs) useEffect(() => { let cancelled = false; getRandomSongs(500).then(songs => { if (cancelled) return; const counts: Record = {}; 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(() => {}); return () => { cancelled = true; }; }, [musicLibraryFilterVersion]); useEffect(() => { if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; setLfmRecentLoading(true); lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20) .then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); }) .catch(() => setLfmRecentLoading(false)); }, [lastfmSessionKey, lastfmUsername]); useEffect(() => { if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return; setLfmLoading(true); Promise.all([ lastfmGetTopArtists(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), lastfmGetTopAlbums(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), lastfmGetTopTracks(lastfmUsername, lastfmSessionKey, lfmPeriod, 10), ]).then(([artists, albums, tracks]) => { setLfmTopArtists(artists); setLfmTopAlbums(albums); setLfmTopTracks(tracks); setLfmLoading(false); }).catch(() => setLfmLoading(false)); }, [lfmPeriod, lastfmSessionKey, lastfmUsername]); const loadMore = async ( type: 'frequent' | 'highest', currentList: SubsonicAlbum[], setter: React.Dispatch> ) => { try { const more = await getAlbumList(type, 12, currentList.length); const newItems = more.filter(m => !currentList.find(c => c.id === m.id)); if (newItems.length > 0) setter(prev => [...prev, ...newItems]); } catch (e) { console.error('Failed to load more', e); } }; const playtimeDisplay = totalPlaytime === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime); const countDisplay = (n: number | null) => n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString(); const stats = [ { label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' }, { label: t('statistics.statAlbums'), value: countDisplay(totalAlbums) }, { label: t('statistics.statSongs'), value: countDisplay(totalSongs) }, { label: t('statistics.statPlaytime'), value: playtimeDisplay }, ]; const topGenres = genres.slice(0, 10); const maxGenreSongs = topGenres[0]?.songCount ?? 1; return (

{t('statistics.title')}

{loading ? (
) : (
{stats.map(s => (
{s.value} {s.label}
))}
{/* Genre Insights + Format Distribution */} {(topGenres.length > 0 || formatData) && (
{topGenres.length > 0 && (

{t('statistics.genreInsights')}

{topGenres.map(g => (
{g.value} {g.songCount.toLocaleString()}
))}
)} {formatData && (

{t('statistics.formatDistribution')}

{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}

{formatData.map(f => { const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0; return (
{f.format} {pct}%
); })}
)}
)} {recent.length > 0 && ( )} loadMore('frequent', frequent, setFrequent)} moreText={t('statistics.loadMore')} /> loadMore('highest', highest, setHighest)} moreText={t('statistics.loadMore')} showRating /> {/* Last.fm Stats */} {lastfmIsConfigured() && (

{t('statistics.lfmTitle')}

{lastfmSessionKey && (
{PERIODS.map(p => ( ))}
)}
{!lastfmSessionKey ? (

{t('statistics.lfmNotConnected')}

) : lfmLoading ? (
) : (
{([ { label: t('statistics.lfmTopArtists'), items: lfmTopArtists.map(a => ({ primary: a.name, secondary: null, playcount: a.playcount })) }, { label: t('statistics.lfmTopAlbums'), items: lfmTopAlbums.map(a => ({ primary: a.name, secondary: a.artist, playcount: a.playcount })) }, { label: t('statistics.lfmTopTracks'), items: lfmTopTracks.map(tr => ({ primary: tr.name, secondary: tr.artist, playcount: tr.playcount })) }, ] as { label: string; items: { primary: string; secondary: string | null; playcount: string }[] }[]).map(col => { const max = Math.max(...col.items.map(it => Number(it.playcount)), 1); return (

{col.label}

    {col.items.map((it, i) => (
  1. {i + 1}
    {it.primary}
    {it.secondary && (
    {it.secondary}
    )}
    {Number(it.playcount).toLocaleString()}
  2. ))}
); })}
)}
)} {/* Recent Scrobbles */} {lastfmIsConfigured() && lastfmSessionKey && (

{t('statistics.lfmRecentTracks')}

{lfmRecentLoading ? (
) : (
{lfmRecentTracks.slice(0, 3).map((track, i) => (
{track.name} {track.nowPlaying && ( {t('statistics.lfmNowPlaying')} )}
{track.artist}{track.album ? ` · ${track.album}` : ''}
{track.nowPlaying ? '' : track.timestamp ? relativeTime(track.timestamp, t) : ''}
))}
)}
)}
)}
); }