Files
minstrel/web/src/routes/admin/quarantine/+page.svelte
T

461 lines
16 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 { 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 { copyForCode } from '$lib/api/error-copy';
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, 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'
};
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);
}
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(copyForCode(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(copyForCode(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 = copyForCode(code);
// Also surface as toast so it's visible after dismissing the modal.
showToast(copyForCode(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-action-fg"
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-action-fg 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-action-fg"
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-action-fg"
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-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>
</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>