Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions internal/catalog/item_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/catalog/search_postgres_mixed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand Down
42 changes: 41 additions & 1 deletion web/src/components/GlobalSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ vi.mock("@/components/RequestToAddSection", () => ({
variant,
query,
libraryHadHits,
libraryResultsKnown,
}: {
variant: string;
query: string;
libraryHadHits: boolean;
libraryResultsKnown?: boolean;
}) => (
<div data-testid="request-section">
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}"`}
{`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}" libraryResultsKnown="${String(libraryResultsKnown)}"`}
</div>
),
}));
Expand Down Expand Up @@ -428,6 +430,7 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {

expect(markup).toContain('data-testid="request-section"');
expect(markup).toContain("libraryHadHits=&quot;true&quot;");
expect(markup).toContain("libraryResultsKnown=&quot;true&quot;");
expect(markup).toContain("variant=&quot;dialog&quot;");
});

Expand Down Expand Up @@ -463,6 +466,43 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "ThisDoesNotExist" });

expect(markup).toContain("libraryHadHits=&quot;false&quot;");
expect(markup).toContain("libraryResultsKnown=&quot;true&quot;");
});

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=&quot;false&quot;");
expect(markup).toContain("libraryResultsKnown=&quot;false&quot;");
});

it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => {
Expand Down
1 change: 1 addition & 0 deletions web/src/components/GlobalSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export function GlobalSearch({
variant="dialog"
query={tmdbDebouncedQuery}
libraryHadHits={items.length > 0}
libraryResultsKnown={!previewQuery.isFetching && !previewQuery.isError}
/>
)}
</div>
Expand Down
19 changes: 19 additions & 0 deletions web/src/components/RequestPosterCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,23 @@ describe("RequestPosterCard (discover variant)", () => {
// its absence is the strongest signal that the button was suppressed.
expect(markup).not.toContain("<button");
});

it("shows the media type so same-title movies and series stay distinguishable", () => {
const movieMarkup = renderToStaticMarkup(
<MemoryRouter>
<RequestPosterCard variant="discover" item={requestable} />
</MemoryRouter>,
);
const seriesMarkup = renderToStaticMarkup(
<MemoryRouter>
<RequestPosterCard
variant="discover"
item={{ ...requestable, media_type: "series", tmdb_id: 43 }}
/>
</MemoryRouter>,
);

expect(movieMarkup).toContain(">Movie<");
expect(seriesMarkup).toContain(">Series<");
});
});
10 changes: 9 additions & 1 deletion web/src/components/RequestPosterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,16 @@ function CardMeta({
{hasMeta && (
<div className="text-muted-foreground mt-1 flex items-center gap-1.5 text-[11px]">
{mediaType && (
<Icon className="h-3 w-3 shrink-0 opacity-60" strokeWidth={2} aria-hidden />
<>
<Icon className="h-3 w-3 shrink-0 opacity-60" strokeWidth={2} aria-hidden />
<span>{mediaType === "series" ? "Series" : "Movie"}</span>
</>
)}
{mediaType && year ? (
<span aria-hidden className="text-muted-foreground/40">
·
</span>
) : null}
{year ? <span className="tabular-nums">{year}</span> : null}
{(year || mediaType) && rating ? (
<span aria-hidden className="text-muted-foreground/40">
Expand Down
22 changes: 20 additions & 2 deletions web/src/components/RequestToAddSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<RequestToAddSection
variant="dialog"
query="breaking bad"
libraryHadHits={false}
libraryResultsKnown={false}
/>,
);
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
Expand Down Expand Up @@ -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", () => {
Expand Down
64 changes: 57 additions & 7 deletions web/src/components/RequestToAddSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -58,12 +68,32 @@ export function RequestToAddSection({ variant, query, libraryHadHits }: RequestT
const visible = filtered.slice(0, limit);

if (variant === "dialog") {
return <DialogVariant items={visible} libraryHadHits={libraryHadHits} />;
return (
<DialogVariant
items={visible}
libraryHadHits={libraryHadHits}
libraryResultsKnown={libraryResultsKnown}
/>
);
}
return <GridVariant items={visible} libraryHadHits={libraryHadHits} />;
return (
<GridVariant
items={visible}
libraryHadHits={libraryHadHits}
libraryResultsKnown={libraryResultsKnown}
/>
);
}

function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count: number }) {
function HeaderCopy({
libraryHadHits,
libraryResultsKnown,
count,
}: {
libraryHadHits: boolean;
libraryResultsKnown: boolean;
count: number;
}) {
if (libraryHadHits) {
return (
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-[10px] font-medium tracking-[0.1em] uppercase">
Expand All @@ -75,6 +105,10 @@ function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count:
);
}

if (!libraryResultsKnown) {
return <div className="px-3 pt-3 pb-1 text-[12px] text-amber-300/85">Discovery matches:</div>;
}

return (
<div className="px-3 pt-3 pb-1 text-[12px] text-amber-300/85">
Not in your library, but you can request:
Expand All @@ -85,13 +119,19 @@ function HeaderCopy({ libraryHadHits, count }: { libraryHadHits: boolean; count:
function DialogVariant({
items,
libraryHadHits,
libraryResultsKnown,
}: {
items: RequestMediaResult[];
libraryHadHits: boolean;
libraryResultsKnown: boolean;
}) {
return (
<div className="border-t border-white/5 pt-1">
<HeaderCopy libraryHadHits={libraryHadHits} count={items.length} />
<HeaderCopy
libraryHadHits={libraryHadHits}
libraryResultsKnown={libraryResultsKnown}
count={items.length}
/>
<ul className="px-1 py-1">
{items.map((item) => (
<li key={`${item.media_type}-${item.tmdb_id}`}>
Expand Down Expand Up @@ -154,9 +194,11 @@ function DialogRow({ item }: { item: RequestMediaResult }) {
function GridVariant({
items,
libraryHadHits,
libraryResultsKnown,
}: {
items: RequestMediaResult[];
libraryHadHits: boolean;
libraryResultsKnown: boolean;
}) {
const count = items.length;
const createRequest = useCreateMediaRequest();
Expand Down Expand Up @@ -202,11 +244,19 @@ function GridVariant({
<div className="flex items-center gap-2 text-amber-200/85">
<Sparkles className="h-3.5 w-3.5" strokeWidth={2.2} aria-hidden />
<span className="text-[10px] font-semibold tracking-[0.24em] uppercase">
{libraryHadHits ? "Discover · Outside your library" : "Outside your library"}
{libraryHadHits
? "Discover · Outside your library"
: libraryResultsKnown
? "Outside your library"
: "Discovery"}
</span>
</div>
<h2 className="font-display text-foreground text-[clamp(1.25rem,1.6vw,1.55rem)] leading-tight font-semibold tracking-tight">
{libraryHadHits ? "Request to Add" : "Not in your library, but you can request"}
{libraryHadHits
? "Request to Add"
: libraryResultsKnown
? "Not in your library, but you can request"
: "More search matches"}
</h2>
</div>
<span className="inline-flex items-center gap-1.5 self-end rounded-full border border-amber-400/15 bg-amber-400/[0.06] px-2.5 py-1 text-[11px] font-medium tracking-wide text-amber-100/75 tabular-nums">
Expand Down
1 change: 1 addition & 0 deletions web/src/hooks/queries/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export function useCatalogWindow(
},
isLoading,
isError: page0Result.isError,
isPlaceholderData: page0Result.isPlaceholderData,
error: page0Result.error,
refetch: page0Result.refetch,
};
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/mediaRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
Loading
Loading