Files
minstrel/web/src/routes/admin/quarantine/+page.svelte
T
bvandeusenandClaude Opus 5.5 7c89e59536
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / android (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: admin bugs from the audit: a playback-error delete no longer reports failure, a failed Overview load no longer reads as all-clear, and email settings can't be saved over with defaults (M503 #5604)
- Playback errors: Delete file stops resolving the error row the track
  deletion already took with it (ON DELETE CASCADE), which 404'd into
  "Delete failed".
- Overview: a queue that fails to load says so with Try again, and its tile
  shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr
  holds while Lidarr is connected. An approve refreshes the Collection.
- Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it;
  "1 report", not "1 reports".
- Duplicates: the Keep badge follows the copy picked.
- Integrations: provider save failures and load errors are said, not
  swallowed or shown as "No providers registered"; the Lidarr test catches
  a failed call; SMTP fills once and Save stays off until it has loaded.
  Lidarr's blurb says what it is still used for.
- Requests: release profiles point at Collection › Following.
- Library: an empty library sends an admin to Admin to run a scan, and
  tells anyone else the admin scans it, instead of opening Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:40:11 -04:00

386 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import {
createAdminQuarantineQuery,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { playRadio } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js';
import Modal from '#lib/components/Modal.svelte';
import type { AdminQuarantineRow, LidarrQuarantineReason } from '#lib/api/types.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
// 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, copyForCode(), 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'
};
// 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);
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
}
async function onResolve(r: AdminQuarantineRow) {
try {
await resolveQuarantine(r.track_id);
await invalidate();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
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) {
pushToast(errMessage(e), 'error');
}
}
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 msg = errMessage(e);
// Inline error keeps the modal open so the operator sees the failure
// alongside the album they were about to remove.
deleteLidarrError = msg;
// Also surface as toast so it's visible after dismissing the modal.
pushToast(msg, 'error');
}
}
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>
<svelte:head><title>{pageTitle('Admin · Quarantine')}</title></svelte:head>
<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-fg"
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-fg">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 primary: RowAction = {
icon: RotateCcw,
label: 'Resolve',
ariaLabel: `Resolve ${r.track_title}`,
onclick: () => onResolve(r)
}}
{@const secondary: RowAction[] = [
{
icon: Play,
label: 'Play',
ariaLabel: `Play ${r.track_title}`,
onclick: () => onPlay(r)
},
{
icon: Trash2,
label: 'Delete file',
ariaLabel: `Delete file for ${r.track_title}`,
onclick: () => openDeleteFile(r),
danger: true
},
// In the row's actions, so a phone reaches it from the menu too
// (#5604). A local-only track has no Lidarr album to remove, so
// it isn't offered.
...(r.lidarr_album_mbid
? [{
icon: Cloud,
label: 'Delete via Lidarr',
ariaLabel: `Delete via Lidarr for ${r.track_title}`,
onclick: () => openDeleteLidarr(r),
danger: true
}]
: [])
]}
<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={coverUrl(r.album_id)}
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} {r.report_count === 1 ? 'report' : '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} ${r.reports.length === 1 ? 'report' : '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">
<RowActionsMenu {primary} {secondary} />
</div>
</li>
{/each}
</ul>
{/if}
</div>
<Modal
title="Delete file?"
open={deleteFileRow !== null}
onClose={cancelDeleteFile}
>
{#if deleteFileRow}
<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} {deleteFileRow.report_count === 1 ? 'report' : '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-action-fg"
onclick={() => confirmDeleteFile(deleteFileRow!)}
>
<Trash2 size={14} strokeWidth={2} />
Delete file
</button>
</div>
{/if}
</Modal>
<Modal
title="Delete via Lidarr?"
open={deleteLidarrRow !== null}
onClose={cancelDeleteLidarr}
>
{#if deleteLidarrRow}
<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-hidden focus:ring-2 focus:ring-accent"
aria-label="Type DELETE to confirm"
/>
</label>
{#if deleteLidarrError}
<p class="mt-3 text-sm text-error-fg" 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-action-fg 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>
{/if}
</Modal>
<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-fg);
}
</style>