feat: update media filter to support multiple series names and enhance library item fetching

This commit is contained in:
2026-03-12 03:12:59 +01:00
parent bf07a65dcd
commit f028b1be98
10 changed files with 173 additions and 93 deletions

View File

@@ -82,6 +82,7 @@ dependencies = [
"k-core", "k-core",
"serde", "serde",
"serde_json", "serde_json",
"serde_qs",
"thiserror 2.0.17", "thiserror 2.0.17",
"time", "time",
"tokio", "tokio",
@@ -1727,7 +1728,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.22.1",
"chrono", "chrono",
"getrandom 0.2.16", "getrandom 0.2.16",
"http", "http",
@@ -2630,6 +2631,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_qs"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6"
dependencies = [
"percent-encoding",
"serde",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.20" version = "0.1.20"

View File

@@ -34,6 +34,7 @@ tokio = { version = "1.48.0", features = ["full"] }
# Serialization # Serialization
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_qs = "0.13"
# Error handling # Error handling
thiserror = "2.0.17" thiserror = "2.0.17"

View File

@@ -11,7 +11,7 @@
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Query, State}, extract::{Query, RawQuery, State},
routing::get, routing::get,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -106,15 +106,17 @@ struct GenresQuery {
content_type: Option<String>, content_type: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ItemsQuery { struct ItemsQuery {
/// Free-text search. /// Free-text search.
q: Option<String>, q: Option<String>,
/// Content type filter: "movie", "episode", or "short". /// Content type filter: "movie", "episode", or "short".
#[serde(rename = "type")] #[serde(rename = "type")]
content_type: Option<String>, content_type: Option<String>,
/// Filter episodes to a specific series name. /// Filter episodes by series name. Repeat the param for multiple series:
series: Option<String>, /// `?series=iCarly&series=Victorious`
#[serde(default)]
series: Vec<String>,
/// Scope to a provider collection ID. /// Scope to a provider collection ID.
collection: Option<String>, collection: Option<String>,
/// Maximum number of results (default: 50, max: 200). /// Maximum number of results (default: 50, max: 200).
@@ -163,14 +165,21 @@ async fn list_genres(
async fn search_items( async fn search_items(
State(state): State<AppState>, State(state): State<AppState>,
CurrentUser(_user): CurrentUser, CurrentUser(_user): CurrentUser,
Query(params): Query<ItemsQuery>, RawQuery(raw_query): RawQuery,
) -> Result<Json<Vec<LibraryItemResponse>>, ApiError> { ) -> Result<Json<Vec<LibraryItemResponse>>, ApiError> {
let qs_config = serde_qs::Config::new(2, false); // non-strict: accept encoded brackets
let params: ItemsQuery = raw_query
.as_deref()
.map(|q| qs_config.deserialize_str::<ItemsQuery>(q))
.transpose()
.map_err(|e| ApiError::validation(e.to_string()))?
.unwrap_or_default();
let limit = params.limit.unwrap_or(50).min(200); let limit = params.limit.unwrap_or(50).min(200);
let filter = MediaFilter { let filter = MediaFilter {
content_type: parse_content_type(params.content_type.as_deref())?, content_type: parse_content_type(params.content_type.as_deref())?,
search_term: params.q, search_term: params.q,
series_name: params.series, series_names: params.series,
collections: params collections: params
.collection .collection
.map(|c| vec![c]) .map(|c| vec![c])

View File

@@ -606,9 +606,11 @@ pub struct MediaFilter {
/// Abstract groupings interpreted by each provider (Jellyfin library, Plex section, /// Abstract groupings interpreted by each provider (Jellyfin library, Plex section,
/// filesystem path, etc.). An empty list means "all available content". /// filesystem path, etc.). An empty list means "all available content".
pub collections: Vec<String>, pub collections: Vec<String>,
/// Filter by TV series name. Use with `content_type: Episode` and /// Filter to one or more TV series by name. Use with `content_type: Episode`.
/// `strategy: Sequential` for ordered series playback (e.g. "iCarly"). /// With `Sequential` strategy each series plays in chronological order.
pub series_name: Option<String>, /// Multiple series are OR-combined: any episode from any listed show is eligible.
#[serde(default)]
pub series_names: Vec<String>,
/// Free-text search term. Intended for library browsing; typically omitted /// Free-text search term. Intended for library browsing; typically omitted
/// during schedule generation. /// during schedule generation.
pub search_term: Option<String>, pub search_term: Option<String>,

View File

@@ -50,14 +50,13 @@ impl JellyfinMediaProvider {
} }
} }
#[async_trait] impl JellyfinMediaProvider {
impl IMediaProvider for JellyfinMediaProvider { /// Inner fetch: applies all filter fields plus an optional series name override.
/// Fetch items matching `filter` from the Jellyfin library. async fn fetch_items_for_series(
/// &self,
/// `MediaFilter.collections` maps to Jellyfin `ParentId` (library/folder UUID). filter: &MediaFilter,
/// Multiple collections are not supported in a single call; the first entry wins. series_name: Option<&str>,
/// Decades are mapped to Jellyfin's `MinYear`/`MaxYear`. ) -> DomainResult<Vec<MediaItem>> {
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
let url = format!( let url = format!(
"{}/Users/{}/Items", "{}/Users/{}/Items",
self.config.base_url, self.config.user_id self.config.base_url, self.config.user_id
@@ -97,8 +96,12 @@ impl IMediaProvider for JellyfinMediaProvider {
params.push(("ParentId", parent_id.clone())); params.push(("ParentId", parent_id.clone()));
} }
if let Some(series_name) = &filter.series_name { if let Some(name) = series_name {
params.push(("SeriesName", series_name.clone())); params.push(("SeriesName", name.to_string()));
// Return episodes in chronological order when a specific series is
// requested — season first, then episode within the season.
params.push(("SortBy", "ParentIndexNumber,IndexNumber".into()));
params.push(("SortOrder", "Ascending".into()));
} }
if let Some(q) = &filter.search_term { if let Some(q) = &filter.search_term {
@@ -127,7 +130,52 @@ impl IMediaProvider for JellyfinMediaProvider {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {}", e)) DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {}", e))
})?; })?;
Ok(body.items.into_iter().filter_map(map_jellyfin_item).collect()) // Jellyfin's SeriesName query param is not a strict filter — it can
// bleed items from other shows. Post-filter in Rust to guarantee that
// only the requested series is returned.
let items = body.items.into_iter().filter_map(map_jellyfin_item);
let items: Vec<MediaItem> = if let Some(name) = series_name {
items
.filter(|item| {
item.series_name
.as_deref()
.map(|s| s.eq_ignore_ascii_case(name))
.unwrap_or(false)
})
.collect()
} else {
items.collect()
};
Ok(items)
}
}
#[async_trait]
impl IMediaProvider for JellyfinMediaProvider {
/// Fetch items matching `filter` from the Jellyfin library.
///
/// When `series_names` has more than one entry the results from each series
/// are fetched sequentially and concatenated (Jellyfin only supports one
/// `SeriesName` param per request).
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
match filter.series_names.len() {
0 | 1 => {
let series = filter.series_names.first().map(String::as_str);
self.fetch_items_for_series(filter, series).await
}
_ => {
let mut all = Vec::new();
for series_name in &filter.series_names {
let items = self
.fetch_items_for_series(filter, Some(series_name.as_str()))
.await?;
all.extend(items);
}
Ok(all)
}
}
} }
/// Fetch a single item by its opaque ID. /// Fetch a single item by its opaque ID.

View File

@@ -38,7 +38,7 @@ const mediaFilterSchema = z.object({
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(), min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
max_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()), collections: z.array(z.string()),
series_name: z.string().nullable().optional(), series_names: z.array(z.string()),
search_term: z.string().nullable().optional(), search_term: z.string().nullable().optional(),
}); });
@@ -199,7 +199,7 @@ function defaultFilter(): MediaFilter {
min_duration_secs: null, min_duration_secs: null,
max_duration_secs: null, max_duration_secs: null,
collections: [], collections: [],
series_name: null, series_names: [],
search_term: null, search_term: null,
}; };
} }
@@ -252,8 +252,8 @@ function AlgorithmicFilterEditor({
onChange={(v) => onChange={(v) =>
setFilter({ setFilter({
content_type: v === "" ? null : (v as ContentType), content_type: v === "" ? null : (v as ContentType),
// clear series name if switching away from episode // clear series names if switching away from episode
series_name: v !== "episode" ? null : content.filter.series_name, series_names: v !== "episode" ? [] : content.filter.series_names,
}) })
} }
> >
@@ -282,12 +282,12 @@ function AlgorithmicFilterEditor({
hint={ hint={
content.strategy === "sequential" content.strategy === "sequential"
? "Episodes will play in chronological order" ? "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 <SeriesPicker
value={content.filter.series_name ?? null} values={content.filter.series_names ?? []}
onChange={(v) => setFilter({ series_name: v })} onChange={(v) => setFilter({ series_names: v })}
series={series ?? []} series={series ?? []}
isLoading={loadingSeries} isLoading={loadingSeries}
/> />

View File

@@ -5,92 +5,100 @@ import { X } from "lucide-react";
import type { SeriesResponse } from "@/lib/types"; import type { SeriesResponse } from "@/lib/types";
interface SeriesPickerProps { interface SeriesPickerProps {
value: string | null; values: string[];
onChange: (v: string | null) => void; onChange: (v: string[]) => void;
series: SeriesResponse[]; series: SeriesResponse[];
isLoading?: boolean; isLoading?: boolean;
} }
export function SeriesPicker({ value, onChange, series, isLoading }: SeriesPickerProps) { export function SeriesPicker({ values, onChange, series, isLoading }: SeriesPickerProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const filtered = search.trim() const filtered = series
? series.filter((s) => s.name.toLowerCase().includes(search.toLowerCase())).slice(0, 40) .filter((s) => !values.includes(s.name))
: series.slice(0, 40); .filter((s) => !search.trim() || s.name.toLowerCase().includes(search.toLowerCase()))
.slice(0, 40);
const handleSelect = (name: string) => { const handleSelect = (name: string) => {
onChange(name); onChange([...values, name]);
setSearch(""); setSearch("");
setOpen(false); inputRef.current?.focus();
}; };
const handleClear = () => { const handleRemove = (name: string) => {
onChange(null); onChange(values.filter((v) => v !== name));
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}; };
// Delay blur so clicks inside the dropdown register before closing // Delay blur so clicks inside the dropdown register before closing
const handleBlur = () => setTimeout(() => setOpen(false), 150); 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 ( return (
<div className="relative"> <div className="space-y-1.5">
<input {/* Selected chips */}
ref={inputRef} {values.length > 0 && (
type="text" <div className="flex flex-wrap gap-1.5">
value={search} {values.map((name) => (
placeholder={isLoading ? "Loading series…" : "Search series…"} <span
disabled={isLoading} key={name}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }} 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"
onFocus={() => setOpen(true)} >
onBlur={handleBlur} {name}
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 <button
type="button" type="button"
onMouseDown={() => handleSelect(s.name)} onClick={() => handleRemove(name)}
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-zinc-800" 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> <X className="size-3" />
<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> </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> </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> </div>
); );
} }

View File

@@ -49,7 +49,7 @@ export function useGenres(contentType?: string) {
* Pass `enabled: false` until the user explicitly requests a preview. * Pass `enabled: false` until the user explicitly requests a preview.
*/ */
export function useLibraryItems( 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, enabled: boolean,
) { ) {
const { token } = useAuthContext(); const { token } = useAuthContext();

View File

@@ -127,13 +127,13 @@ export const api = {
items: ( items: (
token: string, 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, limit = 50,
) => { ) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filter.search_term) params.set("q", filter.search_term); if (filter.search_term) params.set("q", filter.search_term);
if (filter.content_type) params.set("type", filter.content_type); 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]); if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
params.set("limit", String(limit)); params.set("limit", String(limit));
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token }); return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });

View File

@@ -12,8 +12,8 @@ export interface MediaFilter {
min_duration_secs?: number | null; min_duration_secs?: number | null;
max_duration_secs?: number | null; max_duration_secs?: number | null;
collections: string[]; collections: string[];
/** Filter by TV series name, e.g. "iCarly". Use with Sequential strategy. */ /** Filter to one or more TV series by name. OR-combined: any listed show is eligible. */
series_name?: string | null; series_names?: string[];
/** Free-text search, used for library browsing only. */ /** Free-text search, used for library browsing only. */
search_term?: string | null; search_term?: string | null;
} }