feat(library): add media library browsing functionality

- Introduced new `library` module in the API routes to handle media library requests.
- Enhanced `AppState` to include a media provider for library interactions.
- Defined new `IMediaProvider` trait methods for listing collections, series, and genres.
- Implemented Jellyfin media provider methods for fetching collections and series.
- Added frontend components for selecting series and displaying filter previews.
- Created hooks for fetching collections, series, and genres from the library.
- Updated media filter to support series name and search term.
- Enhanced API client to handle new library-related endpoints.
This commit is contained in:
2026-03-12 02:54:30 +01:00
parent f069376136
commit bf07a65dcd
14 changed files with 1005 additions and 86 deletions

View File

@@ -0,0 +1,221 @@
//! 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, 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, 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>,
/// Scope to a provider collection ID.
collection: Option<String>,
/// Maximum number of results (default: 50, max: 200).
limit: Option<usize>,
}
// ============================================================================
// 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,
Query(params): Query<ItemsQuery>,
) -> Result<Json<Vec<LibraryItemResponse>>, ApiError> {
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,
collections: params
.collection
.map(|c| vec![c])
.unwrap_or_default(),
..Default::default()
};
let items = state.media_provider.fetch_items(&filter).await?;
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
))),
}
}