diff --git a/internal/catalog/item_repo_test.go b/internal/catalog/item_repo_test.go
index 790c6bfdf..b24fa83de 100644
--- a/internal/catalog/item_repo_test.go
+++ b/internal/catalog/item_repo_test.go
@@ -527,6 +527,38 @@ func TestItemRepo_Search_ShortFinalTokenUsesLeadingTitleIndexes(t *testing.T) {
}
}
+// TestItemRepo_Search_NarrowTitlePathTypesSearchTextParameter guards a
+// prepare-time failure that only appeared after the user finished a short
+// final token: "breaking" used the regular FTS path, while "Breaking Bad"
+// suppressed the overview arm and therefore left the still-bound $1 without a
+// PostgreSQL type (SQLSTATE 42P18). Every physical source and both page/count
+// statements must carry the explicit, parameter-only type guard.
+func TestItemRepo_Search_NarrowTitlePathTypesSearchTextParameter(t *testing.T) {
+ repo := &ItemRepository{}
+ for _, test := range []struct {
+ name string
+ query string
+ itemTypes []string
+ }{
+ {name: "mixed multiword", query: "Breaking Bad"},
+ {name: "media multiword", query: "Breaking Bad", itemTypes: []string{"movie", "series"}},
+ {name: "episode multiword", query: "Who Are You?", itemTypes: []string{"episode"}},
+ {name: "mixed short title", query: "Up"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ dataSQL, countSQL, args := repo.buildSearchSQLWithTotal(test.query, test.itemTypes, 20, 0, AccessFilter{}, true)
+ if len(args) < 2 || args[0] != test.query {
+ t.Fatalf("unexpected fixed search arguments: %#v", args)
+ }
+ for _, sql := range []string{dataSQL, countSQL} {
+ if !strings.Contains(sql, "$1::text IS NOT NULL") {
+ t.Fatalf("narrow search must type bound $1 in both statements; got:\n%s", sql)
+ }
+ }
+ })
+ }
+}
+
// TestItemRepo_BuildFuzzySearchSQL asserts that the fuzzy fallback query scores
// only on indexed normalized title/alias columns (no title tsvector rebuild),
// matches via strict word similarity so long titles stay reachable, ranks by
diff --git a/internal/catalog/search_postgres_mixed.go b/internal/catalog/search_postgres_mixed.go
index 9ff908897..79ea8581b 100644
--- a/internal/catalog/search_postgres_mixed.go
+++ b/internal/catalog/search_postgres_mixed.go
@@ -276,6 +276,16 @@ func (r *ItemRepository) buildMixedSearchSQLFromParsed(
}
scoredCTE := "WITH scored AS (\n" + scoredBody + "\n)"
postFilter := `FROM scored`
+ if narrowTitleLookup {
+ // Narrow title searches intentionally skip the overview branch. That
+ // branch is normally what gives $1 (searchText) its PostgreSQL type;
+ // without it, queries such as "Breaking Bad" reference $2 and later
+ // placeholders but fail at parse time with SQLSTATE 42P18 because $1 is
+ // untyped. This parameter-only guard is always true for a built search
+ // (empty input returned above), types $1 explicitly, and is planned as a
+ // one-time filter without widening either indexed title lookup.
+ postFilter += ` WHERE $1::text IS NOT NULL`
+ }
pageTotalColumn := ""
finalTotalColumn := ""
diff --git a/web/src/components/GlobalSearch.test.tsx b/web/src/components/GlobalSearch.test.tsx
index 6c9f67b82..b53933822 100644
--- a/web/src/components/GlobalSearch.test.tsx
+++ b/web/src/components/GlobalSearch.test.tsx
@@ -43,13 +43,15 @@ vi.mock("@/components/RequestToAddSection", () => ({
variant,
query,
libraryHadHits,
+ libraryResultsKnown,
}: {
variant: string;
query: string;
libraryHadHits: boolean;
+ libraryResultsKnown?: boolean;
}) => (
- {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
+ {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}" libraryResultsKnown="${String(libraryResultsKnown)}"`}
),
}));
@@ -428,6 +430,7 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
expect(markup).toContain('data-testid="request-section"');
expect(markup).toContain("libraryHadHits="true"");
+ expect(markup).toContain("libraryResultsKnown="true"");
expect(markup).toContain("variant="dialog"");
});
@@ -463,6 +466,43 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" });
expect(markup).toContain("libraryHadHits="false"");
+ expect(markup).toContain("libraryResultsKnown="true"");
+ });
+
+ it("marks library results unknown while the local preview is still pending", () => {
+ mocks.useCanRequest.mockReturnValue({
+ discoveryEnabled: true,
+ isResolving: false,
+ submitDisabledReason: null,
+ });
+ mocks.useQuery.mockReturnValue({
+ data: undefined,
+ isFetching: true,
+ isError: false,
+ });
+ mocks.useRequestSearch.mockReturnValue({
+ data: {
+ page: 1,
+ total_pages: 1,
+ total_results: 1,
+ results: [
+ {
+ media_type: "movie",
+ tmdb_id: 1,
+ title: "X",
+ availability: "missing",
+ request: { requestable: true },
+ },
+ ],
+ },
+ isLoading: false,
+ isError: false,
+ });
+
+ const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
+
+ expect(markup).toContain("libraryHadHits="false"");
+ expect(markup).toContain("libraryResultsKnown="false"");
});
it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => {
diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx
index 248c33d55..fa5f13807 100644
--- a/web/src/components/GlobalSearch.tsx
+++ b/web/src/components/GlobalSearch.tsx
@@ -374,6 +374,7 @@ export function GlobalSearch({
variant="dialog"
query={tmdbDebouncedQuery}
libraryHadHits={items.length > 0}
+ libraryResultsKnown={!previewQuery.isFetching && !previewQuery.isError}
/>
)}
diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx
index e84656599..41cc63b5d 100644
--- a/web/src/components/RequestPosterCard.test.tsx
+++ b/web/src/components/RequestPosterCard.test.tsx
@@ -59,4 +59,23 @@ describe("RequestPosterCard (discover variant)", () => {
// its absence is the strongest signal that the button was suppressed.
expect(markup).not.toContain(" {
+ const movieMarkup = renderToStaticMarkup(
+
+
+ ,
+ );
+ const seriesMarkup = renderToStaticMarkup(
+
+
+ ,
+ );
+
+ expect(movieMarkup).toContain(">Movie<");
+ expect(seriesMarkup).toContain(">Series<");
+ });
});
diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx
index a518bbe0e..46b48a613 100644
--- a/web/src/components/RequestPosterCard.tsx
+++ b/web/src/components/RequestPosterCard.tsx
@@ -322,8 +322,16 @@ function CardMeta({
{hasMeta && (
{mediaType && (
-
+ <>
+
+
{mediaType === "series" ? "Series" : "Movie"}
+ >
)}
+ {mediaType && year ? (
+
+ ·
+
+ ) : null}
{year ?
{year} : null}
{(year || mediaType) && rating ? (
diff --git a/web/src/components/RequestToAddSection.test.tsx b/web/src/components/RequestToAddSection.test.tsx
index 224dc3542..fbf372aa1 100644
--- a/web/src/components/RequestToAddSection.test.tsx
+++ b/web/src/components/RequestToAddSection.test.tsx
@@ -152,6 +152,24 @@ describe("RequestToAddSection (dialog variant)", () => {
expect(markup).not.toContain("Request to Add");
});
+ it("does not claim media is absent while the local lookup is unresolved or failed", () => {
+ mocks.useRequestSearch.mockReturnValue({
+ data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] },
+ isLoading: false,
+ isError: false,
+ });
+ const markup = render(
+ ,
+ );
+ expect(markup).toContain("Discovery matches:");
+ expect(markup).not.toContain("Not in your library");
+ });
+
it("filters out results already available in the library", () => {
// missingResult has tmdb_id 1, availableResult has tmdb_id 2. The DialogRow
// renders item.title only as text content (never as a `title=` attribute), so
@@ -237,8 +255,8 @@ describe("RequestToAddSection (dialog variant)", () => {
expect(markup).toContain("Quota Capped Movie");
expect(markup).not.toContain("bg-amber-400/15");
- expect(markup).toContain("Limit reached");
- expect(markup).toContain('title="Limit reached"');
+ expect(markup).toContain("Request limit reached");
+ expect(markup).toContain('title="Request limit reached"');
});
it("prefers request status over reason when a row is already requested", () => {
diff --git a/web/src/components/RequestToAddSection.tsx b/web/src/components/RequestToAddSection.tsx
index 9fd26d9e7..ee04cb2b4 100644
--- a/web/src/components/RequestToAddSection.tsx
+++ b/web/src/components/RequestToAddSection.tsx
@@ -36,9 +36,19 @@ export type RequestToAddSectionProps = {
query: string;
/** True when the library search returned at least one hit. Drives header copy. */
libraryHadHits: boolean;
+ /**
+ * True only after the matching local-library query completed successfully.
+ * Loading and failed searches must not be presented as confirmed absences.
+ */
+ libraryResultsKnown?: boolean;
};
-export function RequestToAddSection({ variant, query, libraryHadHits }: RequestToAddSectionProps) {
+export function RequestToAddSection({
+ variant,
+ query,
+ libraryHadHits,
+ libraryResultsKnown = true,
+}: RequestToAddSectionProps) {
const { discoveryEnabled } = useCanRequest();
const search = useRequestSearch("all", query, 1, {
enabled: discoveryEnabled,
@@ -58,12 +68,32 @@ export function RequestToAddSection({ variant, query, libraryHadHits }: RequestT
const visible = filtered.slice(0, limit);
if (variant === "dialog") {
- return ;
+ return (
+
+ );
}
- return ;
+ return (
+
+ );
}
-function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) {
+function HeaderCopy({
+ libraryHadHits,
+ libraryResultsKnown,
+ count,
+}: {
+ libraryHadHits: boolean;
+ libraryResultsKnown: boolean;
+ count: number;
+}) {
if (libraryHadHits) {
return (
@@ -75,6 +105,10 @@ function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count:
);
}
+ if (!libraryResultsKnown) {
+ return
Discovery matches:
;
+ }
+
return (
Not in your library, but you can request:
@@ -85,13 +119,19 @@ function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count:
function DialogVariant({
items,
libraryHadHits,
+ libraryResultsKnown,
}: {
items: RequestMediaResult[];
libraryHadHits: boolean;
+ libraryResultsKnown: boolean;
}) {
return (
diff --git a/web/src/hooks/queries/catalog.ts b/web/src/hooks/queries/catalog.ts
index 024979514..7be398fe6 100644
--- a/web/src/hooks/queries/catalog.ts
+++ b/web/src/hooks/queries/catalog.ts
@@ -332,6 +332,7 @@ export function useCatalogWindow(
},
isLoading,
isError: page0Result.isError,
+ isPlaceholderData: page0Result.isPlaceholderData,
error: page0Result.error,
refetch: page0Result.refetch,
};
diff --git a/web/src/lib/mediaRequests.ts b/web/src/lib/mediaRequests.ts
index 1a7a686a3..58fe29c11 100644
--- a/web/src/lib/mediaRequests.ts
+++ b/web/src/lib/mediaRequests.ts
@@ -98,7 +98,7 @@ export function formatRequestReason(reason?: string): string {
case "blocked":
return "Blocked";
case "quota_exceeded":
- return "Limit reached";
+ return "Request limit reached";
default:
return "Unavailable";
}
diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx
index 8dc9eb5f0..c6ecdf964 100644
--- a/web/src/pages/Catalog.test.tsx
+++ b/web/src/pages/Catalog.test.tsx
@@ -53,13 +53,15 @@ vi.mock("@/components/RequestToAddSection", () => ({
variant,
query,
libraryHadHits,
+ libraryResultsKnown,
}: {
variant: string;
query: string;
libraryHadHits: boolean;
+ libraryResultsKnown?: boolean;
}) => (
- {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
+ {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}" libraryResultsKnown="${String(libraryResultsKnown)}"`}
),
}));
@@ -388,6 +390,7 @@ describe("Catalog page", () => {
expect(markup).toContain('data-testid="request-section"');
expect(markup).toContain("variant="grid"");
expect(markup).toContain("libraryHadHits="true"");
+ expect(markup).toContain("libraryResultsKnown="true"");
});
it("renders the request grid variant with libraryHadHits=false when library has 0 hits", () => {
@@ -426,6 +429,31 @@ describe("Catalog page", () => {
);
expect(markup).toContain("libraryHadHits="false"");
+ expect(markup).toContain("libraryResultsKnown="true"");
+ });
+
+ it("marks library results unknown when the local search failed", () => {
+ mockUseCanRequest.mockReturnValue({
+ discoveryEnabled: true,
+ isResolving: false,
+ submitDisabledReason: null,
+ });
+ mockUseCatalogWindow.mockReturnValue({
+ data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
+ isLoading: false,
+ isError: true,
+ isPlaceholderData: false,
+ refetch: vi.fn(),
+ });
+
+ const markup = renderToStaticMarkup(
+
+
+ ,
+ );
+
+ expect(markup).toContain("libraryHadHits="false"");
+ expect(markup).toContain("libraryResultsKnown="false"");
});
it("does not render the request section when source is not query", () => {
diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx
index 9eb9db947..fcf46fc49 100644
--- a/web/src/pages/Catalog.tsx
+++ b/web/src/pages/Catalog.tsx
@@ -238,8 +238,10 @@ function CatalogResults({
});
const tmdbMissingCount =
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
- const libraryHasResults = (catalogQuery.data?.totalItems ?? 0) > 0;
- const libraryEmpty = !catalogQuery.isLoading && !libraryHasResults;
+ const libraryResultsKnown =
+ !catalogQuery.isLoading && !catalogQuery.isPlaceholderData && !catalogQuery.isError;
+ const libraryHasResults = libraryResultsKnown && (catalogQuery.data?.totalItems ?? 0) > 0;
+ const libraryEmpty = libraryResultsKnown && !libraryHasResults;
// When the library is empty and the request section will (or might) render,
// hide ItemGrid entirely. The previous approach pinned ItemGrid's `loading`
// prop to true, which renders 24 skeleton tiles forever above the section.
@@ -453,6 +455,7 @@ function CatalogResults({
variant="grid"
query={tmdbDebouncedQ}
libraryHadHits={libraryHasResults}
+ libraryResultsKnown={libraryResultsKnown}
/>
) : null}