diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte
new file mode 100644
index 00000000..f0eb75e3
--- /dev/null
+++ b/web/src/routes/admin/requests/+page.svelte
@@ -0,0 +1,444 @@
+
+
+
+
+
+
+
+ {#if query.isPending}
+
Reading the queue…
+ {:else if query.isError}
+
Couldn't load requests.
+ {:else if rows.length === 0}
+
Nothing here.
+ {:else}
+
+ {/if}
+
+
+{#if overrideRow}
+
+
+
+
e.stopPropagation()}
+ tabindex="-1"
+ >
+
+ Approve with override
+
+
+ Leave fields blank to use the saved defaults.
+
+
+
+
+
+
+
+
+
+
+
+
+{/if}
+
+{#if rejectRow}
+
+
+
+
e.stopPropagation()}
+ tabindex="-1"
+ >
+
+ Reject request?
+
+
+ Notes are shown to the requester.
+
+
+
+
+
+
+
+
+{/if}
+
+{#if toast}
+
+ {toast}
+
+{/if}
+
+
diff --git a/web/src/routes/admin/requests/requests.test.ts b/web/src/routes/admin/requests/requests.test.ts
new file mode 100644
index 00000000..799d1b0c
--- /dev/null
+++ b/web/src/routes/admin/requests/requests.test.ts
@@ -0,0 +1,130 @@
+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';
+
+// Wrap useQueryClient so the page can call invalidateQueries() without a
+// real QueryClient context. Everything else from svelte-query passes through.
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
+});
+
+vi.mock('$lib/api/admin', () => ({
+ createAdminRequestsQuery: vi.fn(),
+ createQualityProfilesQuery: vi.fn(),
+ createRootFoldersQuery: vi.fn(),
+ approveRequest: vi.fn(),
+ rejectRequest: vi.fn()
+}));
+
+import AdminRequestsPage from './+page.svelte';
+import {
+ createAdminRequestsQuery,
+ createQualityProfilesQuery,
+ createRootFoldersQuery,
+ approveRequest,
+ rejectRequest
+} from '$lib/api/admin';
+
+const baseRow: LidarrRequest = {
+ id: 'r1',
+ user_id: 'u1234567-aaaa-bbbb-cccc-deadbeef0001',
+ status: 'pending',
+ kind: 'album',
+ lidarr_artist_mbid: 'a-mbid',
+ lidarr_album_mbid: 'al-mbid',
+ artist_name: 'Boards of Canada',
+ album_title: 'Geogaddi',
+ requested_at: '2026-04-29T10:00:00Z',
+ updated_at: '2026-04-29T10:00:00Z'
+};
+
+afterEach(() => vi.clearAllMocks());
+
+function setup(rows: LidarrRequest[] = [baseRow]) {
+ (createAdminRequestsQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: rows })
+ );
+ (createQualityProfilesQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: [{ id: 1, name: 'Standard' }] })
+ );
+ (createRootFoldersQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
+ );
+ return render(AdminRequestsPage);
+}
+
+describe('/admin/requests', () => {
+ test('Tab switch refetches with new status filter', async () => {
+ setup();
+ await fireEvent.click(screen.getByRole('tab', { name: /approved/i }));
+ expect(createAdminRequestsQuery).toHaveBeenCalledWith('approved');
+ });
+
+ test('Approve without override fires POST with no override body', async () => {
+ setup();
+ (approveRequest as ReturnType).mockResolvedValueOnce(baseRow);
+ await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
+ expect(approveRequest).toHaveBeenCalledWith('r1');
+ });
+
+ test('Override modal Confirm sends chosen values', async () => {
+ setup();
+ await fireEvent.click(screen.getByRole('button', { name: /override geogaddi/i }));
+ await fireEvent.change(screen.getByLabelText(/quality profile/i), {
+ target: { value: '1' }
+ });
+ await fireEvent.change(screen.getByLabelText(/root folder/i), {
+ target: { value: '/music' }
+ });
+ (approveRequest as ReturnType).mockResolvedValueOnce(baseRow);
+ await fireEvent.click(
+ screen.getByRole('button', { name: /approve with override/i })
+ );
+ expect(approveRequest).toHaveBeenCalledWith('r1', {
+ quality_profile_id: 1,
+ root_folder_path: '/music'
+ });
+ });
+
+ test('Reject opens notes textarea; confirm sends notes', async () => {
+ setup();
+ await fireEvent.click(screen.getByRole('button', { name: /reject geogaddi/i }));
+ const textarea = screen.getByLabelText(/notes/i) as HTMLTextAreaElement;
+ await fireEvent.input(textarea, { target: { value: 'duplicate' } });
+ (rejectRequest as ReturnType).mockResolvedValueOnce({
+ ...baseRow,
+ status: 'rejected'
+ });
+ await fireEvent.click(
+ screen.getByRole('button', { name: /confirm reject/i })
+ );
+ expect(rejectRequest).toHaveBeenCalledWith('r1', 'duplicate');
+ });
+
+ test('Lidarr-unreachable error shows toast', async () => {
+ setup();
+ (approveRequest as ReturnType).mockRejectedValueOnce({
+ code: 'lidarr_unreachable',
+ message: 'unreachable',
+ status: 503
+ });
+ await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
+ await waitFor(() =>
+ expect(screen.getByText(/lidarr is unreachable/i)).toBeInTheDocument()
+ );
+ });
+
+ test('Track-kind row renders disclosure copy about adding the album', () => {
+ setup([
+ {
+ ...baseRow,
+ kind: 'track',
+ track_title: 'Roygbiv',
+ album_title: 'Geogaddi'
+ }
+ ]);
+ expect(screen.getByText(/will add the album/i)).toBeInTheDocument();
+ });
+});