feat(search): add Media/Audiobooks/All scope with remembered per-user default

Search previously mixed audiobooks into movie/series results with no way to
separate them beyond single-type filters.

Backend: accept a new "video" group media scope (movies + series) anywhere a
media_scope is valid, expanded centrally via MediaScopeItemTypes into the
search item-type list, browse comma-list Type filter, and a type = ANY(...)
condition in the query executor. Register a user-scoped search.media_scope
setting (all|video|audiobook, default video).

Frontend: Media / Audiobooks / All chips on the search results page that
filter results and persist the choice as the user's default; the global
search typeahead follows the same preference. An explicit URL ?type= always
wins (with type=all as an unscoped sentinel), and the filter-bar dropdown
gains a Movies & Series option.

The API surface is additive, so Android/Apple clients are unaffected until
they adopt the new scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-09 15:46:11 -04:00
co-authored by Claude Fable 5
parent adbdd86ac5
commit 2fe2c918f4
16 changed files with 424 additions and 49 deletions
+9
View File
@@ -20,6 +20,7 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
const (
libraryPageStateSettingKey = "ui.library_page_state"
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
searchMediaScopeSettingKey = "search.media_scope"
)
const (
@@ -171,6 +172,14 @@ var settingsRegistry = map[string]settingSpec{
DefaultValue: "true",
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
},
// Preferred default scope for global/catalog search. "video" keeps
// results to movies and series; "all" mixes audiobooks in.
searchMediaScopeSettingKey: {
Scope: scopeUser,
DefaultValue: "video",
Validate: validateEnumSetting(searchMediaScopeSettingKey,
"all", "video", "audiobook"),
},
"player.hdr_enabled": {
Scope: scopeDevice,
DefaultValue: "true",
+4 -5
View File
@@ -389,12 +389,11 @@ func normalizeCatalogMatch(raw string) string {
}
func parseCatalogMediaScope(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "movie", "series", "episode", "audiobook":
return strings.ToLower(strings.TrimSpace(raw))
default:
return ""
scope := strings.ToLower(strings.TrimSpace(raw))
if scope != "" && IsValidMediaScope(scope) {
return scope
}
return ""
}
func defaultCatalogQuerySort(searchQuery string) QuerySort {
+7 -14
View File
@@ -1086,12 +1086,8 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
}
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
if def.MediaScope != "" &&
def.MediaScope != "movie" &&
def.MediaScope != "series" &&
def.MediaScope != "episode" &&
def.MediaScope != "audiobook" {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
if !IsValidMediaScope(def.MediaScope) {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
@@ -1607,12 +1603,7 @@ func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter,
MaxContentRating: access.MaxContentRating,
}
var itemTypes []string
if req.Query.MediaScope != "" {
itemTypes = []string{req.Query.MediaScope}
}
return searchAccess, itemTypes, false
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
}
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
@@ -1622,7 +1613,9 @@ func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilter
}
filters := BrowseFilters{
Type: req.Query.MediaScope,
// BrowseFilters.Type accepts a comma-separated type list, so group
// scopes like "video" expand here rather than leaking downstream.
Type: strings.Join(MediaScopeItemTypes(req.Query.MediaScope), ","),
NamePrefix: req.NamePrefix,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
@@ -1763,7 +1756,7 @@ func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*model
if item == nil {
continue
}
if def.MediaScope != "" && !strings.EqualFold(strings.TrimSpace(item.Type), def.MediaScope) {
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
continue
}
if catalogDefinitionMatchesItem(item, def) {
+117
View File
@@ -0,0 +1,117 @@
package catalog
import (
"net/url"
"reflect"
"strings"
"testing"
)
// TestMediaScopeItemTypes pins the expansion of group scopes: "video" covers
// the video-side media_items types so search/browse can offer a
// "Movies & Series vs Audiobooks" split without enumerating types per caller.
func TestMediaScopeItemTypes(t *testing.T) {
cases := []struct {
scope string
want []string
}{
{"", nil},
{"movie", []string{"movie"}},
{"audiobook", []string{"audiobook"}},
{"video", []string{"movie", "series"}},
{" Video ", []string{"movie", "series"}},
}
for _, tc := range cases {
if got := MediaScopeItemTypes(tc.scope); !reflect.DeepEqual(got, tc.want) {
t.Errorf("MediaScopeItemTypes(%q) = %v, want %v", tc.scope, got, tc.want)
}
}
}
func TestMediaScopeMatchesItemType(t *testing.T) {
cases := []struct {
scope string
itemType string
want bool
}{
{"", "audiobook", true},
{"video", "movie", true},
{"video", "series", true},
{"video", "audiobook", false},
{"audiobook", "audiobook", true},
{"movie", "series", false},
}
for _, tc := range cases {
if got := MediaScopeMatchesItemType(tc.scope, tc.itemType); got != tc.want {
t.Errorf("MediaScopeMatchesItemType(%q, %q) = %v, want %v", tc.scope, tc.itemType, got, tc.want)
}
}
}
// TestParseCatalogRequest_VideoMediaScope asserts ?type=video parses into the
// query definition's media scope, and that catalogSearchAccess expands it to
// the video item types for the direct search path.
func TestParseCatalogRequest_VideoMediaScope(t *testing.T) {
req, err := ParseCatalogRequest(url.Values{
"source": {"query"},
"q": {"the rookie"},
"type": {"video"},
})
if err != nil {
t.Fatalf("ParseCatalogRequest: %v", err)
}
if req.Query.MediaScope != "video" {
t.Fatalf("expected media scope video, got %q", req.Query.MediaScope)
}
_, itemTypes, earlyEmpty := catalogSearchAccess(req, AccessFilter{})
if earlyEmpty {
t.Fatal("unexpected early empty")
}
if !reflect.DeepEqual(itemTypes, []string{"movie", "series"}) {
t.Fatalf("expected video scope to expand to movie+series, got %v", itemTypes)
}
}
// TestPreviewPage_VideoScopeUsesTypeAny asserts the preview/query-executor
// path renders a multi-type condition for the video group scope.
func TestPreviewPage_VideoScopeUsesTypeAny(t *testing.T) {
sql, args, err := (&QueryExecutor{}).buildPreviewPageSQL(
QueryDefinition{
MediaScope: "video",
Sort: QuerySort{Field: "title", Order: "asc"},
},
AccessFilter{},
20,
0,
true,
)
if err != nil {
t.Fatalf("buildPreviewPageSQL error: %v", err)
}
if !strings.Contains(sql, "mi.type = ANY(") {
t.Fatalf("expected mi.type = ANY(...) for video scope, got %s", sql)
}
found := false
for _, arg := range args {
if types, ok := arg.([]string); ok && reflect.DeepEqual(types, []string{"movie", "series"}) {
found = true
}
}
if !found {
t.Fatalf("expected movie+series type arg, got %v", args)
}
}
// TestQueryDefinitionValidate_VideoScope asserts "video" passes definition
// validation alongside the single-type scopes.
func TestQueryDefinitionValidate_VideoScope(t *testing.T) {
def := QueryDefinition{MediaScope: "video"}
if err := def.Validate(); err != nil {
t.Fatalf("expected video media scope to validate, got %v", err)
}
bad := QueryDefinition{MediaScope: "podcast"}
if err := bad.Validate(); err == nil {
t.Fatal("expected invalid media scope to fail validation")
}
}
+49 -6
View File
@@ -104,6 +104,53 @@ type QueryDefinition struct {
Limit *int `json:"limit,omitempty"`
}
// MediaScopeVideo is the group scope covering all video-side item types. It
// is accepted anywhere a single-type media scope is and expands to the
// underlying media_items.type values via MediaScopeItemTypes.
const MediaScopeVideo = "video"
// IsValidMediaScope reports whether scope (already normalized to lowercase)
// is an accepted media_scope value. Empty means unscoped and is valid.
func IsValidMediaScope(scope string) bool {
switch scope {
case "", "movie", "series", "episode", "audiobook", MediaScopeVideo:
return true
default:
return false
}
}
// MediaScopeItemTypes expands a media scope into the media_items.type values
// it covers. Single-type scopes map to themselves; the empty scope returns
// nil (unscoped).
func MediaScopeItemTypes(scope string) []string {
scope = strings.ToLower(strings.TrimSpace(scope))
switch scope {
case "":
return nil
case MediaScopeVideo:
return []string{"movie", "series"}
default:
return []string{scope}
}
}
// MediaScopeMatchesItemType reports whether an item type falls inside scope.
// An empty scope matches every type.
func MediaScopeMatchesItemType(scope, itemType string) bool {
types := MediaScopeItemTypes(scope)
if len(types) == 0 {
return true
}
itemType = strings.ToLower(strings.TrimSpace(itemType))
for _, t := range types {
if t == itemType {
return true
}
}
return false
}
const (
DefaultSmartCollectionItemLimit = 100
MaxSmartCollectionItemLimit = 500
@@ -182,12 +229,8 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
}
}
if normalized.MediaScope != "" &&
normalized.MediaScope != "movie" &&
normalized.MediaScope != "series" &&
normalized.MediaScope != "episode" &&
normalized.MediaScope != "audiobook" {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
if !IsValidMediaScope(normalized.MediaScope) {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'")
}
if normalized.Match != "all" && normalized.Match != "any" {
+8 -2
View File
@@ -282,8 +282,14 @@ func (e *QueryExecutor) buildPreviewPagePlan(
conditions = append(conditions, filterWhere)
}
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
args = append(args, def.MediaScope)
scopeTypes := MediaScopeItemTypes(def.MediaScope)
if len(scopeTypes) == 1 {
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
args = append(args, scopeTypes[0])
} else {
conditions = append(conditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
args = append(args, scopeTypes)
}
argIdx++
}
libScopeWhere, libScopeArgs, hasLibraryScope := "", []any(nil), false
+3 -2
View File
@@ -1174,7 +1174,7 @@ export interface QuerySort {
export interface QueryDefinition {
library_ids: number[];
media_scope?: "movie" | "series" | "episode" | "audiobook";
media_scope?: "movie" | "series" | "episode" | "audiobook" | "video";
match: "all" | "any";
groups: QueryGroup[];
sort: QuerySort;
@@ -3129,7 +3129,8 @@ export function normalizeQueryDefinition(value?: QueryDefinitionInput | null): Q
value?.media_scope === "movie" ||
value?.media_scope === "series" ||
value?.media_scope === "episode" ||
value?.media_scope === "audiobook"
value?.media_scope === "audiobook" ||
value?.media_scope === "video"
? value.media_scope
: undefined,
match: value?.match === "any" ? "any" : "all",
+16 -3
View File
@@ -5,8 +5,9 @@ import { VisuallyHidden } from "radix-ui";
import { useViewTransitionNavigate } from "@/hooks/useViewTransition";
import { useDebounce } from "@/hooks/useDebounce";
import { buildQueryCatalogHref } from "@/pages/catalogSearchParams";
import type { BrowseItem } from "@/api/types";
import { createEmptyQueryDefinition, type BrowseItem } from "@/api/types";
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
import { useSearchMediaScope } from "@/hooks/useSearchMediaScope";
import { useRequestSearch } from "@/hooks/queries/useRequests";
import { useCanRequest } from "@/hooks/useCanRequest";
import { catalogKeys } from "@/hooks/queries/keys";
@@ -29,6 +30,8 @@ function typeLabel(type: BrowseItem["type"]): string {
return "Season";
case "episode":
return "Episode";
case "audiobook":
return "Audiobook";
default:
return type;
}
@@ -123,9 +126,19 @@ export function GlobalSearch({
const tmdbDebounceCatchingUp =
canRequest.discoveryEnabled && tmdbDebouncedQuery !== debouncedQuery;
// Preview results follow the user's preferred search scope (Media vs
// Audiobooks vs All); the full results page applies the same default.
const { scope: searchScope } = useSearchMediaScope();
const searchState = useMemo(
() => createCatalogSearchState("query", { q: debouncedQuery || undefined }),
[debouncedQuery],
() =>
createCatalogSearchState("query", {
q: debouncedQuery || undefined,
query_definition: {
...createEmptyQueryDefinition(),
media_scope: searchScope === "all" ? undefined : searchScope,
},
}),
[debouncedQuery, searchScope],
);
const previewQuery = useQuery({
+20 -10
View File
@@ -53,7 +53,9 @@ export default function CatalogFilterBar({
<Select
value={state.mediaScope}
onValueChange={(v) => {
const nextRelevanceScope = v === "all" ? "all" : (v as QuerySortRelevanceScope);
// "video" spans movie+series, so sorts valid for "all" stay valid.
const nextRelevanceScope =
v === "all" || v === "video" ? "all" : (v as QuerySortRelevanceScope);
const nextSort = normalizeQuerySortForScope(
{ field: state.sortField, order: state.sortOrder },
{
@@ -73,6 +75,7 @@ export default function CatalogFilterBar({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Media</SelectItem>
<SelectItem value="video">Movies & Series</SelectItem>
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
@@ -85,21 +88,28 @@ export default function CatalogFilterBar({
<Select
value={selectedSort.field}
onValueChange={(v) => {
const sortOption = getQuerySortOptions({ includePersonalized: allowPersonalizedSorts })
.find((opt) => opt.value === v);
const sortOption = getQuerySortOptions({
includePersonalized: allowPersonalizedSorts,
}).find((opt) => opt.value === v);
const patch: Partial<GuidedFormState> = {
sortField: v,
sortOrder: getDefaultQuerySortOrder(v),
};
if (showMediaScopeSelector && sortOption) {
const scopeTypes: Array<Exclude<QuerySortRelevanceScope, "all">> | null =
state.mediaScope === "all"
? null
: state.mediaScope === "video"
? ["movie", "series"]
: [state.mediaScope];
const currentApplicable =
state.mediaScope === "all" ||
sortOption.applicableMediaScopes.includes(
state.mediaScope as Exclude<QuerySortRelevanceScope, "all">,
);
if (sortOption.preferredMediaScope && state.mediaScope !== sortOption.preferredMediaScope) {
patch.mediaScope = sortOption
.preferredMediaScope as GuidedFormState["mediaScope"];
!scopeTypes ||
scopeTypes.some((scope) => sortOption.applicableMediaScopes.includes(scope));
if (
sortOption.preferredMediaScope &&
state.mediaScope !== sortOption.preferredMediaScope
) {
patch.mediaScope = sortOption.preferredMediaScope as GuidedFormState["mediaScope"];
} else if (!currentApplicable) {
patch.mediaScope = sortOption
.applicableMediaScopes[0] as GuidedFormState["mediaScope"];
@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
import type { SearchMediaScope } from "@/hooks/useSearchMediaScope";
const SCOPE_OPTIONS: Array<{ value: SearchMediaScope; label: string }> = [
{ value: "video", label: "Media" },
{ value: "audiobook", label: "Audiobooks" },
{ value: "all", label: "All" },
];
export interface SearchScopeChipsProps {
activeScope: SearchMediaScope;
onScopeChange: (scope: SearchMediaScope) => void;
}
/**
* Coarse search-scope toggle shown under the search bar: Media (movies &
* series), Audiobooks, or All. Selecting a chip both filters the current
* results and saves the choice as the user's default search scope.
*/
export default function SearchScopeChips({ activeScope, onScopeChange }: SearchScopeChipsProps) {
return (
<div
role="radiogroup"
aria-label="Search scope"
className="surface-panel inline-flex items-center gap-1 rounded-full p-1"
>
{SCOPE_OPTIONS.map((option) => {
const isActive = option.value === activeScope;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => {
if (!isActive) {
onScopeChange(option.value);
}
}}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
@@ -40,7 +40,7 @@ const DECADE_OPTIONS = Array.from({ length: 15 }, (_, index) => 2030 - index * 1
/** Flat form state that maps 1-to-1 with friendly form fields. */
export interface GuidedFormState {
mediaScope: "all" | "movie" | "series" | "episode" | "audiobook";
mediaScope: "all" | "video" | "movie" | "series" | "episode" | "audiobook";
libraryIds: number[];
genres: string[];
decade: string;
@@ -462,7 +462,8 @@ export default function CollectionGuidedRulesEditor({
<Select
value={state.mediaScope}
onValueChange={(v) => {
const nextRelevanceScope = v === "all" ? "all" : (v as QuerySortRelevanceScope);
const nextRelevanceScope =
v === "all" || v === "video" ? "all" : (v as QuerySortRelevanceScope);
const nextSort = normalizeQuerySortForScope(
{ field: state.sortField, order: state.sortOrder },
{
@@ -483,6 +484,7 @@ export default function CollectionGuidedRulesEditor({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Media</SelectItem>
<SelectItem value="video">Movies & Series</SelectItem>
<SelectItem value="movie">Movies</SelectItem>
<SelectItem value="series">Series</SelectItem>
<SelectItem value="episode">Episodes</SelectItem>
+39
View File
@@ -0,0 +1,39 @@
import { useCallback } from "react";
import { useSetSetting, useSetting } from "@/hooks/queries/settings";
export const SEARCH_MEDIA_SCOPE_SETTING_KEY = "search.media_scope";
/** Coarse search scope: "video" = movies & series, "audiobook" = books. */
export type SearchMediaScope = "all" | "video" | "audiobook";
export const DEFAULT_SEARCH_MEDIA_SCOPE: SearchMediaScope = "video";
export function parseSearchMediaScope(value: string | null | undefined): SearchMediaScope | null {
return value === "all" || value === "video" || value === "audiobook" ? value : null;
}
/**
* Server-persisted preference for the default search scope. Search entry
* points (global search, the catalog search page) apply this scope when the
* URL doesn't carry an explicit `type` param, and the scope chips write back
* to it so the choice sticks across sessions and devices.
*/
export function useSearchMediaScope() {
const settingQuery = useSetting(SEARCH_MEDIA_SCOPE_SETTING_KEY);
const setSetting = useSetSetting();
const scope = parseSearchMediaScope(settingQuery.data) ?? DEFAULT_SEARCH_MEDIA_SCOPE;
const setScope = useCallback(
(next: SearchMediaScope) => {
setSetting.mutate({ key: SEARCH_MEDIA_SCOPE_SETTING_KEY, value: next });
},
[setSetting],
);
// While the setting loads, scope falls back to the default ("video") so
// consumers can fetch immediately; a differing stored preference simply
// refetches once it arrives.
return { scope, setScope };
}
+47
View File
@@ -557,4 +557,51 @@ describe("Catalog page", () => {
expect(markup).toContain('data-loading="false"');
expect(markup).toContain('data-total="0"');
});
it("applies the preferred media scope (default: video) when the URL has no type param", () => {
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(mockUseCatalogWindow).toHaveBeenCalledWith(
expect.objectContaining({
source: "query",
q: "heat",
query_definition: expect.objectContaining({ media_scope: "video" }),
}),
expect.anything(),
);
});
it("respects an explicit type=all scope instead of the preferred default", () => {
appInitialEntries = ["/catalog?source=query&q=heat&type=all"];
renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(mockUseCatalogWindow).toHaveBeenCalledWith(
expect.objectContaining({
source: "query",
q: "heat",
query_definition: expect.objectContaining({ media_scope: undefined }),
}),
expect.anything(),
);
});
it("renders the search scope chips on query results", () => {
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>,
);
expect(markup).toContain('aria-label="Search scope"');
expect(markup).toContain("Audiobooks");
});
});
+35 -4
View File
@@ -7,7 +7,9 @@ import ItemGrid from "@/components/ItemGrid";
import { RequestToAddSection } from "@/components/RequestToAddSection";
import { Button } from "@/components/ui/button";
import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel";
import SearchScopeChips from "@/components/catalog/SearchScopeChips";
import { useCatalogWindow } from "@/hooks/queries/catalog";
import { useSearchMediaScope, type SearchMediaScope } from "@/hooks/useSearchMediaScope";
import { useRemoveHistory } from "@/hooks/queries/history";
import { useRequestSearch } from "@/hooks/queries/useRequests";
import { useCanRequest } from "@/hooks/useCanRequest";
@@ -106,14 +108,42 @@ function CatalogResults({
useEffect(() => () => clearTimeout(debounceRef.current), []);
const isQuerySource = state.source === "query" && Boolean(state.q);
const { scope: preferredScope, setScope: setPreferredScope } = useSearchMediaScope();
// The URL `type` param is the explicit search scope ("all" included as a
// sentinel). When it's absent — fresh navigation, bookmark, global search —
// the user's preferred scope applies as the default.
const hasExplicitScope = !isQuerySource || searchParams.has("type");
const effectiveState = useMemo(() => {
if (hasExplicitScope || preferredScope === "all") {
return state;
}
return {
...state,
query_definition: { ...state.query_definition, media_scope: preferredScope },
};
}, [hasExplicitScope, preferredScope, state]);
const mediaScope = effectiveState.query_definition.media_scope;
const activeChipScope: SearchMediaScope =
mediaScope === "audiobook" ? "audiobook" : mediaScope ? "video" : "all";
const handleChipScopeChange = useCallback(
(scope: SearchMediaScope) => {
setPreferredScope(scope);
const next = new URLSearchParams(searchParams);
next.set("type", scope);
setSearchParams(next);
},
[searchParams, setPreferredScope, setSearchParams],
);
const showExactResultCount = state.source !== "section";
const catalogQuery = useCatalogWindow(state, {
const catalogQuery = useCatalogWindow(effectiveState, {
limit,
visibleRange,
includeTotal: showExactResultCount,
});
const canRequest = useCanRequest();
const isQuerySource = state.source === "query" && Boolean(state.q);
// Add a 200ms TMDB debounce on top of SearchBar's 200ms input debounce so the
// TMDB plugin isn't hit at the same cadence as the local library query.
const tmdbDebouncedQ = useDebounce(state.q ?? "", 200);
@@ -217,13 +247,14 @@ function CatalogResults({
</header>
{state.source === "query" ? (
<div className="flex justify-center">
<div className="flex flex-col items-center gap-3">
<SearchBar prominent initialQuery={state.q ?? ""} autoFocus />
<SearchScopeChips activeScope={activeChipScope} onScopeChange={handleChipScopeChange} />
</div>
) : null}
<CatalogFiltersPanel
state={state}
state={effectiveState}
onStateChange={(nextState) => {
const nextSearchParams = buildCatalogApiSearchParams(nextState);
if (nextSearchParams.toString() !== searchParams.toString()) {
@@ -39,6 +39,14 @@ describe("parseCatalogSearchParams", () => {
expect(state.query_definition.sort).toEqual({ field: "title", order: "asc" });
});
it("parses the video group scope and treats type=all as unscoped", () => {
const video = parseCatalogSearchParams(params("source=query&q=heat&type=video"));
expect(video.query_definition.media_scope).toBe("video");
const all = parseCatalogSearchParams(params("source=query&q=heat&type=all"));
expect(all.query_definition.media_scope).toBeUndefined();
});
it("parses person catalog sources with overlays", () => {
const state = parseCatalogSearchParams(
params(
+5 -1
View File
@@ -135,7 +135,11 @@ export function parseCatalogSearchParams(searchParams: URLSearchParams): Catalog
baseState.query_definition = normalizeQueryDefinition({
library_ids: baseState.library_id ? [baseState.library_id] : [],
media_scope:
type === "movie" || type === "series" || type === "episode" || type === "audiobook"
type === "movie" ||
type === "series" ||
type === "episode" ||
type === "audiobook" ||
type === "video"
? type
: undefined,
match: searchParams.get("match") === "any" ? "any" : "all",