diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index 189ca154..bfc44343 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -106,6 +106,62 @@ describe('stats route', () => { expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('stats'); }); + it('still renders when listening stats status prefetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('/api/stats/cached')) { + return createResponse({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }); + } + if (url.includes('/api/listening-stats/status')) { + return createResponse({ error: 'status unavailable' }, false, 500); + } + if (url.includes('/api/stats/db-storage')) { + return createResponse({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }); + } + if (url.includes('/api/stats/library-disk-usage')) { + return createResponse({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }); + } + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('Not synced yet')).toBeInTheDocument(); + }); + it('stores the time range in route search state', async () => { const { history } = renderStatsRoute(); @@ -125,6 +181,80 @@ describe('stats route', () => { ); }); + it('falls back to streaming when track resolution fails', async () => { + window.SoulSyncWebShellBridge = createShellBridge({ + startStream: vi.fn(), + }); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('/api/stats/cached')) { + return createResponse({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [{ name: 'Track A', artist: 'Artist A', album: 'Album A', play_count: 3 }], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }); + } + if (url.includes('/api/listening-stats/status')) { + return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); + } + if (url.includes('/api/stats/resolve-track')) { + return createResponse({ error: 'resolve unavailable' }, false, 500); + } + if (url.includes('/api/enhanced-search/stream-track')) { + return createResponse({ + success: true, + result: { stream_url: '/api/stream/1' }, + }); + } + if (url.includes('/api/stats/db-storage')) { + return createResponse({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }); + } + if (url.includes('/api/stats/library-disk-usage')) { + return createResponse({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }); + } + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + + renderStatsRoute(); + + fireEvent.click((await screen.findAllByTitle('Play'))[0]); + + await waitFor(() => + expect(window.SoulSyncWebShellBridge?.startStream).toHaveBeenCalledWith({ + stream_url: '/api/stream/1', + }), + ); + expect(window.SoulSyncWebShellBridge?.playLibraryTrack).not.toHaveBeenCalled(); + }); + it('redirects back home when the page is not allowed', async () => { window.SoulSyncWebShellBridge = createShellBridge({ isPageAllowed: vi.fn((pageId) => pageId !== 'stats'), diff --git a/webui/src/routes/stats/-stats.api.test.ts b/webui/src/routes/stats/-stats.api.test.ts index 9a91b93f..ab85853e 100644 --- a/webui/src/routes/stats/-stats.api.test.ts +++ b/webui/src/routes/stats/-stats.api.test.ts @@ -38,6 +38,46 @@ describe('stats api', () => { }); }); + it('returns an empty payload when cached stats are not available yet', async () => { + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ success: false, error: 'Listening stats not synced yet' }), + ), + ); + + await expect(fetchStatsCached('7d')).resolves.toMatchObject({ + success: true, + overview: { total_plays: 0 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }); + + it('returns an empty payload when the server reports a cache miss as an HTTP error', async () => { + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ error: 'No cached stats available yet' }, { status: 500 }), + ), + ); + + await expect(fetchStatsCached('7d')).resolves.toMatchObject({ + success: true, + overview: { total_plays: 0 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }); + it('surfaces db storage and disk usage errors', async () => { server.use( http.get('/api/stats/db-storage', () => diff --git a/webui/src/routes/stats/-stats.api.ts b/webui/src/routes/stats/-stats.api.ts index c8e69d9e..e0514233 100644 --- a/webui/src/routes/stats/-stats.api.ts +++ b/webui/src/routes/stats/-stats.api.ts @@ -1,4 +1,5 @@ import { queryOptions, type QueryClient } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; import { apiClient, readJson } from '@/app/api-client'; @@ -12,18 +13,50 @@ import type { StatsStreamTrackPayload, } from './-stats.types'; +import { EMPTY_STATS_PAYLOAD } from './-stats.helpers'; + export const STATS_QUERY_KEY = ['stats'] as const; +const NO_STATS_YET_PATTERNS = [ + /not synced/i, + /no listening stats/i, + /no cached stats/i, + /cache miss/i, + /stats cache.*(missing|empty|not found)/i, +] as const; + +function isNoStatsYetMessage(message: string | undefined): boolean { + if (!message) return false; + return NO_STATS_YET_PATTERNS.some((pattern) => pattern.test(message)); +} + +function getEmptyStatsPayload(): StatsCachedPayload { + return { + success: true, + ...EMPTY_STATS_PAYLOAD, + }; +} + export async function fetchStatsCached(range: StatsRange): Promise { - const payload = await readJson( - apiClient.get('stats/cached', { - searchParams: { range }, - }), - ); - if (!payload.success) { - throw new Error(payload.error || 'Failed to load listening stats'); + try { + const payload = await readJson( + apiClient.get('stats/cached', { + searchParams: { range }, + }), + ); + if (!payload.success) { + if (isNoStatsYetMessage(payload.error)) { + return getEmptyStatsPayload(); + } + throw new Error(payload.error || 'Failed to load listening stats'); + } + return payload; + } catch (error) { + if (error instanceof HTTPError && isNoStatsYetMessage(error.message)) { + return getEmptyStatsPayload(); + } + throw error; } - return payload; } export async function fetchListeningStatsStatus(): Promise { diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 988cf8c9..cf19f02e 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -871,22 +871,26 @@ async function playStatsTrack( bridge: ShellBridge, track: { title: string; artist: string; album: string }, ) { - const resolvedTrack = await resolveStatsTrack(track.title, track.artist); - if (resolvedTrack) { - await bridge.playLibraryTrack( - { - id: resolvedTrack.id, - title: resolvedTrack.title, - file_path: resolvedTrack.file_path, - bitrate: resolvedTrack.bitrate, - artist_id: resolvedTrack.artist_id, - album_id: resolvedTrack.album_id, - _stats_image: resolvedTrack.image_url || null, - }, - resolvedTrack.album_title || track.album, - resolvedTrack.artist_name || track.artist, - ); - return; + try { + const resolvedTrack = await resolveStatsTrack(track.title, track.artist); + if (resolvedTrack) { + await bridge.playLibraryTrack( + { + id: resolvedTrack.id, + title: resolvedTrack.title, + file_path: resolvedTrack.file_path, + bitrate: resolvedTrack.bitrate, + artist_id: resolvedTrack.artist_id, + album_id: resolvedTrack.album_id, + _stats_image: resolvedTrack.image_url || null, + }, + resolvedTrack.album_title || track.album, + resolvedTrack.artist_name || track.artist, + ); + return; + } + } catch { + // Library resolve is best-effort; fall through to stream lookup on failure. } bridge.showLoadingOverlay(`Searching for ${track.title}...`); diff --git a/webui/src/routes/stats/route.tsx b/webui/src/routes/stats/route.tsx index 364eb651..358d6f36 100644 --- a/webui/src/routes/stats/route.tsx +++ b/webui/src/routes/stats/route.tsx @@ -21,7 +21,12 @@ export const Route = createFileRoute('/stats')({ loader: async ({ context, deps }) => { await Promise.all([ context.queryClient.ensureQueryData(statsCachedQueryOptions(deps.range)), - context.queryClient.ensureQueryData(listeningStatsStatusQueryOptions()), + context.queryClient + .fetchQuery({ + ...listeningStatsStatusQueryOptions(), + retry: false, + }) + .catch(() => undefined), ]); }, component: StatsPage,