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",
|
||||
"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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ItemsQuery {
|
||||
/// Free-text search.
|
||||
q: Option<String>,
|
||||
/// Content type filter: "movie", "episode", or "short".
|
||||
#[serde(rename = "type")]
|
||||
content_type: Option<String>,
|
||||
/// Filter episodes to a specific series name.
|
||||
series: Option<String>,
|
||||
/// Filter episodes by series name. Repeat the param for multiple series:
|
||||
/// `?series=iCarly&series=Victorious`
|
||||
#[serde(default)]
|
||||
series: Vec<String>,
|
||||
/// Scope to a provider collection ID.
|
||||
collection: Option<String>,
|
||||
/// Maximum number of results (default: 50, max: 200).
|
||||
@@ -163,14 +165,21 @@ async fn list_genres(
|
||||
async fn search_items(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(params): Query<ItemsQuery>,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
) -> 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 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])
|
||||
|
||||
@@ -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<String>,
|
||||
/// Filter by TV series name. Use with `content_type: Episode` and
|
||||
/// `strategy: Sequential` for ordered series playback (e.g. "iCarly").
|
||||
pub series_name: Option<String>,
|
||||
/// 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<String>,
|
||||
/// Free-text search term. Intended for library browsing; typically omitted
|
||||
/// during schedule generation.
|
||||
pub search_term: Option<String>,
|
||||
|
||||
@@ -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<Vec<MediaItem>> {
|
||||
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<Vec<MediaItem>> {
|
||||
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<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.
|
||||
|
||||
Reference in New Issue
Block a user