import React, { useEffect, useState } from 'react'; import { Share2 } from 'lucide-react'; import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview, getAlbumList, SubsonicAlbum, SubsonicGenre, } from '../api/subsonic'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import AlbumRow from '../components/AlbumRow'; import StatsExportModal from '../components/StatsExportModal'; 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) }); } 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 [exportOpen, setExportOpen] = useState(false); 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(() => { 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, genre insights (cached per server+library like rating prefetch) useEffect(() => { let cancelled = false; setTotalPlaytime(null); setTotalAlbums(null); setTotalSongs(null); setPlaytimeCapped(false); setGenres([]); (async () => { 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([]); } } })(); return () => { cancelled = true; }; }, [musicLibraryFilterVersion]); // Background: format distribution (cached random sample, same TTL as other Statistics fetches) useEffect(() => { let cancelled = false; setFormatData(null); setFormatSampleSize(0); fetchStatisticsFormatSample() .then(s => { if (cancelled) return; setFormatData(s.rows); setFormatSampleSize(s.sampleSize); }) .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 ? '≥ ' : '') + formatHumanHoursMinutes(totalPlaytime); const countDisplay = (n: number | null) => n === null ? t('statistics.computing') : (playtimeCapped ? '≥ ' : '') + n.toLocaleString(); const stats = [ { 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 }, ]; 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.trim() ? g.value : t('statistics.decadeUnknown')} {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')} headerExtra={frequent.length >= 9 ? ( ) : undefined} /> 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) : ''}
))}
)}
)}
)} setExportOpen(false)} />
); }