diff --git a/web/src/lib/components/AdminSidebar.svelte b/web/src/lib/components/AdminSidebar.svelte
index 381395ab..99114878 100644
--- a/web/src/lib/components/AdminSidebar.svelte
+++ b/web/src/lib/components/AdminSidebar.svelte
@@ -13,7 +13,7 @@
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
{ href: '/admin/integrations', label: 'Integrations', icon: Plug },
{ href: '/admin/requests', label: 'Requests', icon: ListChecks },
- { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true },
+ { href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX },
{ href: '/admin/users', label: 'Users', icon: Users, placeholder: true },
{ href: '/admin/library', label: 'Library', icon: FolderTree, placeholder: true }
];
diff --git a/web/src/lib/components/AdminSidebar.test.ts b/web/src/lib/components/AdminSidebar.test.ts
index 792913e7..8e73d0ab 100644
--- a/web/src/lib/components/AdminSidebar.test.ts
+++ b/web/src/lib/components/AdminSidebar.test.ts
@@ -38,14 +38,29 @@ describe('AdminSidebar', () => {
);
});
+ test('Quarantine link is active when on /admin/quarantine', () => {
+ state.pageUrl = new URL('http://localhost/admin/quarantine');
+ render(AdminSidebar);
+ expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute(
+ 'aria-current',
+ 'page'
+ );
+ });
+
+ test('Quarantine renders as a real link', () => {
+ state.pageUrl = new URL('http://localhost/admin');
+ render(AdminSidebar);
+ const link = screen.getByRole('link', { name: /quarantine/i });
+ expect(link).toHaveAttribute('href', '/admin/quarantine');
+ });
+
test('placeholder items render as non-links with aria-disabled', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminSidebar);
- expect(screen.queryByRole('link', { name: /quarantine/i })).not.toBeInTheDocument();
- const quar = screen.getByText(/quarantine/i);
- expect(quar.closest('[aria-disabled="true"]')).toBeInTheDocument();
- // Users + Library are also placeholders today.
+ // Users + Library are still placeholders today.
expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument();
+ const users = screen.getByText(/^users$/i);
+ expect(users.closest('[aria-disabled="true"]')).toBeInTheDocument();
});
});
diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte
new file mode 100644
index 00000000..15cf81b1
--- /dev/null
+++ b/web/src/routes/admin/quarantine/+page.svelte
@@ -0,0 +1,476 @@
+
+
+
+
+
+
Quarantine
+ {#if totalReports > 0}
+
+ {totalReports}
+
+ {/if}
+
+ User-reported broken tracks waiting for triage.
+
+
+ {#if query.isPending}
+
Reading the queue…
+ {:else if query.isError}
+
Couldn't load the quarantine queue.
+ {:else if rows.length === 0}
+
Nothing to triage right now.
+ {:else}
+
+ {#each rows as r (r.track_id)}
+ {@const isExpanded = !!expanded[r.track_id]}
+ {@const lidarrDisabled = !r.lidarr_album_mbid}
+
+
+
+ {#if r.album_id}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {r.track_title}
+
+
+ {r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)}
+
+
+
+
+ {#each Object.entries(r.reason_counts) as [reason, count] (reason)}
+
+ {count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}
+
+ {/each}
+
+
+
+
toggleExpanded(r.track_id)}
+ >
+
+ {isExpanded ? 'Hide reports' : `Show ${r.reports.length} reports`}
+
+
+ {#if isExpanded}
+
+ {#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)}
+
+ {rep.username}
+ · {REASON_LABELS[rep.reason] ?? rep.reason}
+ {#if rep.notes}
+ · {rep.notes}
+ {/if}
+ · {relativeTime(rep.created_at)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
onPlay(r)}
+ >
+
+
+
+
onResolve(r)}
+ >
+
+ Resolve
+
+
+
openDeleteFile(r)}
+ >
+
+ Delete file
+
+
+ {#if lidarrDisabled}
+
+
+
+ Delete via Lidarr
+
+ {:else}
+
openDeleteLidarr(r)}
+ >
+
+
+ Delete via Lidarr
+
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+
+{#if deleteFileRow}
+
+
+
+
e.stopPropagation()}
+ tabindex="-1"
+ >
+
+ Delete file?
+
+
+ Remove {deleteFileRow.track_title}
+ from disk and clear {deleteFileRow.report_count} reports? Lidarr may auto-redownload depending on its monitor settings.
+
+
+
+ Cancel
+
+ confirmDeleteFile(deleteFileRow!)}
+ >
+
+ Delete file
+
+
+
+
+{/if}
+
+{#if deleteLidarrRow}
+
+
+
+
e.stopPropagation()}
+ tabindex="-1"
+ >
+
+ Delete via Lidarr?
+
+
+ This will tell Lidarr to remove {deleteLidarrRow.album_title}
+ (artist {deleteLidarrRow.artist_name} )
+ and add it to the import-list exclusion. Affects all tracks on the album. Type DELETE to confirm.
+
+
+ Type DELETE to confirm
+
+
+ {#if deleteLidarrError}
+
+ {deleteLidarrError}
+
+ {/if}
+
+
+ Cancel
+
+ confirmDeleteLidarr(deleteLidarrRow!)}
+ >
+
+
+ Delete via Lidarr
+
+
+
+
+{/if}
+
+{#if toast}
+
+ {toast}
+
+{/if}
+
+
diff --git a/web/src/routes/admin/quarantine/quarantine.test.ts b/web/src/routes/admin/quarantine/quarantine.test.ts
new file mode 100644
index 00000000..68d7eedb
--- /dev/null
+++ b/web/src/routes/admin/quarantine/quarantine.test.ts
@@ -0,0 +1,181 @@
+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 { AdminQuarantineRow } 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', () => ({
+ createAdminQuarantineQuery: vi.fn(),
+ resolveQuarantine: vi.fn(),
+ deleteQuarantineFile: vi.fn(),
+ deleteQuarantineViaLidarr: vi.fn()
+}));
+
+vi.mock('$lib/player/store.svelte', () => ({
+ playRadio: vi.fn()
+}));
+
+import AdminQuarantinePage from './+page.svelte';
+import {
+ createAdminQuarantineQuery,
+ resolveQuarantine,
+ deleteQuarantineFile,
+ deleteQuarantineViaLidarr
+} from '$lib/api/admin';
+import { playRadio } from '$lib/player/store.svelte';
+
+const baseRow: AdminQuarantineRow = {
+ track_id: 't-001',
+ track_title: 'Roygbiv',
+ artist_name: 'Boards of Canada',
+ album_title: 'Geogaddi',
+ album_id: 'al-001',
+ lidarr_album_mbid: 'al-mbid-1',
+ report_count: 3,
+ latest_at: new Date(Date.now() - 2 * 3_600_000).toISOString(),
+ reason_counts: { bad_rip: 2, wrong_tags: 1 },
+ reports: [
+ {
+ user_id: 'u1',
+ username: 'alice',
+ reason: 'bad_rip',
+ notes: 'crackles at 1:24',
+ created_at: new Date(Date.now() - 2 * 3_600_000).toISOString()
+ },
+ {
+ user_id: 'u2',
+ username: 'bob',
+ reason: 'bad_rip',
+ notes: null,
+ created_at: new Date(Date.now() - 5 * 3_600_000).toISOString()
+ },
+ {
+ user_id: 'u3',
+ username: 'carol',
+ reason: 'wrong_tags',
+ notes: 'wrong year',
+ created_at: new Date(Date.now() - 7 * 3_600_000).toISOString()
+ }
+ ]
+};
+
+afterEach(() => vi.clearAllMocks());
+
+function setup(rows: AdminQuarantineRow[] = [baseRow]) {
+ (createAdminQuarantineQuery as ReturnType).mockReturnValue(
+ mockQuery({ data: rows })
+ );
+ return render(AdminQuarantinePage);
+}
+
+describe('/admin/quarantine', () => {
+ test('Empty state shows the spec copy', () => {
+ setup([]);
+ expect(screen.getByText('Nothing to triage right now.')).toBeInTheDocument();
+ });
+
+ test('Aggregated row renders with reason distribution + report count', () => {
+ setup();
+ expect(screen.getByText('Roygbiv')).toBeInTheDocument();
+ expect(
+ screen.getByText(/Boards of Canada · Geogaddi · 3 reports — latest/i)
+ ).toBeInTheDocument();
+ const pills = screen.getAllByTestId('reason-pill').map((el) => el.textContent?.trim());
+ expect(pills).toContain('2× Bad rip');
+ expect(pills).toContain('1× Wrong tags');
+ expect(screen.getByTestId('report-count-pill')).toHaveTextContent('3');
+ });
+
+ test('Resolve fires resolveQuarantine and invalidates the query', async () => {
+ setup();
+ (resolveQuarantine as ReturnType).mockResolvedValueOnce({
+ action_id: 'a1',
+ affected_users: 3
+ });
+ await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
+ expect(resolveQuarantine).toHaveBeenCalledWith('t-001');
+ });
+
+ test('Delete file → modal-confirm → fires deleteQuarantineFile', async () => {
+ setup();
+ (deleteQuarantineFile as ReturnType).mockResolvedValueOnce({
+ action_id: 'a2',
+ affected_users: 3
+ });
+ await fireEvent.click(
+ screen.getByRole('button', { name: /delete file for roygbiv/i })
+ );
+ const dialog = await screen.findByRole('dialog');
+ expect(dialog).toHaveTextContent(/Remove/i);
+ expect(dialog).toHaveTextContent(/clear 3 reports/i);
+ await fireEvent.click(
+ screen.getByRole('button', { name: /confirm delete file/i })
+ );
+ expect(deleteQuarantineFile).toHaveBeenCalledWith('t-001');
+ });
+
+ test('Delete via Lidarr → typed-confirm "DELETE" → fires deleteQuarantineViaLidarr', async () => {
+ setup();
+ (deleteQuarantineViaLidarr as ReturnType).mockResolvedValueOnce({
+ action_id: 'a3',
+ affected_users: 3,
+ deleted_track_count: 12
+ });
+ await fireEvent.click(
+ screen.getByRole('button', { name: /delete via lidarr for roygbiv/i })
+ );
+ await screen.findByRole('dialog');
+ const confirmBtn = screen.getByRole('button', {
+ name: /confirm delete via lidarr/i
+ }) as HTMLButtonElement;
+ expect(confirmBtn).toBeDisabled();
+ const input = screen.getByLabelText(/type delete to confirm/i) as HTMLInputElement;
+ await fireEvent.input(input, { target: { value: 'DELETE' } });
+ expect(confirmBtn).not.toBeDisabled();
+ await fireEvent.click(confirmBtn);
+ expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t-001');
+ });
+
+ test('Lidarr-unreachable error shows toast with spec copy', async () => {
+ setup();
+ (resolveQuarantine as ReturnType).mockRejectedValueOnce({
+ code: 'lidarr_unreachable',
+ message: 'unreachable',
+ status: 503
+ });
+ await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
+ await waitFor(() =>
+ expect(
+ screen.getByText(
+ 'Lidarr is unreachable right now. Try again, or check Settings → Integrations.'
+ )
+ ).toBeInTheDocument()
+ );
+ });
+
+ test('Dimmed Delete-via-Lidarr when lidarr_album_mbid is null', () => {
+ setup([{ ...baseRow, lidarr_album_mbid: null }]);
+ const disabled = screen.getByTestId('delete-lidarr-disabled');
+ expect(disabled).toHaveClass('cursor-not-allowed');
+ expect(disabled).toHaveClass('opacity-50');
+ expect(disabled).toHaveAttribute(
+ 'title',
+ 'Local-only track — no Lidarr album to remove.'
+ );
+ expect(
+ screen.queryByRole('button', { name: /delete via lidarr for roygbiv/i })
+ ).not.toBeInTheDocument();
+ });
+
+ test('Inline play button calls playRadio with the track id', async () => {
+ setup();
+ await fireEvent.click(screen.getByRole('button', { name: /play roygbiv/i }));
+ expect(playRadio).toHaveBeenCalledWith('t-001');
+ });
+});