feat(metadata): add resilient match queue diagnostics (#463)

* feat(metadata): add resilient match queue diagnostics

* fix(metadata): harden match queue lifecycle

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
Suspense
2026-07-24 12:18:49 -04:00
committed by GitHub
co-authored by Quick104
parent 383973ec22
commit 22fec4ed2d
24 changed files with 3101 additions and 397 deletions
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function CollapsibleDiagnosticsSection({
title,
description,
count,
icon,
iconClassName,
open,
onOpenChange,
children,
}: {
title: string;
description: string;
count: number;
icon: ReactNode;
iconClassName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) {
return (
<section className="surface-panel-subtle overflow-hidden rounded-2xl">
<button
type="button"
className="hover:bg-surface-hover/40 flex w-full items-center gap-3 px-5 py-4 text-left transition-colors"
aria-expanded={open}
onClick={() => onOpenChange(!open)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
iconClassName ?? "bg-amber-500/10",
)}
>
{icon}
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<h2 className="text-sm font-semibold tracking-wide">{title}</h2>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
</div>
{open ? (
<Badge variant="secondary" className="text-[11px] tabular-nums">
{count}
</Badge>
) : (
<div className="text-2xl leading-none font-bold tabular-nums">{count}</div>
)}
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
!open && "-rotate-90",
)}
/>
</button>
{open ? <div className="px-3 pb-3">{children}</div> : null}
</section>
);
}
@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Library } from "@/api/types";
import { MetadataMatcherQueuesSection } from "./MetadataMatcherQueuesSection";
const mocks = vi.hoisted(() => ({
useQueues: vi.fn(),
useDetail: vi.fn(),
useRetry: vi.fn(),
}));
vi.mock("@/hooks/queries/admin/libraries", () => ({
useLibraryMetadataMatchQueues: (...args: unknown[]) => mocks.useQueues(...args),
useLibraryMetadataMatchQueueDetail: (...args: unknown[]) => mocks.useDetail(...args),
useRetryLibraryMetadataMatchQueue: (...args: unknown[]) => mocks.useRetry(...args),
}));
describe("MetadataMatcherQueuesSection", () => {
it("renders the structured failure kind with the parked item detail", async () => {
mocks.useQueues.mockReturnValue({
data: [
{
library_id: 1,
movie_count: 1,
series_count: 0,
raw_file_count: 0,
total_count: 1,
pending_count: 0,
parked_count: 1,
},
],
});
mocks.useDetail.mockReturnValue({
data: {
movies: [
{
media_file_id: 42,
file_path: "/media/movies/Unknown.mkv",
state: "parked",
failure_kind: "candidate_rejected",
failure_detail: { message: "Score below threshold" },
},
],
series: [],
raw_files: [],
},
});
mocks.useRetry.mockReturnValue({ mutate: vi.fn(), isPending: false, variables: undefined });
const libraries = [{ id: 1, name: "Movies" }] as Library[];
render(<MetadataMatcherQueuesSection libraries={libraries} />);
await userEvent.click(screen.getByRole("button", { name: /metadata matcher/i }));
await userEvent.click(screen.getByText("Movies"));
expect(screen.getByText("candidate rejected")).toBeInTheDocument();
expect(screen.getByText("Score below threshold")).toBeInTheDocument();
expect(screen.getByText("parked")).toBeInTheDocument();
});
it("pages through every queue entry instead of stopping at the first ten", async () => {
mocks.useQueues.mockReturnValue({
data: [
{
library_id: 1,
movie_count: 15,
series_count: 0,
raw_file_count: 0,
total_count: 15,
pending_count: 15,
parked_count: 0,
},
],
});
mocks.useDetail.mockImplementation((_libraryID: number | null, offset: number) => ({
data: {
limit: 10,
offset,
movie_count: 15,
series_count: 0,
raw_file_count: 0,
movies: Array.from({ length: offset === 0 ? 10 : 5 }, (_, index) => ({
media_file_id: offset + index + 1,
file_path: `/media/movies/Movie ${offset + index + 1}.mkv`,
state: "pending",
})),
series: [],
raw_files: [],
},
isFetching: false,
}));
mocks.useRetry.mockReturnValue({ mutate: vi.fn(), isPending: false, variables: undefined });
const libraries = [{ id: 1, name: "Movies" }] as Library[];
render(<MetadataMatcherQueuesSection libraries={libraries} />);
await userEvent.click(screen.getByRole("button", { name: /metadata matcher/i }));
await userEvent.click(screen.getByText("Movies"));
await userEvent.click(screen.getByRole("button", { name: "Next" }));
expect(mocks.useDetail).toHaveBeenLastCalledWith(1, 10);
expect(screen.getByText("Page 2")).toBeInTheDocument();
expect(screen.getByText("/media/movies/Movie 15.mkv")).toBeInTheDocument();
});
});
@@ -0,0 +1,260 @@
import { Fragment, useState } from "react";
import { Wrench } from "lucide-react";
import type { Library } from "@/api/types";
import { CollapsibleDiagnosticsSection } from "@/components/admin/CollapsibleDiagnosticsSection";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
useLibraryMetadataMatchQueueDetail,
useLibraryMetadataMatchQueues,
useRetryLibraryMetadataMatchQueue,
} from "@/hooks/queries/admin/libraries";
function formatFailureKind(kind: string): string {
return kind.replace(/_/g, " ");
}
export function MetadataMatcherQueuesSection({ libraries }: { libraries: Library[] }) {
const [open, setOpen] = useState(false);
const [selectedLibraryID, setSelectedLibraryID] = useState<number | null>(null);
const [detailOffset, setDetailOffset] = useState(0);
const { data: queues = [] } = useLibraryMetadataMatchQueues();
// Passing null while collapsed disables the detail query entirely so a
// hidden expansion does not keep polling the per-library endpoint.
const { data: detail, isFetching: detailFetching } = useLibraryMetadataMatchQueueDetail(
open ? selectedLibraryID : null,
detailOffset,
);
const retry = useRetryLibraryMetadataMatchQueue();
const total = queues.reduce((sum, queue) => sum + queue.total_count, 0);
const detailLimit = detail?.limit ?? 10;
const hasPreviousDetailPage = detailOffset > 0;
const hasNextDetailPage = detail
? detailOffset + detail.movies.length < detail.movie_count ||
detailOffset + detail.series.length < detail.series_count ||
detailOffset + detail.raw_files.length < detail.raw_file_count
: false;
const detailEntries = detail
? [
...detail.movies.map((entry) => ({
key: `movie-${entry.media_file_id}`,
path: entry.file_path,
state: entry.state,
failureKind: entry.failure_kind,
message: entry.failure_detail?.message ?? entry.last_error,
decision: entry.failure_detail?.decision,
})),
...detail.series.map((entry) => ({
key: `series-${entry.media_folder_id}-${entry.observed_root_path}`,
path: entry.observed_root_path,
state: entry.state,
failureKind: entry.failure_kind,
message: entry.failure_detail?.message ?? entry.last_error,
decision: entry.failure_detail?.decision,
})),
...detail.raw_files.map((entry) => {
const identity = [
entry.base_title,
entry.base_year ? `(${entry.base_year})` : "",
entry.base_type ? `[${entry.base_type}]` : "",
]
.filter(Boolean)
.join(" ");
return {
key: `raw-${entry.media_file_id}`,
path: entry.file_path,
state: "pending" as const,
failureKind: null,
message: identity ? `Awaiting initial match: ${identity}` : "Awaiting initial match",
decision: undefined,
};
}),
]
: [];
if (total === 0) return null;
return (
<CollapsibleDiagnosticsSection
title="Metadata Matcher"
description="Pending and parked items that still need a provider match."
count={total}
icon={<Wrench className="h-4 w-4 text-amber-500" />}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setSelectedLibraryID(null);
setDetailOffset(0);
}
}}
>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Library</TableHead>
<TableHead className="text-right">Pending</TableHead>
<TableHead className="text-right">Parked</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{queues.map((queue) => {
const selected = selectedLibraryID === queue.library_id;
const library = libraries.find((entry) => entry.id === queue.library_id);
return (
<Fragment key={queue.library_id}>
<TableRow
className="cursor-pointer"
onClick={() => {
setSelectedLibraryID(selected ? null : queue.library_id);
setDetailOffset(0);
}}
>
<TableCell className="font-medium">
{library?.name ?? `Library ${queue.library_id}`}
</TableCell>
<TableCell className="text-right tabular-nums">{queue.pending_count}</TableCell>
<TableCell className="text-right tabular-nums">
{queue.parked_count > 0 ? (
<Badge variant="outline" className="border-amber-500/30 text-amber-600">
{queue.parked_count}
</Badge>
) : (
0
)}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
disabled={retry.isPending && retry.variables === queue.library_id}
onClick={(event) => {
event.stopPropagation();
retry.mutate(queue.library_id);
}}
>
Retry now
</Button>
</TableCell>
</TableRow>
{selected ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="bg-muted/30 p-3">
<div className="space-y-2">
{detailEntries.map((entry) => {
const { path, failureKind, message, decision } = entry;
return (
<div
key={entry.key}
className="bg-background/70 rounded-lg border p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<code className="min-w-0 truncate font-mono">{path}</code>
<div className="flex shrink-0 items-center gap-1.5">
{failureKind ? (
<Badge variant="outline">
{formatFailureKind(failureKind)}
</Badge>
) : null}
<Badge
variant={entry.state === "parked" ? "outline" : "secondary"}
>
{entry.state}
</Badge>
</div>
</div>
{message ? (
<p className="text-muted-foreground mt-1">{message}</p>
) : null}
{decision?.top_candidates?.length ? (
<div className="mt-2 space-y-1 border-t pt-2">
{decision.top_candidates.map((candidate, candidateIndex) => (
<div
key={`${candidate.title}-${candidate.year ?? 0}-${candidate.score}-${candidateIndex}`}
className="text-muted-foreground flex flex-wrap items-baseline gap-x-2"
>
<span className="text-foreground font-medium">
{candidate.title}
{candidate.year ? ` (${candidate.year})` : ""}
</span>
<span className="tabular-nums">
score {candidate.score.toFixed(1)} / {decision.threshold}
</span>
{candidate.matched_title &&
candidate.matched_title !== candidate.title ? (
<span>matched “{candidate.matched_title}”</span>
) : null}
{candidate.reasons?.length ? (
<span>{candidate.reasons.join(", ")}</span>
) : null}
{candidate.sources?.length ? (
<span>via {candidate.sources.join(", ")}</span>
) : null}
</div>
))}
</div>
) : null}
</div>
);
})}
{detail && detailEntries.length === 0 ? (
<p className="text-muted-foreground text-center">
No queued item details.
</p>
) : null}
{hasPreviousDetailPage || hasNextDetailPage ? (
<div className="flex items-center justify-between pt-1">
<span className="text-muted-foreground text-xs">
Page {Math.floor(detailOffset / detailLimit) + 1}
</span>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={!hasPreviousDetailPage || detailFetching}
onClick={() =>
setDetailOffset((current) => Math.max(0, current - detailLimit))
}
>
Previous
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={!hasNextDetailPage || detailFetching}
onClick={() =>
setDetailOffset((current) => current + detailLimit)
}
>
Next
</Button>
</div>
</div>
) : null}
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
</CollapsibleDiagnosticsSection>
);
}