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
This commit is contained in:
parent
18a70be0df
commit
ca84aa2e65
5 changed files with 237 additions and 25 deletions
|
|
@ -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…
Reference in a new issue