From 2fe2c918f430b98209f7e0afed830ec6dbf80ac0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:46:11 -0400 Subject: [PATCH] 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 --- internal/api/handlers/settings.go | 9 ++ internal/catalog/catalog_parser.go | 9 +- internal/catalog/catalog_resolver.go | 21 ++-- internal/catalog/media_scope_test.go | 117 ++++++++++++++++++ internal/catalog/query_definition.go | 55 +++++++- internal/catalog/query_executor.go | 10 +- web/src/api/types.ts | 5 +- web/src/components/GlobalSearch.tsx | 19 ++- .../components/catalog/CatalogFilterBar.tsx | 30 +++-- .../components/catalog/SearchScopeChips.tsx | 53 ++++++++ .../CollectionGuidedRulesEditor.tsx | 6 +- web/src/hooks/useSearchMediaScope.ts | 39 ++++++ web/src/pages/Catalog.test.tsx | 47 +++++++ web/src/pages/Catalog.tsx | 39 +++++- web/src/pages/catalogSearchParams.test.ts | 8 ++ web/src/pages/catalogSearchParams.ts | 6 +- 16 files changed, 424 insertions(+), 49 deletions(-) create mode 100644 internal/catalog/media_scope_test.go create mode 100644 web/src/components/catalog/SearchScopeChips.tsx create mode 100644 web/src/hooks/useSearchMediaScope.ts diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index be2d1883..56d722a6 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -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", diff --git a/internal/catalog/catalog_parser.go b/internal/catalog/catalog_parser.go index 1c5d97b2..bddd0905 100644 --- a/internal/catalog/catalog_parser.go +++ b/internal/catalog/catalog_parser.go @@ -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 { diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index b949bdca..75bdbd1d 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -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) { diff --git a/internal/catalog/media_scope_test.go b/internal/catalog/media_scope_test.go new file mode 100644 index 00000000..b08c8206 --- /dev/null +++ b/internal/catalog/media_scope_test.go @@ -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") + } +} diff --git a/internal/catalog/query_definition.go b/internal/catalog/query_definition.go index b9fb115f..ccd859c6 100644 --- a/internal/catalog/query_definition.go +++ b/internal/catalog/query_definition.go @@ -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" { diff --git a/internal/catalog/query_executor.go b/internal/catalog/query_executor.go index e4e00ff6..37950cf8 100644 --- a/internal/catalog/query_executor.go +++ b/internal/catalog/query_executor.go @@ -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 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 34a04912..6b56e09a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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", diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx index f9bcc8b1..db25d9f3 100644 --- a/web/src/components/GlobalSearch.tsx +++ b/web/src/components/GlobalSearch.tsx @@ -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({ diff --git a/web/src/components/catalog/CatalogFilterBar.tsx b/web/src/components/catalog/CatalogFilterBar.tsx index 5f664b72..703f9eb0 100644 --- a/web/src/components/catalog/CatalogFilterBar.tsx +++ b/web/src/components/catalog/CatalogFilterBar.tsx @@ -53,7 +53,9 @@ export default function CatalogFilterBar({ { - const sortOption = getQuerySortOptions({ includePersonalized: allowPersonalizedSorts }) - .find((opt) => opt.value === v); + const sortOption = getQuerySortOptions({ + includePersonalized: allowPersonalizedSorts, + }).find((opt) => opt.value === v); const patch: Partial = { sortField: v, sortOrder: getDefaultQuerySortOrder(v), }; if (showMediaScopeSelector && sortOption) { + const scopeTypes: Array> | null = + state.mediaScope === "all" + ? null + : state.mediaScope === "video" + ? ["movie", "series"] + : [state.mediaScope]; const currentApplicable = - state.mediaScope === "all" || - sortOption.applicableMediaScopes.includes( - state.mediaScope as Exclude, - ); - 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"]; diff --git a/web/src/components/catalog/SearchScopeChips.tsx b/web/src/components/catalog/SearchScopeChips.tsx new file mode 100644 index 00000000..7337b488 --- /dev/null +++ b/web/src/components/catalog/SearchScopeChips.tsx @@ -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 ( +
+ {SCOPE_OPTIONS.map((option) => { + const isActive = option.value === activeScope; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/components/collections/CollectionGuidedRulesEditor.tsx b/web/src/components/collections/CollectionGuidedRulesEditor.tsx index fb907b06..6d5a22ef 100644 --- a/web/src/components/collections/CollectionGuidedRulesEditor.tsx +++ b/web/src/components/collections/CollectionGuidedRulesEditor.tsx @@ -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({