From 2c086345cdae7b1b8f26784d21eecb82d94326e9 Mon Sep 17 00:00:00 2001
From: blurbery <271863866+blurbery@users.noreply.github.com>
Date: Sat, 29 Aug 2026 15:24:20 +1000
Subject: [PATCH] fix(search): restore narrow title lookups
---
internal/catalog/item_repo_test.go | 32 ++++++++++
internal/catalog/search_postgres_mixed.go | 10 +++
web/src/components/GlobalSearch.test.tsx | 42 +++++++++++-
web/src/components/GlobalSearch.tsx | 1 +
web/src/components/RequestPosterCard.test.tsx | 19 ++++++
web/src/components/RequestPosterCard.tsx | 10 ++-
.../components/RequestToAddSection.test.tsx | 22 ++++++-
web/src/components/RequestToAddSection.tsx | 64 +++++++++++++++++--
web/src/hooks/queries/catalog.ts | 1 +
web/src/lib/mediaRequests.ts | 2 +-
web/src/pages/Catalog.test.tsx | 30 ++++++++-
web/src/pages/Catalog.tsx | 7 +-
12 files changed, 225 insertions(+), 15 deletions(-)
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("