From f028b1be984f316e675aa8b40e690140cc4bc931 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 12 Mar 2026 03:12:59 +0100 Subject: [PATCH] feat: update media filter to support multiple series names and enhance library item fetching --- k-tv-backend/Cargo.lock | 14 +- k-tv-backend/api/Cargo.toml | 1 + k-tv-backend/api/src/routes/library.rs | 21 ++- k-tv-backend/domain/src/value_objects.rs | 8 +- k-tv-backend/infra/src/jellyfin.rs | 70 ++++++++-- .../components/edit-channel-sheet.tsx | 14 +- .../dashboard/components/series-picker.tsx | 128 ++++++++++-------- k-tv-frontend/hooks/use-library.ts | 2 +- k-tv-frontend/lib/api.ts | 4 +- k-tv-frontend/lib/types.ts | 4 +- 10 files changed, 173 insertions(+), 93 deletions(-) diff --git a/k-tv-backend/Cargo.lock b/k-tv-backend/Cargo.lock index c3f055d..d1e45b3 100644 --- a/k-tv-backend/Cargo.lock +++ b/k-tv-backend/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ "k-core", "serde", "serde_json", + "serde_qs", "thiserror 2.0.17", "time", "tokio", @@ -1727,7 +1728,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.16", "http", @@ -2630,6 +2631,17 @@ dependencies = [ "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]] name = "serde_repr" version = "0.1.20" diff --git a/k-tv-backend/api/Cargo.toml b/k-tv-backend/api/Cargo.toml index 9e48b53..52f8595 100644 --- a/k-tv-backend/api/Cargo.toml +++ b/k-tv-backend/api/Cargo.toml @@ -34,6 +34,7 @@ tokio = { version = "1.48.0", features = ["full"] } # Serialization serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0" +serde_qs = "0.13" # Error handling thiserror = "2.0.17" diff --git a/k-tv-backend/api/src/routes/library.rs b/k-tv-backend/api/src/routes/library.rs index 1b10c49..fa0ce69 100644 --- a/k-tv-backend/api/src/routes/library.rs +++ b/k-tv-backend/api/src/routes/library.rs @@ -11,7 +11,7 @@ use axum::{ Json, Router, - extract::{Query, State}, + extract::{Query, RawQuery, State}, routing::get, }; use serde::{Deserialize, Serialize}; @@ -106,15 +106,17 @@ struct GenresQuery { content_type: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] struct ItemsQuery { /// Free-text search. q: Option, /// Content type filter: "movie", "episode", or "short". #[serde(rename = "type")] content_type: Option, - /// Filter episodes to a specific series name. - series: Option, + /// Filter episodes by series name. Repeat the param for multiple series: + /// `?series=iCarly&series=Victorious` + #[serde(default)] + series: Vec, /// Scope to a provider collection ID. collection: Option, /// Maximum number of results (default: 50, max: 200). @@ -163,14 +165,21 @@ async fn list_genres( async fn search_items( State(state): State, CurrentUser(_user): CurrentUser, - Query(params): Query, + RawQuery(raw_query): RawQuery, ) -> Result>, 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::(q)) + .transpose() + .map_err(|e| ApiError::validation(e.to_string()))? + .unwrap_or_default(); let limit = params.limit.unwrap_or(50).min(200); let filter = MediaFilter { content_type: parse_content_type(params.content_type.as_deref())?, search_term: params.q, - series_name: params.series, + series_names: params.series, collections: params .collection .map(|c| vec![c]) diff --git a/k-tv-backend/domain/src/value_objects.rs b/k-tv-backend/domain/src/value_objects.rs index f3d3afe..19e806d 100644 --- a/k-tv-backend/domain/src/value_objects.rs +++ b/k-tv-backend/domain/src/value_objects.rs @@ -606,9 +606,11 @@ pub struct MediaFilter { /// Abstract groupings interpreted by each provider (Jellyfin library, Plex section, /// filesystem path, etc.). An empty list means "all available content". pub collections: Vec, - /// Filter by TV series name. Use with `content_type: Episode` and - /// `strategy: Sequential` for ordered series playback (e.g. "iCarly"). - pub series_name: Option, + /// Filter to one or more TV series by name. Use with `content_type: Episode`. + /// With `Sequential` strategy each series plays in chronological order. + /// Multiple series are OR-combined: any episode from any listed show is eligible. + #[serde(default)] + pub series_names: Vec, /// Free-text search term. Intended for library browsing; typically omitted /// during schedule generation. pub search_term: Option, diff --git a/k-tv-backend/infra/src/jellyfin.rs b/k-tv-backend/infra/src/jellyfin.rs index a1f5ec6..e149719 100644 --- a/k-tv-backend/infra/src/jellyfin.rs +++ b/k-tv-backend/infra/src/jellyfin.rs @@ -50,14 +50,13 @@ impl JellyfinMediaProvider { } } -#[async_trait] -impl IMediaProvider for JellyfinMediaProvider { - /// Fetch items matching `filter` from the Jellyfin library. - /// - /// `MediaFilter.collections` maps to Jellyfin `ParentId` (library/folder UUID). - /// Multiple collections are not supported in a single call; the first entry wins. - /// Decades are mapped to Jellyfin's `MinYear`/`MaxYear`. - async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult> { +impl JellyfinMediaProvider { + /// Inner fetch: applies all filter fields plus an optional series name override. + async fn fetch_items_for_series( + &self, + filter: &MediaFilter, + series_name: Option<&str>, + ) -> DomainResult> { let url = format!( "{}/Users/{}/Items", self.config.base_url, self.config.user_id @@ -97,8 +96,12 @@ impl IMediaProvider for JellyfinMediaProvider { params.push(("ParentId", parent_id.clone())); } - if let Some(series_name) = &filter.series_name { - params.push(("SeriesName", series_name.clone())); + if let Some(name) = series_name { + 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 { @@ -127,7 +130,52 @@ impl IMediaProvider for JellyfinMediaProvider { 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 = 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> { + 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. diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index 9cab003..84fbcaa 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -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" } > setFilter({ series_name: v })} + values={content.filter.series_names ?? []} + onChange={(v) => setFilter({ series_names: v })} series={series ?? []} isLoading={loadingSeries} /> diff --git a/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx b/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx index d4b8dfa..32b57e6 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx @@ -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(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 ( -
- {value} - -
- ); - } - return ( -
- { 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 && ( -
    - {filtered.map((s) => ( -
  • +
    + {/* Selected chips */} + {values.length > 0 && ( +
    + {values.map((name) => ( + + {name} -
  • + ))} -
- )} - - {open && !isLoading && series.length === 0 && ( -
- No series found in library.
)} + + {/* Search input */} +
+ { 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 && ( +
    + {filtered.map((s) => ( +
  • + +
  • + ))} +
+ )} + + {open && !isLoading && series.length === 0 && ( +
+ No series found in library. +
+ )} +
); } diff --git a/k-tv-frontend/hooks/use-library.ts b/k-tv-frontend/hooks/use-library.ts index d25bc2c..b9bf860 100644 --- a/k-tv-frontend/hooks/use-library.ts +++ b/k-tv-frontend/hooks/use-library.ts @@ -49,7 +49,7 @@ export function useGenres(contentType?: string) { * Pass `enabled: false` until the user explicitly requests a preview. */ export function useLibraryItems( - filter: Pick | null, + filter: Pick | null, enabled: boolean, ) { const { token } = useAuthContext(); diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts index fdd5ec5..edf5316 100644 --- a/k-tv-frontend/lib/api.ts +++ b/k-tv-frontend/lib/api.ts @@ -127,13 +127,13 @@ export const api = { items: ( token: string, - filter: Pick, + filter: Pick, 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(`/library/items?${params}`, { token }); diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index 342578b..cc19d55 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -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; }