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
+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>