feat: update media filter to support multiple series names and enhance library item fetching
This commit is contained in:
14
k-tv-backend/Cargo.lock
generated
14
k-tv-backend/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,59 +5,66 @@ 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 (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2">
|
<div className="space-y-1.5">
|
||||||
<span className="flex-1 truncate text-sm text-zinc-100">{value}</span>
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={() => handleRemove(name)}
|
||||||
className="shrink-0 text-zinc-500 hover:text-zinc-300"
|
className="ml-0.5 text-zinc-500 hover:text-zinc-300"
|
||||||
aria-label="Clear series"
|
aria-label={`Remove ${name}`}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Search input */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
placeholder={isLoading ? "Loading series…" : "Search series…"}
|
placeholder={isLoading ? "Loading series…" : values.length === 0 ? "Search series…" : "Add another series…"}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
@@ -92,5 +99,6 @@ export function SeriesPicker({ value, onChange, series, isLoading }: SeriesPicke
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user