feat: update media filter to support multiple series names and enhance library item fetching
This commit is contained in:
@@ -38,7 +38,7 @@ const mediaFilterSchema = z.object({
|
||||
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||||
max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||||
collections: z.array(z.string()),
|
||||
series_name: z.string().nullable().optional(),
|
||||
series_names: z.array(z.string()),
|
||||
search_term: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ function defaultFilter(): MediaFilter {
|
||||
min_duration_secs: null,
|
||||
max_duration_secs: null,
|
||||
collections: [],
|
||||
series_name: null,
|
||||
series_names: [],
|
||||
search_term: null,
|
||||
};
|
||||
}
|
||||
@@ -252,8 +252,8 @@ function AlgorithmicFilterEditor({
|
||||
onChange={(v) =>
|
||||
setFilter({
|
||||
content_type: v === "" ? null : (v as ContentType),
|
||||
// clear series name if switching away from episode
|
||||
series_name: v !== "episode" ? null : content.filter.series_name,
|
||||
// clear series names if switching away from episode
|
||||
series_names: v !== "episode" ? [] : content.filter.series_names,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -282,12 +282,12 @@ function AlgorithmicFilterEditor({
|
||||
hint={
|
||||
content.strategy === "sequential"
|
||||
? "Episodes will play in chronological order"
|
||||
: "Filter to one show, or leave empty for all"
|
||||
: "Filter to specific shows, or leave empty for all"
|
||||
}
|
||||
>
|
||||
<SeriesPicker
|
||||
value={content.filter.series_name ?? null}
|
||||
onChange={(v) => setFilter({ series_name: v })}
|
||||
values={content.filter.series_names ?? []}
|
||||
onChange={(v) => setFilter({ series_names: v })}
|
||||
series={series ?? []}
|
||||
isLoading={loadingSeries}
|
||||
/>
|
||||
|
||||
@@ -5,92 +5,100 @@ import { X } from "lucide-react";
|
||||
import type { SeriesResponse } from "@/lib/types";
|
||||
|
||||
interface SeriesPickerProps {
|
||||
value: string | null;
|
||||
onChange: (v: string | null) => void;
|
||||
values: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
series: SeriesResponse[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SeriesPicker({ value, onChange, series, isLoading }: SeriesPickerProps) {
|
||||
export function SeriesPicker({ values, onChange, series, isLoading }: SeriesPickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = search.trim()
|
||||
? series.filter((s) => s.name.toLowerCase().includes(search.toLowerCase())).slice(0, 40)
|
||||
: series.slice(0, 40);
|
||||
const filtered = series
|
||||
.filter((s) => !values.includes(s.name))
|
||||
.filter((s) => !search.trim() || s.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.slice(0, 40);
|
||||
|
||||
const handleSelect = (name: string) => {
|
||||
onChange(name);
|
||||
onChange([...values, name]);
|
||||
setSearch("");
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
const handleRemove = (name: string) => {
|
||||
onChange(values.filter((v) => v !== name));
|
||||
};
|
||||
|
||||
// Delay blur so clicks inside the dropdown register before closing
|
||||
const handleBlur = () => setTimeout(() => setOpen(false), 150);
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2">
|
||||
<span className="flex-1 truncate text-sm text-zinc-100">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="shrink-0 text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Clear series"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={isLoading ? "Loading series…" : "Search series…"}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{open && filtered.length > 0 && (
|
||||
<ul className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 py-1 shadow-xl">
|
||||
{filtered.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div className="space-y-1.5">
|
||||
{/* Selected chips */}
|
||||
{values.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{values.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="flex items-center gap-1 rounded-full border border-zinc-600 bg-zinc-700/60 px-2.5 py-0.5 text-xs text-zinc-200"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => handleSelect(s.name)}
|
||||
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-zinc-800"
|
||||
onClick={() => handleRemove(name)}
|
||||
className="ml-0.5 text-zinc-500 hover:text-zinc-300"
|
||||
aria-label={`Remove ${name}`}
|
||||
>
|
||||
<span className="truncate text-sm text-zinc-100">{s.name}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
|
||||
{s.episode_count} ep{s.episode_count !== 1 ? "s" : ""}
|
||||
{s.year ? ` · ${s.year}` : ""}
|
||||
</span>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
</span>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{open && !isLoading && series.length === 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-500 shadow-xl">
|
||||
No series found in library.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={isLoading ? "Loading series…" : values.length === 0 ? "Search series…" : "Add another series…"}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{open && filtered.length > 0 && (
|
||||
<ul className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 py-1 shadow-xl">
|
||||
{filtered.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={() => handleSelect(s.name)}
|
||||
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-zinc-800"
|
||||
>
|
||||
<span className="truncate text-sm text-zinc-100">{s.name}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
|
||||
{s.episode_count} ep{s.episode_count !== 1 ? "s" : ""}
|
||||
{s.year ? ` · ${s.year}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{open && !isLoading && series.length === 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-500 shadow-xl">
|
||||
No series found in library.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useGenres(contentType?: string) {
|
||||
* Pass `enabled: false` until the user explicitly requests a preview.
|
||||
*/
|
||||
export function useLibraryItems(
|
||||
filter: Pick<MediaFilter, "content_type" | "series_name" | "collections" | "search_term" | "genres"> | null,
|
||||
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres"> | null,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const { token } = useAuthContext();
|
||||
|
||||
@@ -127,13 +127,13 @@ export const api = {
|
||||
|
||||
items: (
|
||||
token: string,
|
||||
filter: Pick<MediaFilter, "content_type" | "series_name" | "collections" | "search_term" | "genres">,
|
||||
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">,
|
||||
limit = 50,
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.search_term) params.set("q", filter.search_term);
|
||||
if (filter.content_type) params.set("type", filter.content_type);
|
||||
if (filter.series_name) params.set("series", filter.series_name);
|
||||
filter.series_names?.forEach((name) => params.append("series[]", name));
|
||||
if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
|
||||
params.set("limit", String(limit));
|
||||
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface MediaFilter {
|
||||
min_duration_secs?: number | null;
|
||||
max_duration_secs?: number | null;
|
||||
collections: string[];
|
||||
/** Filter by TV series name, e.g. "iCarly". Use with Sequential strategy. */
|
||||
series_name?: string | null;
|
||||
/** Filter to one or more TV series by name. OR-combined: any listed show is eligible. */
|
||||
series_names?: string[];
|
||||
/** Free-text search, used for library browsing only. */
|
||||
search_term?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user