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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user