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:
2026-03-12 02:54:30 +01:00
parent f069376136
commit bf07a65dcd
14 changed files with 1005 additions and 86 deletions

View File

@@ -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" && (

View File

@@ -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>
);
}

View File

@@ -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>
);
}