//! Library browsing routes //! //! These endpoints expose the media provider's library to the dashboard so //! users can discover what's available without knowing provider-internal IDs. //! All routes require authentication. //! //! GET /library/collections — top-level libraries (Jellyfin views, Plex sections) //! GET /library/series — TV series, optionally scoped to a collection //! GET /library/genres — available genres, optionally filtered by content type //! GET /library/items — search / browse items (used for block filter preview) use axum::{ Json, Router, extract::{Query, RawQuery, State}, routing::get, }; use domain::IProviderRegistry as _; use serde::{Deserialize, Serialize}; use domain::{Collection, ContentType, MediaFilter, SeriesSummary}; use crate::{error::ApiError, extractors::CurrentUser, state::AppState}; pub fn router() -> Router { Router::new() .route("/collections", get(list_collections)) .route("/series", get(list_series)) .route("/genres", get(list_genres)) .route("/items", get(search_items)) } // ============================================================================ // Response DTOs // ============================================================================ #[derive(Debug, Serialize)] struct CollectionResponse { id: String, name: String, #[serde(skip_serializing_if = "Option::is_none")] collection_type: Option, } impl From for CollectionResponse { fn from(c: Collection) -> Self { Self { id: c.id, name: c.name, collection_type: c.collection_type, } } } #[derive(Debug, Serialize)] struct SeriesResponse { id: String, name: String, episode_count: u32, genres: Vec, #[serde(skip_serializing_if = "Option::is_none")] year: Option, } impl From for SeriesResponse { fn from(s: SeriesSummary) -> Self { Self { id: s.id, name: s.name, episode_count: s.episode_count, genres: s.genres, year: s.year, } } } #[derive(Debug, Serialize)] struct LibraryItemResponse { id: String, title: String, content_type: String, duration_secs: u32, #[serde(skip_serializing_if = "Option::is_none")] series_name: Option, #[serde(skip_serializing_if = "Option::is_none")] season_number: Option, #[serde(skip_serializing_if = "Option::is_none")] episode_number: Option, #[serde(skip_serializing_if = "Option::is_none")] year: Option, genres: Vec, } // ============================================================================ // Query params // ============================================================================ #[derive(Debug, Deserialize)] struct CollectionsQuery { /// Provider key to query (default: primary). provider: Option, } #[derive(Debug, Deserialize)] struct SeriesQuery { /// Scope results to a specific collection (provider library ID). collection: Option, /// Provider key to query (default: primary). provider: Option, } #[derive(Debug, Deserialize)] struct GenresQuery { /// Limit genres to a content type: "movie", "episode", or "short". #[serde(rename = "type")] content_type: Option, /// Provider key to query (default: primary). provider: Option, } #[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 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). limit: Option, /// Fill strategy to simulate: "random" | "sequential" | "best_fit". /// Applies the same ordering the schedule engine would use so the preview /// reflects what will actually be scheduled. strategy: Option, /// Provider key to query (default: primary). provider: Option, } // ============================================================================ // Handlers // ============================================================================ /// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.) async fn list_collections( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { let provider_id = params.provider.as_deref().unwrap_or(""); let registry = state.provider_registry.read().await; let caps = registry.capabilities(provider_id).ok_or_else(|| { ApiError::validation(format!("Unknown provider '{}'", provider_id)) })?; if !caps.collections { return Err(ApiError::not_implemented("collections not supported by this provider")); } let collections = registry.list_collections(provider_id).await?; Ok(Json(collections.into_iter().map(Into::into).collect())) } /// List TV series, optionally scoped to a collection. async fn list_series( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { let provider_id = params.provider.as_deref().unwrap_or(""); let registry = state.provider_registry.read().await; let caps = registry.capabilities(provider_id).ok_or_else(|| { ApiError::validation(format!("Unknown provider '{}'", provider_id)) })?; if !caps.series { return Err(ApiError::not_implemented("series not supported by this provider")); } let series = registry .list_series(provider_id, params.collection.as_deref()) .await?; Ok(Json(series.into_iter().map(Into::into).collect())) } /// List available genres, optionally filtered to a content type. async fn list_genres( State(state): State, CurrentUser(_user): CurrentUser, Query(params): Query, ) -> Result>, ApiError> { let provider_id = params.provider.as_deref().unwrap_or(""); let registry = state.provider_registry.read().await; let caps = registry.capabilities(provider_id).ok_or_else(|| { ApiError::validation(format!("Unknown provider '{}'", provider_id)) })?; if !caps.genres { return Err(ApiError::not_implemented("genres not supported by this provider")); } let ct = parse_content_type(params.content_type.as_deref())?; let genres = registry.list_genres(provider_id, ct.as_ref()).await?; Ok(Json(genres)) } /// Search / browse library items. Used by the block editor to preview what a /// filter matches before saving a channel config. async fn search_items( State(state): State, CurrentUser(_user): CurrentUser, 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 provider_id = params.provider.as_deref().unwrap_or(""); let filter = MediaFilter { content_type: parse_content_type(params.content_type.as_deref())?, search_term: params.q, series_names: params.series, collections: params .collection .map(|c| vec![c]) .unwrap_or_default(), ..Default::default() }; let registry = state.provider_registry.read().await; let mut items = registry.fetch_items(provider_id, &filter).await?; // Apply the same ordering the schedule engine uses so the preview reflects // what will actually be scheduled rather than raw provider order. match params.strategy.as_deref() { Some("random") => { use rand::seq::SliceRandom; items.shuffle(&mut rand::thread_rng()); } Some("best_fit") => { // Mirror the greedy bin-packing: longest items first. items.sort_by(|a, b| b.duration_secs.cmp(&a.duration_secs)); } _ => {} // "sequential" / unset: keep provider order (episode order per series) } let response: Vec = items .into_iter() .take(limit) .map(|item| LibraryItemResponse { id: item.id.into_inner(), title: item.title, content_type: match item.content_type { domain::ContentType::Movie => "movie".into(), domain::ContentType::Episode => "episode".into(), domain::ContentType::Short => "short".into(), }, duration_secs: item.duration_secs, series_name: item.series_name, season_number: item.season_number, episode_number: item.episode_number, year: item.year, genres: item.genres, }) .collect(); Ok(Json(response)) } // ============================================================================ // Helpers // ============================================================================ fn parse_content_type(s: Option<&str>) -> Result, ApiError> { match s { None | Some("") => Ok(None), Some("movie") => Ok(Some(ContentType::Movie)), Some("episode") => Ok(Some(ContentType::Episode)), Some("short") => Ok(Some(ContentType::Short)), Some(other) => Err(ApiError::validation(format!( "Unknown content type '{}'. Use movie, episode, or short.", other ))), } }