diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte
index b25c5f31..9c83f655 100644
--- a/web/src/lib/components/Shell.svelte
+++ b/web/src/lib/components/Shell.svelte
@@ -26,6 +26,7 @@
{ href: '/library/liked', label: 'Liked' },
{ href: '/search', label: 'Search' },
{ href: '/discover', label: 'Discover' },
+ { href: '/requests', label: 'Requests' },
{ href: '/playlists', label: 'Playlists' },
{ href: '/settings', label: 'Settings' }
];
diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts
index cae5eed2..b4489f46 100644
--- a/web/src/lib/components/Shell.test.ts
+++ b/web/src/lib/components/Shell.test.ts
@@ -28,11 +28,12 @@ describe('Shell', () => {
expect(screen.getByText('alice')).toBeInTheDocument();
});
- test('renders nav items including Discover between Search and Playlists', () => {
+ test('renders nav items including Discover and Requests between Search and Playlists', () => {
render(Shell);
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search');
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
+ expect(screen.getByRole('link', { name: 'Requests' })).toHaveAttribute('href', '/requests');
expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists');
});
diff --git a/web/src/routes/requests/+page.svelte b/web/src/routes/requests/+page.svelte
new file mode 100644
index 00000000..72c0d6b9
--- /dev/null
+++ b/web/src/routes/requests/+page.svelte
@@ -0,0 +1,140 @@
+
+
+
+
+
+ {#if query.isError}
+
+ {:else if query.isPending}
+
Reading the ledger…
+ {:else if requests.length === 0}
+
Nothing requested yet.
+ {:else}
+
+ {#each requests as r (r.id)}
+ {@const Icon = fallbackIcon(r.kind)}
+ {@const href = listenHref(r)}
+ -
+
+
+
+
+
+
+ {r.kind}
+
+
+
+ {rowTitle(r)}
+
+
+ {rowMeta(r)}
+
+ {#if r.status === 'rejected' && r.notes}
+
+ {r.notes}
+
+ {/if}
+
+
+
+ {#if r.status === 'pending'}
+
+ {:else if r.status === 'completed' && href}
+
+ Listen
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/web/src/routes/requests/requests.test.ts b/web/src/routes/requests/requests.test.ts
new file mode 100644
index 00000000..097fd9e9
--- /dev/null
+++ b/web/src/routes/requests/requests.test.ts
@@ -0,0 +1,159 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+import { mockQuery } from '../../test-utils/query';
+import type { LidarrRequest } from '$lib/api/types';
+
+// Capture invalidateQueries on the mocked QueryClient so the cancel test can
+// assert against the same instance the page consumed.
+const invalidateQueries = vi.fn().mockResolvedValue(undefined);
+
+vi.mock('$lib/api/requests', () => ({
+ createMyRequestsQuery: vi.fn(),
+ cancelRequest: vi.fn().mockResolvedValue({ id: 'r1' })
+}));
+
+vi.mock('$lib/api/queries', () => ({
+ qk: {
+ myRequests: () => ['myRequests']
+ }
+}));
+
+vi.mock('$lib/api/client', () => ({
+ api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() }
+}));
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return {
+ ...actual,
+ useQueryClient: () => ({ invalidateQueries })
+ };
+});
+
+import RequestsPage from './+page.svelte';
+import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests';
+import { qk } from '$lib/api/queries';
+
+const mockedCreateMyRequestsQuery = createMyRequestsQuery as ReturnType;
+const mockedCancelRequest = cancelRequest as ReturnType;
+
+function req(over: Partial = {}): LidarrRequest {
+ return {
+ id: 'r1',
+ user_id: 'u1',
+ status: 'pending',
+ kind: 'album',
+ lidarr_artist_mbid: 'art-mbid',
+ lidarr_album_mbid: 'al-mbid',
+ lidarr_track_mbid: null,
+ artist_name: 'Boards of Canada',
+ album_title: 'Music Has The Right To Children',
+ track_title: null,
+ quality_profile_id: null,
+ root_folder_path: null,
+ decided_at: null,
+ decided_by: null,
+ notes: null,
+ completed_at: null,
+ matched_track_id: null,
+ matched_album_id: null,
+ matched_artist_id: null,
+ requested_at: '2026-04-28T12:00:00Z',
+ updated_at: '2026-04-28T12:00:00Z',
+ ...over
+ };
+}
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('Requests page', () => {
+ test('renders one row per request from the API', () => {
+ const rows: LidarrRequest[] = [
+ req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }),
+ req({ id: 'r2', kind: 'album', album_title: 'Geogaddi', status: 'completed' }),
+ req({ id: 'r3', kind: 'track', track_title: 'Roygbiv', status: 'rejected', notes: 'Already in library' })
+ ];
+ mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows }));
+ render(RequestsPage);
+ expect(screen.getAllByTestId('request-row')).toHaveLength(3);
+ expect(screen.getByText('Boards of Canada')).toBeInTheDocument();
+ expect(screen.getByText('Geogaddi')).toBeInTheDocument();
+ expect(screen.getByText('Roygbiv')).toBeInTheDocument();
+ });
+
+ test('pending row exposes Cancel; clicking calls cancelRequest and invalidates myRequests', async () => {
+ const rows: LidarrRequest[] = [
+ req({ id: 'r-pending', status: 'pending', kind: 'album', album_title: 'Geogaddi' })
+ ];
+ mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows }));
+ render(RequestsPage);
+ const cancelBtn = screen.getByRole('button', { name: /cancel request for geogaddi/i });
+ await fireEvent.click(cancelBtn);
+ await waitFor(() => {
+ expect(mockedCancelRequest).toHaveBeenCalledWith('r-pending');
+ expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: qk.myRequests() });
+ });
+ });
+
+ test('completed row with matched_track_id renders Listen link to /tracks/', () => {
+ const rows: LidarrRequest[] = [
+ req({
+ id: 'r-track',
+ status: 'completed',
+ kind: 'track',
+ track_title: 'Roygbiv',
+ matched_track_id: 'tr-9',
+ matched_album_id: 'al-9',
+ matched_artist_id: 'art-9'
+ })
+ ];
+ mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows }));
+ render(RequestsPage);
+ const link = screen.getByRole('link', { name: /listen to roygbiv/i });
+ expect(link).toHaveAttribute('href', '/tracks/tr-9');
+ });
+
+ test('completed row with only matched_album_id renders Listen link to /albums/', () => {
+ const rows: LidarrRequest[] = [
+ req({
+ id: 'r-album',
+ status: 'completed',
+ kind: 'album',
+ album_title: 'Geogaddi',
+ matched_track_id: null,
+ matched_album_id: 'al-7',
+ matched_artist_id: 'art-7'
+ })
+ ];
+ mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows }));
+ render(RequestsPage);
+ const link = screen.getByRole('link', { name: /listen to geogaddi/i });
+ expect(link).toHaveAttribute('href', '/albums/al-7');
+ });
+
+ test('rejected row renders notes and hides Cancel and Listen', () => {
+ const rows: LidarrRequest[] = [
+ req({
+ id: 'r-rej',
+ status: 'rejected',
+ kind: 'album',
+ album_title: 'Geogaddi',
+ notes: 'Already kept in the library.'
+ })
+ ];
+ mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: rows }));
+ render(RequestsPage);
+ expect(screen.getByTestId('rejection-notes')).toHaveTextContent('Already kept in the library.');
+ expect(screen.queryByRole('button', { name: /cancel request/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: /listen to/i })).not.toBeInTheDocument();
+ });
+
+ test('empty list shows the voice-rule "Nothing requested yet." copy', () => {
+ mockedCreateMyRequestsQuery.mockReturnValue(mockQuery({ data: [] }));
+ render(RequestsPage);
+ expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument();
+ expect(screen.queryAllByTestId('request-row')).toHaveLength(0);
+ });
+});