diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go
index fa3a4784..8fe0df76 100644
--- a/internal/api/handlers/libraries.go
+++ b/internal/api/handlers/libraries.go
@@ -2058,16 +2058,45 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
}
}
+ // Optional case-insensitive search across title, library name, type, and
+ // status. Applied server-side so it spans the whole table, not just the
+ // current page. The displayed folder still comes from the lateral join below,
+ // but the search predicate checks every membership so multi-library items are
+ // found when any linked library name matches.
+ search := strings.TrimSpace(q.Get("q"))
+ filter := ""
+ filterArgs := []any{}
+ if search != "" {
+ filterArgs = append(filterArgs, "%"+search+"%")
+ filter = ` AND (
+ mi.title ILIKE $1
+ OR mi.type ILIKE $1
+ OR mi.status ILIKE $1
+ OR EXISTS (
+ SELECT 1
+ FROM media_item_libraries search_mil
+ JOIN media_folders search_f ON search_f.id = search_mil.media_folder_id
+ WHERE search_mil.content_id = mi.content_id
+ AND search_f.name ILIKE $1
+ )
+ )`
+ }
+
+ countSQL := `
+ SELECT COUNT(*)
+ FROM media_items mi
+ WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')`
+ countSQL += filter
+
var total int
- if err := h.pool.QueryRow(r.Context(),
- `SELECT COUNT(*) FROM media_items WHERE status IN ('unmatched', 'pending', 'ambiguous')`,
- ).Scan(&total); err != nil {
+ if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil {
slog.Error("counting unmatched items", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items")
return
}
- rows, err := h.pool.Query(r.Context(), `
+ listArgs := append(append([]any{}, filterArgs...), limit, offset)
+ listSQL := fmt.Sprintf(`
SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status,
COALESCE(lib.folder_id, 0),
COALESCE(lib.folder_name, '')
@@ -2079,10 +2108,12 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http
WHERE mil.content_id = mi.content_id
LIMIT 1
) lib ON true
- WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')
+ WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s
ORDER BY mi.title ASC, mi.content_id ASC
- LIMIT $1 OFFSET $2
- `, limit, offset)
+ LIMIT $%d OFFSET $%d
+ `, filter, len(filterArgs)+1, len(filterArgs)+2)
+
+ rows, err := h.pool.Query(r.Context(), listSQL, listArgs...)
if err != nil {
slog.Error("listing unmatched items", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items")
diff --git a/web/src/components/MatchItemDialog.tsx b/web/src/components/MatchItemDialog.tsx
index 4bc1d839..7abd52e0 100644
--- a/web/src/components/MatchItemDialog.tsx
+++ b/web/src/components/MatchItemDialog.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { FileVersion, ItemDetail, MatchCandidate } from "@/api/types";
import MediaLocations from "@/components/MediaLocations";
import { useSearchItemMatchCandidates, useApplyItemMatch } from "@/hooks/queries/items";
@@ -170,55 +171,71 @@ export default function MatchItemDialog({ item, open, onOpenChange }: MatchItemD
{candidates.length > 0 && (
Results
-
- {candidates.map((candidate, index) => {
- const candidateKey = Object.entries(candidate.provider_ids)
- .map(([k, v]) => `${k}-${v}`)
- .join("_");
- return (
-
setSelectedCandidate(candidate)}
- data-testid="match-candidate"
- >
- {candidate.image_url ? (
-
- ) : (
-
- )}
-
-
{candidate.title}
-
- {candidate.year ? candidate.year : ""}
+
+
+ {candidates.map((candidate, index) => {
+ const candidateKey = Object.entries(candidate.provider_ids)
+ .map(([k, v]) => `${k}-${v}`)
+ .join("_");
+ return (
+
setSelectedCandidate(candidate)}
+ data-testid="match-candidate"
+ >
+ {candidate.image_url ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
{candidate.title}
+
+ {candidate.year ? candidate.year : ""}
+
+
+ {candidate.sources.map((source) => (
+
+ {source}
+
+ ))}
+ {candidate.sources.length > 1 && (
+
+ {candidate.sources.length} sources agree
+
+ )}
+
-
- {candidate.sources.map((source) => (
-
- {source}
-
- ))}
- {candidate.sources.length > 1 && (
-
- {candidate.sources.length} sources agree
-
- )}
-
-
-
- );
- })}
-
+
+ );
+ })}
+
+
)}
diff --git a/web/src/hooks/queries/admin/libraries.ts b/web/src/hooks/queries/admin/libraries.ts
index 680b8cdd..f7b9c250 100644
--- a/web/src/hooks/queries/admin/libraries.ts
+++ b/web/src/hooks/queries/admin/libraries.ts
@@ -510,13 +510,16 @@ export function useRefreshLibraryMetadata() {
const UNMATCHED_PAGE_SIZE = 10;
-export function useUnmatchedLibraryItems(page = 0) {
+export function useUnmatchedLibraryItems(page = 0, search = "") {
const offset = page * UNMATCHED_PAGE_SIZE;
+ const trimmed = search.trim();
return useQuery({
- queryKey: adminKeys.unmatchedItems(page),
+ queryKey: adminKeys.unmatchedItems(page, trimmed),
queryFn: () =>
api
(
- `/libraries/unmatched-items?limit=${UNMATCHED_PAGE_SIZE}&offset=${offset}`,
+ `/libraries/unmatched-items?limit=${UNMATCHED_PAGE_SIZE}&offset=${offset}${
+ trimmed ? `&q=${encodeURIComponent(trimmed)}` : ""
+ }`,
).then((d) => d ?? { items: [], total: 0 }),
staleTime: ADMIN_STALE_TIME,
placeholderData: (prev) => prev,
diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts
index b0e8272c..2268b8de 100644
--- a/web/src/hooks/queries/keys.ts
+++ b/web/src/hooks/queries/keys.ts
@@ -386,9 +386,9 @@ export const adminKeys = {
pluginRepositories: () => ["admin", "plugins", "repositories"] as const,
pluginCatalog: () => ["admin", "plugins", "catalog"] as const,
pluginInstallations: () => ["admin", "plugins", "installations"] as const,
- unmatchedItems: (page?: number) =>
+ unmatchedItems: (page?: number, search?: string) =>
page != null
- ? (["admin", "libraries", "unmatchedItems", page] as const)
+ ? (["admin", "libraries", "unmatchedItems", page, search ?? ""] as const)
: (["admin", "libraries", "unmatchedItems"] as const),
itemImages: (id: string) => ["admin", "items", id, "images"] as const,
buildInfo: () => ["admin", "system", "buildInfo"] as const,
diff --git a/web/src/pages/AdminLibraries.test.tsx b/web/src/pages/AdminLibraries.test.tsx
index 1b818130..6250ee8a 100644
--- a/web/src/pages/AdminLibraries.test.tsx
+++ b/web/src/pages/AdminLibraries.test.tsx
@@ -1,5 +1,6 @@
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
@@ -23,6 +24,11 @@ const mocks = vi.hoisted(() => ({
useDeleteLibraryPoster: vi.fn(),
useUnmatchedLibraryItems: vi.fn(),
useAdminPlugins: vi.fn(),
+ useCancelLibraryScans: vi.fn(),
+ useLibraryRoots: vi.fn(),
+ useUpsertLibraryRootOverride: vi.fn(),
+ useDeleteLibraryRootOverride: vi.fn(),
+ useActiveScans: vi.fn(),
}));
vi.mock("@/hooks/queries/admin/libraries", () => ({
@@ -45,14 +51,38 @@ vi.mock("@/hooks/queries/admin/libraries", () => ({
useUploadLibraryPoster: (...args: unknown[]) => mocks.useUploadLibraryPoster(...args),
useDeleteLibraryPoster: (...args: unknown[]) => mocks.useDeleteLibraryPoster(...args),
useUnmatchedLibraryItems: (...args: unknown[]) => mocks.useUnmatchedLibraryItems(...args),
+ useCancelLibraryScans: (...args: unknown[]) => mocks.useCancelLibraryScans(...args),
+ useLibraryRoots: (...args: unknown[]) => mocks.useLibraryRoots(...args),
+ useUpsertLibraryRootOverride: (...args: unknown[]) => mocks.useUpsertLibraryRootOverride(...args),
+ useDeleteLibraryRootOverride: (...args: unknown[]) => mocks.useDeleteLibraryRootOverride(...args),
+ UNMATCHED_PAGE_SIZE: 10,
}));
vi.mock("@/hooks/queries/admin/plugins", () => ({
useAdminPlugins: (...args: unknown[]) => mocks.useAdminPlugins(...args),
}));
+vi.mock("@/hooks/queries/admin/scans", () => ({
+ useActiveScans: (...args: unknown[]) => mocks.useActiveScans(...args),
+}));
+
import AdminLibraries from "./AdminLibraries";
+// renderPage wraps the page in the providers it needs at runtime: a
+// QueryClientProvider for the (mocked) TanStack hooks, and a MemoryRouter for
+// the s inside AdminLibraries. Without QueryClientProvider, even fully
+// mocked useQuery hooks throw "No QueryClient set" during render.
+const renderPage = () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+};
+
describe("AdminLibraries", () => {
beforeEach(() => {
const mutate = vi.fn();
@@ -114,17 +144,18 @@ describe("AdminLibraries", () => {
isLoading: false,
});
mocks.useUnmatchedLibraryItems.mockReturnValue({
- data: [],
+ data: { items: [], total: 0 },
isLoading: false,
});
+ mocks.useCancelLibraryScans.mockReturnValue(queryState);
+ mocks.useLibraryRoots.mockReturnValue({ data: [], isLoading: false });
+ mocks.useUpsertLibraryRootOverride.mockReturnValue(queryState);
+ mocks.useDeleteLibraryRootOverride.mockReturnValue(queryState);
+ mocks.useActiveScans.mockReturnValue({ data: [], isLoading: false });
});
it("uses scan language instead of metadata refresh language on the admin libraries page", () => {
- const markup = renderToStaticMarkup(
-
-
- ,
- );
+ const markup = renderPage();
expect(markup).toContain(
"Manage library roots and scans. Catalog import/export now lives under Maintenance.",
@@ -137,16 +168,20 @@ describe("AdminLibraries", () => {
);
});
- it("renders a low-key troubleshooting section only when skipped roots exist", () => {
- mocks.useSkippedLibraryRoots.mockReturnValue({
+ it("renders the Ambiguous Roots section with a populated row", () => {
+ mocks.useLibraryRoots.mockReturnValue({
data: [
{
library_id: 1,
library_name: "Movies",
root_path: "/media/movies/Inception (2010)",
- reason: "missing_folder_ids",
+ state: "ambiguous",
+ inferred_type: "movie",
+ type_confidence: "low",
+ title: "Inception",
+ year: 2010,
+ observed_file_count: 1,
sample_file_path: "/media/movies/Inception (2010)/Inception (2010).mkv",
- file_count: 1,
first_seen_at: "2026-03-23T20:00:00Z",
last_seen_at: "2026-03-23T21:00:00Z",
},
@@ -154,29 +189,21 @@ describe("AdminLibraries", () => {
isLoading: false,
});
- const markup = renderToStaticMarkup(
-
-
- ,
- );
+ const markup = renderPage();
- expect(markup).toContain("Troubleshooting");
- expect(markup).toContain("Root path");
- expect(markup).toContain("Movies");
+ expect(markup).toContain("Ambiguous Roots");
+ expect(markup).toContain("Inception");
expect(markup).toContain("/media/movies/Inception (2010)");
- expect(markup).toContain("missing_folder_ids");
- expect(markup).toContain("First seen");
- expect(markup).toContain("Last seen");
});
- it("hides the troubleshooting section when no skipped roots exist", () => {
- const markup = renderToStaticMarkup(
-
-
- ,
- );
+ it("renders the empty-state inside Ambiguous Roots when no roots exist", () => {
+ // Default useLibraryRoots mock returns { data: [], isLoading: false }. The
+ // section itself still renders (it's gated on libraries.length, not on the
+ // root list), and the table body shows the empty-state copy.
+ const markup = renderPage();
- expect(markup).not.toContain("Root path");
+ expect(markup).toContain("Ambiguous Roots");
+ expect(markup).toContain("No ambiguous roots for this library");
});
it("renders Match instead of Re-match for stale IDs", () => {
@@ -198,11 +225,7 @@ describe("AdminLibraries", () => {
isLoading: false,
});
- const markup = renderToStaticMarkup(
-
-
- ,
- );
+ const markup = renderPage();
expect(markup).toContain("Match");
expect(markup).not.toContain("Re-match");
@@ -210,25 +233,24 @@ describe("AdminLibraries", () => {
it("renders an unmatched items section when unmatched items exist", () => {
mocks.useUnmatchedLibraryItems.mockReturnValue({
- data: [
- {
- content_id: "movie-99",
- title: "Unknown Film",
- year: 0,
- content_type: "movie",
- library_id: 1,
- library_name: "Movies",
- status: "unmatched",
- },
- ],
+ data: {
+ items: [
+ {
+ content_id: "movie-99",
+ title: "Unknown Film",
+ year: 0,
+ content_type: "movie",
+ library_id: 1,
+ library_name: "Movies",
+ status: "unmatched",
+ },
+ ],
+ total: 1,
+ },
isLoading: false,
});
- const markup = renderToStaticMarkup(
-
-
- ,
- );
+ const markup = renderPage();
expect(markup).toContain("Unmatched Items");
expect(markup).toContain("Unknown Film");
@@ -236,11 +258,7 @@ describe("AdminLibraries", () => {
});
it("hides unmatched items section when no unmatched items exist", () => {
- const markup = renderToStaticMarkup(
-
-
- ,
- );
+ const markup = renderPage();
expect(markup).not.toContain("Unmatched Items");
});
diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx
index 243ef9a3..5097a979 100644
--- a/web/src/pages/AdminLibraries.tsx
+++ b/web/src/pages/AdminLibraries.tsx
@@ -1,5 +1,6 @@
import { Fragment, useState, useEffect, useCallback, useMemo } from "react";
import type { FormEvent } from "react";
+import { useDebounce } from "@/hooks/useDebounce";
import { useEventChannel } from "@/components/realtimeEventsContext";
import type {
AdminJob,
@@ -1150,6 +1151,8 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
);
}, [roots, search]);
+ const pag = usePagination(filteredRoots);
+
if (libraries.length === 0) {
return null;
}
@@ -1177,7 +1180,10 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
value={
effectiveSelectedLibraryId != null ? String(effectiveSelectedLibraryId) : undefined
}
- onValueChange={(value) => setSelectedLibraryId(Number.parseInt(value, 10))}
+ onValueChange={(value) => {
+ setSelectedLibraryId(Number.parseInt(value, 10));
+ pag.setPage(0);
+ }}
>
@@ -1195,7 +1201,10 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
setSearch(e.target.value)}
+ onChange={(e) => {
+ setSearch(e.target.value);
+ pag.setPage(0);
+ }}
className="h-8 pl-8 text-xs"
/>
@@ -1220,7 +1229,7 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
) : (
- filteredRoots.map((root) => (
+ pag.rows.map((root) => (
@@ -1262,6 +1271,7 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
+
{editingRoot ? (
@@ -1646,27 +1656,20 @@ function SkippedRootsSection({ skippedRoots }: { skippedRoots: LibrarySkippedRoo
function UnmatchedItemsSection() {
const [page, setPage] = useState(0);
const [search, setSearch] = useState("");
+ const debouncedSearch = useDebounce(search, 250);
const [matchItem, setMatchItem] = useState(null);
- const { data } = useUnmatchedLibraryItems(page);
+ const { data } = useUnmatchedLibraryItems(page, debouncedSearch);
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / UNMATCHED_PAGE_SIZE));
const clamped = Math.min(page, totalPages - 1);
const rangeStart = total === 0 ? 0 : clamped * UNMATCHED_PAGE_SIZE + 1;
const rangeEnd = Math.min((clamped + 1) * UNMATCHED_PAGE_SIZE, total);
- const filteredItems = useMemo(() => {
- const items = data?.items ?? [];
- if (!search) return items;
- const q = search.toLowerCase();
- return items.filter(
- (item) =>
- item.title.toLowerCase().includes(q) ||
- item.library_name.toLowerCase().includes(q) ||
- item.content_type.toLowerCase().includes(q) ||
- item.status.toLowerCase().includes(q),
- );
- }, [data?.items, search]);
+ const items = data?.items ?? [];
- if (total === 0 && page === 0) return null;
+ // Hide the section only when there are genuinely no unmatched items and no
+ // active search — keep it mounted while searching so the box and the
+ // "no matches" state stay visible even when a query returns nothing.
+ if (total === 0 && page === 0 && search.trim() === "") return null;
return (
- {filteredItems.length === 0 ? (
+ {items.length === 0 ? (
- No unmatched items on this page match your filter.
+ No unmatched items match your search.
) : (
- filteredItems.map((u) => (
+ items.map((u) => (