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 && (
-
- {candidates.map((candidate, index) => { - const candidateKey = Object.entries(candidate.provider_ids) - .map(([k, v]) => `${k}-${v}`) - .join("_"); - return ( - - ); - })} -
+ + ); + })} +
+ )} 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 (
@@ -1689,9 +1692,14 @@ function UnmatchedItemsSection() {
setSearch(e.target.value)} + onChange={(e) => { + // Search is server-side and spans the whole table; jump back to + // the first page so results start at the top as the query changes. + setSearch(e.target.value); + setPage(0); + }} className="h-8 pl-8 text-xs" />
@@ -1707,14 +1715,14 @@ function UnmatchedItemsSection() { - {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) => (