feat(web): /admin/quarantine aggregated queue with resolution actions

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 20:40:34 -04:00
parent 0d7a65cffb
commit 3bfec944c7
4 changed files with 677 additions and 5 deletions
+1 -1
View File
@@ -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 }
];
+19 -4
View File
@@ -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();
});
});
@@ -0,0 +1,476 @@
<script lang="ts">
import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminQuarantineQuery,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { playRadio } from '$lib/player/store.svelte';
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
// Aggregated triage queue. One row per track, with per-row resolution
// actions: Resolve (clears reports), Delete file (Bronze; modal-confirm),
// Delete via Lidarr (Oxblood; typed-confirm "DELETE"). Mirrors
// /admin/requests for shape — toast helper, errorCopy(), invalidateQueries.
const client = useQueryClient();
const queryStore = createAdminQuarantineQuery();
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as AdminQuarantineRow[]);
// Sum of reports across all rows; rendered as the accent-tint pill in the
// header. Count of *reports*, not unique tracks — that's the spec.
const totalReports = $derived(
rows.reduce((sum, r) => sum + r.report_count, 0)
);
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
bad_rip: 'Bad rip',
wrong_file: 'Wrong file',
wrong_tags: 'Wrong tags',
duplicate: 'Duplicate',
other: 'Other'
};
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const days = Math.floor(ms / (24 * 3_600_000));
if (days >= 1) return `${days}d ago`;
const hours = Math.floor(ms / 3_600_000);
if (hours >= 1) return `${hours}h ago`;
const minutes = Math.floor(ms / 60_000);
if (minutes >= 1) return `${minutes}m ago`;
return 'just now';
}
// Row-level expand state: tracks which rows have their per-user report
// details revealed. Keyed by track_id.
let expanded = $state<Record<string, boolean>>({});
function toggleExpanded(trackID: string) {
expanded[trackID] = !expanded[trackID];
}
// Modal state. The modal-confirm (delete file) and the typed-confirm (delete
// via Lidarr) both store the active row's track_id; null when closed.
let deleteFileOpen = $state<string | null>(null);
let deleteLidarrOpen = $state<string | null>(null);
let deleteLidarrInput = $state<string>('');
// Inline error inside the typed-confirm modal — the modal stays open with
// feedback if Lidarr can't be reached or the album lookup fails.
let deleteLidarrError = $state<string | null>(null);
// Toast surface — same pattern as /admin/requests for the lidarr-unreachable
// and other error codes from /resolve and /delete-file.
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function showToast(msg: string) {
if (toastTimer) clearTimeout(toastTimer);
toast = msg;
toastTimer = setTimeout(() => {
toast = null;
}, 5000);
}
function errorCopy(code: string): string {
switch (code) {
case 'lidarr_unreachable':
return "Lidarr is unreachable right now. Try again, or check Settings → Integrations.";
case 'lidarr_disabled':
return 'Lidarr integration is not enabled.';
case 'lidarr_auth_failed':
return 'Lidarr authentication failed.';
case 'lidarr_album_lookup_failed':
return "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.";
case 'album_mbid_missing':
return 'This track has no Lidarr album to remove.';
default:
return "Couldn't reach Lidarr.";
}
}
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
}
async function onResolve(r: AdminQuarantineRow) {
try {
await resolveQuarantine(r.track_id);
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
}
}
function openDeleteFile(r: AdminQuarantineRow) {
deleteFileOpen = r.track_id;
}
function cancelDeleteFile() {
deleteFileOpen = null;
}
async function confirmDeleteFile(r: AdminQuarantineRow) {
deleteFileOpen = null;
try {
await deleteQuarantineFile(r.track_id);
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
}
}
function openDeleteLidarr(r: AdminQuarantineRow) {
deleteLidarrOpen = r.track_id;
deleteLidarrInput = '';
deleteLidarrError = null;
}
function cancelDeleteLidarr() {
deleteLidarrOpen = null;
deleteLidarrInput = '';
deleteLidarrError = null;
}
async function confirmDeleteLidarr(r: AdminQuarantineRow) {
// Trimmed equality matches the M5a Disconnect typed-confirm pattern.
if (deleteLidarrInput.trim() !== 'DELETE') return;
try {
await deleteQuarantineViaLidarr(r.track_id);
deleteLidarrOpen = null;
deleteLidarrInput = '';
deleteLidarrError = null;
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
// Inline error keeps the modal open so the operator sees the failure
// alongside the album they were about to remove.
deleteLidarrError = errorCopy(code);
// Also surface as toast so it's visible after dismissing the modal.
showToast(errorCopy(code));
}
}
async function onPlay(r: AdminQuarantineRow) {
try {
await playRadio(r.track_id);
} catch {
// Silent on failure — the play button is a convenience, not a primary
// action. Toast surface is reserved for the destructive action errors.
}
}
// Modal lookups guard against the row disappearing if the query refetches
// while the modal is open.
const deleteFileRow = $derived(
deleteFileOpen ? rows.find((r) => r.track_id === deleteFileOpen) ?? null : null
);
const deleteLidarrRow = $derived(
deleteLidarrOpen ? rows.find((r) => r.track_id === deleteLidarrOpen) ?? null : null
);
</script>
<div class="space-y-6">
<header class="space-y-1">
<div class="flex items-center gap-2">
<h2 class="font-display text-2xl font-medium text-text-primary">Quarantine</h2>
{#if totalReports > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
data-testid="report-count-pill"
>
{totalReports}
</span>
{/if}
</div>
<p class="text-text-secondary">User-reported broken tracks waiting for triage.</p>
</header>
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError}
<p class="text-error">Couldn't load the quarantine queue.</p>
{:else if rows.length === 0}
<p class="text-text-secondary">Nothing to triage right now.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each rows as r (r.track_id)}
{@const isExpanded = !!expanded[r.track_id]}
{@const lidarrDisabled = !r.lidarr_album_mbid}
<li
class="flex items-start gap-4 p-3"
data-testid="admin-quarantine-row"
data-track-id={r.track_id}
>
<!-- 56px album art with Slate fallback. -->
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
aria-hidden="true"
>
{#if r.album_id}
<img
src={`/api/albums/${r.album_id}/cover`}
alt=""
class="h-full w-full rounded-md object-cover"
loading="lazy"
/>
{:else}
<Music2 size={24} strokeWidth={1} class="text-text-muted" />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-1">
<div class="truncate text-base font-medium text-text-primary">
{r.track_title}
</div>
<div class="truncate text-sm text-text-secondary">
{r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)}
</div>
<!-- Reason distribution pills -->
<div class="flex flex-wrap gap-1.5 pt-1">
{#each Object.entries(r.reason_counts) as [reason, count] (reason)}
<span
class="reason-pill"
data-testid="reason-pill"
>
{count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}
</span>
{/each}
</div>
<!-- Reports details disclosure -->
<button
type="button"
class="mt-1 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Hide' : 'Show'} reports for ${r.track_title}`}
onclick={() => toggleExpanded(r.track_id)}
>
<ChevronRight
size={12}
strokeWidth={1.5}
class="transition-transform {isExpanded ? 'rotate-90' : ''}"
/>
{isExpanded ? 'Hide reports' : `Show ${r.reports.length} reports`}
</button>
{#if isExpanded}
<ul
class="mt-2 space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary"
data-testid="reports-details"
>
{#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)}
<li>
<span class="text-text-primary">{rep.username}</span>
· {REASON_LABELS[rep.reason] ?? rep.reason}
{#if rep.notes}
· <span class="italic">{rep.notes}</span>
{/if}
· {relativeTime(rep.created_at)}
</li>
{/each}
</ul>
{/if}
</div>
<!-- Action cluster -->
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`Play ${r.track_title}`}
class="inline-flex items-center justify-center rounded-md p-2 text-accent hover:bg-accent-tint"
onclick={() => onPlay(r)}
>
<Play size={16} strokeWidth={2} />
</button>
<button
type="button"
aria-label={`Resolve ${r.track_title}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
onclick={() => onResolve(r)}
>
<RotateCcw size={14} strokeWidth={1.5} />
Resolve
</button>
<button
type="button"
aria-label={`Delete file for ${r.track_title}`}
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={() => openDeleteFile(r)}
>
<Trash2 size={14} strokeWidth={2} />
Delete file
</button>
{#if lidarrDisabled}
<span
aria-label={`Delete via Lidarr disabled for ${r.track_title}`}
title="Local-only track no Lidarr album to remove."
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary opacity-50 cursor-not-allowed"
data-testid="delete-lidarr-disabled"
>
<Trash2 size={14} strokeWidth={2} />
<Cloud size={14} strokeWidth={2} />
Delete via Lidarr
</span>
{:else}
<button
type="button"
aria-label={`Delete via Lidarr for ${r.track_title}`}
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary"
onclick={() => openDeleteLidarr(r)}
>
<Trash2 size={14} strokeWidth={2} />
<Cloud size={14} strokeWidth={2} />
Delete via Lidarr
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
{#if deleteFileRow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelDeleteFile}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-file-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="delete-file-title" class="font-display text-lg font-medium text-text-primary">
Delete file?
</h3>
<p class="mt-2 text-sm text-text-secondary">
Remove <em class="font-medium text-text-primary">{deleteFileRow.track_title}</em>
from disk and clear {deleteFileRow.report_count} reports? Lidarr may auto-redownload depending on its monitor settings.
</p>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={cancelDeleteFile}
>
Cancel
</button>
<button
type="button"
aria-label="Confirm delete file"
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={() => confirmDeleteFile(deleteFileRow!)}
>
<Trash2 size={14} strokeWidth={2} />
Delete file
</button>
</div>
</div>
</div>
{/if}
{#if deleteLidarrRow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelDeleteLidarr}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-lidarr-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="delete-lidarr-title" class="font-display text-lg font-medium text-text-primary">
Delete via Lidarr?
</h3>
<p class="mt-2 text-sm text-text-secondary">
This will tell Lidarr to remove <em class="font-medium text-text-primary">{deleteLidarrRow.album_title}</em>
(artist <em class="font-medium text-text-primary">{deleteLidarrRow.artist_name}</em>)
and add it to the import-list exclusion. Affects all tracks on the album. Type <strong>DELETE</strong> to confirm.
</p>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Type DELETE to confirm</span>
<input
type="text"
bind:value={deleteLidarrInput}
placeholder="DELETE"
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
aria-label="Type DELETE to confirm"
/>
</label>
{#if deleteLidarrError}
<p class="mt-3 text-sm text-error" data-testid="delete-lidarr-error">
{deleteLidarrError}
</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={cancelDeleteLidarr}
>
Cancel
</button>
<button
type="button"
aria-label="Confirm delete via Lidarr"
disabled={deleteLidarrInput.trim() !== 'DELETE'}
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => confirmDeleteLidarr(deleteLidarrRow!)}
>
<Trash2 size={14} strokeWidth={2} />
<Cloud size={14} strokeWidth={2} />
Delete via Lidarr
</button>
</div>
</div>
</div>
{/if}
{#if toast}
<div
role="status"
aria-live="polite"
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
data-testid="toast"
>
{toast}
</div>
{/if}
<style>
.reason-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
</style>
@@ -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<string, unknown>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});