249 lines
8.3 KiB
Rust
249 lines
8.3 KiB
Rust
//! 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 serde::{Deserialize, Serialize};
|
|
|
|
use domain::{Collection, ContentType, MediaFilter, SeriesSummary};
|
|
|
|
use crate::{error::ApiError, extractors::CurrentUser, state::AppState};
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
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<String>,
|
|
}
|
|
|
|
impl From<Collection> 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
year: Option<u16>,
|
|
}
|
|
|
|
impl From<SeriesSummary> 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
season_number: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
episode_number: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
year: Option<u16>,
|
|
genres: Vec<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Query params
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SeriesQuery {
|
|
/// Scope results to a specific collection (provider library ID).
|
|
collection: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GenresQuery {
|
|
/// Limit genres to a content type: "movie", "episode", or "short".
|
|
#[serde(rename = "type")]
|
|
content_type: Option<String>,
|
|
}
|
|
|
|
#[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 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).
|
|
limit: Option<usize>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Handlers
|
|
// ============================================================================
|
|
|
|
/// List top-level collections (Jellyfin virtual libraries, Plex sections, etc.)
|
|
async fn list_collections(
|
|
State(state): State<AppState>,
|
|
CurrentUser(_user): CurrentUser,
|
|
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
|
let collections = state.media_provider.list_collections().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<AppState>,
|
|
CurrentUser(_user): CurrentUser,
|
|
Query(params): Query<SeriesQuery>,
|
|
) -> Result<Json<Vec<SeriesResponse>>, ApiError> {
|
|
let series = state
|
|
.media_provider
|
|
.list_series(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<AppState>,
|
|
CurrentUser(_user): CurrentUser,
|
|
Query(params): Query<GenresQuery>,
|
|
) -> Result<Json<Vec<String>>, ApiError> {
|
|
let ct = parse_content_type(params.content_type.as_deref())?;
|
|
let genres = state.media_provider.list_genres(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<AppState>,
|
|
CurrentUser(_user): CurrentUser,
|
|
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_names: params.series,
|
|
collections: params
|
|
.collection
|
|
.map(|c| vec![c])
|
|
.unwrap_or_default(),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut items = state.media_provider.fetch_items(&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<LibraryItemResponse> = 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<Option<ContentType>, 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
|
|
))),
|
|
}
|
|
}
|