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');
|
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 () => {
|
it('stores the time range in route search state', async () => {
|
||||||
const { history } = renderStatsRoute();
|
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 () => {
|
it('redirects back home when the page is not allowed', async () => {
|
||||||
window.SoulSyncWebShellBridge = createShellBridge({
|
window.SoulSyncWebShellBridge = createShellBridge({
|
||||||
isPageAllowed: vi.fn((pageId) => pageId !== 'stats'),
|
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 () => {
|
it('surfaces db storage and disk usage errors', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/stats/db-storage', () =>
|
http.get('/api/stats/db-storage', () =>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { queryOptions, type QueryClient } from '@tanstack/react-query';
|
import { queryOptions, type QueryClient } from '@tanstack/react-query';
|
||||||
|
import { HTTPError } from 'ky';
|
||||||
|
|
||||||
import { apiClient, readJson } from '@/app/api-client';
|
import { apiClient, readJson } from '@/app/api-client';
|
||||||
|
|
||||||
|
|
@ -12,18 +13,50 @@ import type {
|
||||||
StatsStreamTrackPayload,
|
StatsStreamTrackPayload,
|
||||||
} from './-stats.types';
|
} from './-stats.types';
|
||||||
|
|
||||||
|
import { EMPTY_STATS_PAYLOAD } from './-stats.helpers';
|
||||||
|
|
||||||
export const STATS_QUERY_KEY = ['stats'] as const;
|
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> {
|
export async function fetchStatsCached(range: StatsRange): Promise<StatsCachedPayload> {
|
||||||
const payload = await readJson<StatsCachedPayload>(
|
try {
|
||||||
apiClient.get('stats/cached', {
|
const payload = await readJson<StatsCachedPayload>(
|
||||||
searchParams: { range },
|
apiClient.get('stats/cached', {
|
||||||
}),
|
searchParams: { range },
|
||||||
);
|
}),
|
||||||
if (!payload.success) {
|
);
|
||||||
throw new Error(payload.error || 'Failed to load listening stats');
|
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> {
|
export async function fetchListeningStatsStatus(): Promise<ListeningStatsStatus> {
|
||||||
|
|
|
||||||
|
|
@ -871,22 +871,26 @@ async function playStatsTrack(
|
||||||
bridge: ShellBridge,
|
bridge: ShellBridge,
|
||||||
track: { title: string; artist: string; album: string },
|
track: { title: string; artist: string; album: string },
|
||||||
) {
|
) {
|
||||||
const resolvedTrack = await resolveStatsTrack(track.title, track.artist);
|
try {
|
||||||
if (resolvedTrack) {
|
const resolvedTrack = await resolveStatsTrack(track.title, track.artist);
|
||||||
await bridge.playLibraryTrack(
|
if (resolvedTrack) {
|
||||||
{
|
await bridge.playLibraryTrack(
|
||||||
id: resolvedTrack.id,
|
{
|
||||||
title: resolvedTrack.title,
|
id: resolvedTrack.id,
|
||||||
file_path: resolvedTrack.file_path,
|
title: resolvedTrack.title,
|
||||||
bitrate: resolvedTrack.bitrate,
|
file_path: resolvedTrack.file_path,
|
||||||
artist_id: resolvedTrack.artist_id,
|
bitrate: resolvedTrack.bitrate,
|
||||||
album_id: resolvedTrack.album_id,
|
artist_id: resolvedTrack.artist_id,
|
||||||
_stats_image: resolvedTrack.image_url || null,
|
album_id: resolvedTrack.album_id,
|
||||||
},
|
_stats_image: resolvedTrack.image_url || null,
|
||||||
resolvedTrack.album_title || track.album,
|
},
|
||||||
resolvedTrack.artist_name || track.artist,
|
resolvedTrack.album_title || track.album,
|
||||||
);
|
resolvedTrack.artist_name || track.artist,
|
||||||
return;
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Library resolve is best-effort; fall through to stream lookup on failure.
|
||||||
}
|
}
|
||||||
|
|
||||||
bridge.showLoadingOverlay(`Searching for ${track.title}...`);
|
bridge.showLoadingOverlay(`Searching for ${track.title}...`);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ export const Route = createFileRoute('/stats')({
|
||||||
loader: async ({ context, deps }) => {
|
loader: async ({ context, deps }) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
context.queryClient.ensureQueryData(statsCachedQueryOptions(deps.range)),
|
context.queryClient.ensureQueryData(statsCachedQueryOptions(deps.range)),
|
||||||
context.queryClient.ensureQueryData(listeningStatsStatusQueryOptions()),
|
context.queryClient
|
||||||
|
.fetchQuery({
|
||||||
|
...listeningStatsStatusQueryOptions(),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
.catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
component: StatsPage,
|
component: StatsPage,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue