fix(webui): harden stats route fallback flows

- make listening status prefetch optional so /stats still renders on status failures
- normalize no-stats-yet cache responses into the existing empty stats state
- restore streaming fallback when library track resolution errors
- add route and API regression coverage for the review fixes
pull/590/head
Antti Kettunen 2 weeks ago
parent 18a70be0df
commit ca84aa2e65
No known key found for this signature in database
GPG Key ID: C6B2A3D250359BD7

@ -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'),

@ -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', () =>

@ -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<StatsCachedPayload> {
const payload = await readJson<StatsCachedPayload>(
apiClient.get('stats/cached', {
searchParams: { range },
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load listening stats');
try {
const payload = await readJson<StatsCachedPayload>(
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<ListeningStatsStatus> {

@ -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}...`);

@ -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,

Loading…
Cancel
Save