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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user