feat(library): add media library browsing functionality
- Introduced new `library` module in the API routes to handle media library requests. - Enhanced `AppState` to include a media provider for library interactions. - Defined new `IMediaProvider` trait methods for listing collections, series, and genres. - Implemented Jellyfin media provider methods for fetching collections and series. - Added frontend components for selecting series and displaying filter previews. - Created hooks for fetching collections, series, and genres from the library. - Updated media filter to support series name and search term. - Enhanced API client to handle new library-related endpoints.
This commit is contained in:
@@ -7,6 +7,9 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TagInput } from "./tag-input";
|
||||
import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline";
|
||||
import { SeriesPicker } from "./series-picker";
|
||||
import { FilterPreview } from "./filter-preview";
|
||||
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||||
import type {
|
||||
ChannelResponse,
|
||||
ProgrammingBlock,
|
||||
@@ -35,6 +38,8 @@ 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(),
|
||||
search_term: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const blockSchema = z.object({
|
||||
@@ -194,6 +199,8 @@ function defaultFilter(): MediaFilter {
|
||||
min_duration_secs: null,
|
||||
max_duration_secs: null,
|
||||
collections: [],
|
||||
series_name: null,
|
||||
search_term: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +214,200 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AlgorithmicFilterEditor — filter section with live library data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AlgorithmicFilterEditorProps {
|
||||
content: Extract<BlockContent, { type: "algorithmic" }>;
|
||||
pfx: string;
|
||||
errors: FieldErrors;
|
||||
setFilter: (patch: Partial<MediaFilter>) => void;
|
||||
setStrategy: (strategy: FillStrategy) => void;
|
||||
}
|
||||
|
||||
function AlgorithmicFilterEditor({
|
||||
content,
|
||||
pfx,
|
||||
errors,
|
||||
setFilter,
|
||||
setStrategy,
|
||||
}: AlgorithmicFilterEditorProps) {
|
||||
const [showGenres, setShowGenres] = useState(false);
|
||||
|
||||
const { data: collections, isLoading: loadingCollections } = useCollections();
|
||||
const { data: series, isLoading: loadingSeries } = useSeries();
|
||||
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined);
|
||||
|
||||
const isEpisode = content.filter.content_type === "episode";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Media type">
|
||||
<NativeSelect
|
||||
value={content.filter.content_type ?? ""}
|
||||
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,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="movie">Movie</option>
|
||||
<option value="episode">Episode</option>
|
||||
<option value="short">Short</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field label="Strategy">
|
||||
<NativeSelect
|
||||
value={content.strategy}
|
||||
onChange={(v) => setStrategy(v as FillStrategy)}
|
||||
>
|
||||
<option value="random">Random</option>
|
||||
<option value="best_fit">Best fit</option>
|
||||
<option value="sequential">Sequential</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Series — only meaningful for episodes */}
|
||||
{isEpisode && (
|
||||
<Field
|
||||
label="Series"
|
||||
hint={
|
||||
content.strategy === "sequential"
|
||||
? "Episodes will play in chronological order"
|
||||
: "Filter to one show, or leave empty for all"
|
||||
}
|
||||
>
|
||||
<SeriesPicker
|
||||
value={content.filter.series_name ?? null}
|
||||
onChange={(v) => setFilter({ series_name: v })}
|
||||
series={series ?? []}
|
||||
isLoading={loadingSeries}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Library — real collection names when the provider supports it */}
|
||||
<Field
|
||||
label="Library"
|
||||
hint={
|
||||
loadingCollections
|
||||
? "Loading libraries…"
|
||||
: collections
|
||||
? "Scope this block to one library"
|
||||
: "Enter a provider library ID"
|
||||
}
|
||||
>
|
||||
{collections && collections.length > 0 ? (
|
||||
<NativeSelect
|
||||
value={content.filter.collections[0] ?? ""}
|
||||
onChange={(v) => setFilter({ collections: v ? [v] : [] })}
|
||||
>
|
||||
<option value="">All libraries</option>
|
||||
{collections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
{c.collection_type ? ` (${c.collection_type})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
) : (
|
||||
<TagInput
|
||||
values={content.filter.collections}
|
||||
onChange={(v) => setFilter({ collections: v })}
|
||||
placeholder="Library ID…"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Genres with browse-from-library shortcut */}
|
||||
<Field label="Genres" hint="Press Enter or comma to add">
|
||||
<TagInput
|
||||
values={content.filter.genres}
|
||||
onChange={(v) => setFilter({ genres: v })}
|
||||
placeholder="Comedy, Animation…"
|
||||
/>
|
||||
{genreOptions && genreOptions.length > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGenres((s) => !s)}
|
||||
className="text-[11px] text-zinc-600 hover:text-zinc-400"
|
||||
>
|
||||
{showGenres ? "Hide" : "Browse"} available genres
|
||||
</button>
|
||||
{showGenres && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{genreOptions
|
||||
.filter((g) => !content.filter.genres.includes(g))
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => setFilter({ genres: [...content.filter.genres, g] })}
|
||||
className="rounded px-1.5 py-0.5 text-[11px] bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||
>
|
||||
+ {g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Tags" hint="Press Enter or comma to add">
|
||||
<TagInput
|
||||
values={content.filter.tags}
|
||||
onChange={(v) => setFilter({ tags: v })}
|
||||
placeholder="classic, family…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="Decade" hint="e.g. 1990" error={errors[`${pfx}.content.filter.decade`]}>
|
||||
<NumberInput
|
||||
value={content.filter.decade ?? ""}
|
||||
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
|
||||
placeholder="1990"
|
||||
error={!!errors[`${pfx}.content.filter.decade`]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
||||
<NumberInput
|
||||
value={content.filter.min_duration_secs ?? ""}
|
||||
onChange={(v) =>
|
||||
setFilter({ min_duration_secs: v === "" ? null : (v as number) })
|
||||
}
|
||||
placeholder="1200"
|
||||
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
||||
<NumberInput
|
||||
value={content.filter.max_duration_secs ?? ""}
|
||||
onChange={(v) =>
|
||||
setFilter({ max_duration_secs: v === "" ? null : (v as number) })
|
||||
}
|
||||
placeholder="3600"
|
||||
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Preview — snapshot of current filter, only fetches on explicit click */}
|
||||
<FilterPreview filter={content.filter} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlockEditor (detail form for a single block)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,84 +534,13 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
||||
</div>
|
||||
|
||||
{content.type === "algorithmic" && (
|
||||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Media type">
|
||||
<NativeSelect
|
||||
value={content.filter.content_type ?? ""}
|
||||
onChange={(v) => setFilter({ content_type: v === "" ? null : (v as ContentType) })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="movie">Movie</option>
|
||||
<option value="episode">Episode</option>
|
||||
<option value="short">Short</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field label="Strategy">
|
||||
<NativeSelect
|
||||
value={content.strategy}
|
||||
onChange={(v) => setStrategy(v as FillStrategy)}
|
||||
>
|
||||
<option value="random">Random</option>
|
||||
<option value="best_fit">Best fit</option>
|
||||
<option value="sequential">Sequential</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Genres" hint="Press Enter or comma to add">
|
||||
<TagInput
|
||||
values={content.filter.genres}
|
||||
onChange={(v) => setFilter({ genres: v })}
|
||||
placeholder="Comedy, Sci-Fi…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tags" hint="Press Enter or comma to add">
|
||||
<TagInput
|
||||
values={content.filter.tags}
|
||||
onChange={(v) => setFilter({ tags: v })}
|
||||
placeholder="classic, family…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="Decade" hint="e.g. 1990" error={errors[`${pfx}.content.filter.decade`]}>
|
||||
<NumberInput
|
||||
value={content.filter.decade ?? ""}
|
||||
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
|
||||
placeholder="1990"
|
||||
error={!!errors[`${pfx}.content.filter.decade`]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
||||
<NumberInput
|
||||
value={content.filter.min_duration_secs ?? ""}
|
||||
onChange={(v) => setFilter({ min_duration_secs: v === "" ? null : (v as number) })}
|
||||
placeholder="1200"
|
||||
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
||||
<NumberInput
|
||||
value={content.filter.max_duration_secs ?? ""}
|
||||
onChange={(v) => setFilter({ max_duration_secs: v === "" ? null : (v as number) })}
|
||||
placeholder="3600"
|
||||
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Collections" hint="Jellyfin library IDs">
|
||||
<TagInput
|
||||
values={content.filter.collections}
|
||||
onChange={(v) => setFilter({ collections: v })}
|
||||
placeholder="abc123…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<AlgorithmicFilterEditor
|
||||
content={content}
|
||||
pfx={pfx}
|
||||
errors={errors}
|
||||
setFilter={setFilter}
|
||||
setStrategy={setStrategy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{content.type === "manual" && (
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useLibraryItems } from "@/hooks/use-library";
|
||||
import type { MediaFilter, LibraryItemResponse } from "@/lib/types";
|
||||
|
||||
interface FilterPreviewProps {
|
||||
filter: MediaFilter;
|
||||
}
|
||||
|
||||
function fmtDuration(secs: number): string {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function ItemRow({ item }: { item: LibraryItemResponse }) {
|
||||
const label =
|
||||
item.content_type === "episode" && item.series_name
|
||||
? `${item.series_name}${item.season_number != null ? ` S${item.season_number}` : ""}${item.episode_number != null ? `E${String(item.episode_number).padStart(2, "0")}` : ""} – ${item.title}`
|
||||
: item.title;
|
||||
|
||||
return (
|
||||
<li className="flex items-baseline justify-between gap-3 py-1.5">
|
||||
<span className="truncate text-xs text-zinc-300">{label}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
|
||||
{fmtDuration(item.duration_secs)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterPreview({ filter }: FilterPreviewProps) {
|
||||
// Snapshot of filter at the moment "Preview" was clicked, so edits to the
|
||||
// filter don't silently re-fetch while the user is still configuring.
|
||||
const [snapshot, setSnapshot] = useState<MediaFilter | null>(null);
|
||||
|
||||
const { data: items, isFetching, isError } = useLibraryItems(snapshot, !!snapshot);
|
||||
|
||||
const handlePreview = () => setSnapshot({ ...filter });
|
||||
|
||||
const filterChanged =
|
||||
snapshot !== null && JSON.stringify(snapshot) !== JSON.stringify(filter);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-800/60 px-2.5 py-1 text-[11px] text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
|
||||
>
|
||||
{snapshot ? "Refresh preview" : "Preview results"}
|
||||
</button>
|
||||
{filterChanged && (
|
||||
<span className="text-[11px] text-amber-500">Filter changed</span>
|
||||
)}
|
||||
{isFetching && <Loader2 className="size-3 animate-spin text-zinc-600" />}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-[11px] text-red-400">Failed to load preview.</p>
|
||||
)}
|
||||
|
||||
{items && !isFetching && (
|
||||
<div className="rounded-md border border-zinc-700/60 bg-zinc-800/30 px-3 py-1">
|
||||
<p className="pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wider text-zinc-600">
|
||||
{items.length === 30 ? "First 30 matches" : `${items.length} match${items.length !== 1 ? "es" : ""}`}
|
||||
</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="py-1.5 text-xs text-zinc-600">No items match this filter.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((item) => (
|
||||
<ItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { SeriesResponse } from "@/lib/types";
|
||||
|
||||
interface SeriesPickerProps {
|
||||
value: string | null;
|
||||
onChange: (v: string | null) => void;
|
||||
series: SeriesResponse[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SeriesPicker({ value, 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 handleSelect = (name: string) => {
|
||||
onChange(name);
|
||||
setSearch("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// 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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user