feat: implement multi-provider support in media library

- Introduced IProviderRegistry to manage multiple media providers.
- Updated AppState to use provider_registry instead of a single media_provider.
- Refactored library routes to support provider-specific queries for collections, series, genres, and items.
- Enhanced ProgrammingBlock to include provider_id for algorithmic and manual content types.
- Modified frontend components to allow selection of providers and updated API calls to include provider parameters.
- Adjusted hooks and types to accommodate provider-specific functionality.
This commit is contained in:
2026-03-14 23:59:21 +01:00
parent c53892159a
commit ead65e6be2
21 changed files with 468 additions and 150 deletions

View File

@@ -14,6 +14,7 @@ use axum::{
extract::{Query, RawQuery, State},
routing::get,
};
use domain::IProviderRegistry as _;
use serde::{Deserialize, Serialize};
use domain::{Collection, ContentType, MediaFilter, SeriesSummary};
@@ -93,10 +94,18 @@ struct LibraryItemResponse {
// Query params
// ============================================================================
#[derive(Debug, Deserialize)]
struct CollectionsQuery {
/// Provider key to query (default: primary).
provider: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SeriesQuery {
/// Scope results to a specific collection (provider library ID).
collection: Option<String>,
/// Provider key to query (default: primary).
provider: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -104,6 +113,8 @@ struct GenresQuery {
/// Limit genres to a content type: "movie", "episode", or "short".
#[serde(rename = "type")]
content_type: Option<String>,
/// Provider key to query (default: primary).
provider: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
@@ -125,6 +136,8 @@ struct ItemsQuery {
/// Applies the same ordering the schedule engine would use so the preview
/// reflects what will actually be scheduled.
strategy: Option<String>,
/// Provider key to query (default: primary).
provider: Option<String>,
}
// ============================================================================
@@ -135,13 +148,16 @@ struct ItemsQuery {
async fn list_collections(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Query(params): Query<CollectionsQuery>,
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
if !state.media_provider.capabilities().collections {
return Err(ApiError::not_implemented(
"collections not supported by this provider",
));
let provider_id = params.provider.as_deref().unwrap_or("");
let caps = state.provider_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 = state.media_provider.list_collections().await?;
let collections = state.provider_registry.list_collections(provider_id).await?;
Ok(Json(collections.into_iter().map(Into::into).collect()))
}
@@ -151,14 +167,16 @@ async fn list_series(
CurrentUser(_user): CurrentUser,
Query(params): Query<SeriesQuery>,
) -> Result<Json<Vec<SeriesResponse>>, ApiError> {
if !state.media_provider.capabilities().series {
return Err(ApiError::not_implemented(
"series not supported by this provider",
));
let provider_id = params.provider.as_deref().unwrap_or("");
let caps = state.provider_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 = state
.media_provider
.list_series(params.collection.as_deref())
.provider_registry
.list_series(provider_id, params.collection.as_deref())
.await?;
Ok(Json(series.into_iter().map(Into::into).collect()))
}
@@ -169,13 +187,15 @@ async fn list_genres(
CurrentUser(_user): CurrentUser,
Query(params): Query<GenresQuery>,
) -> Result<Json<Vec<String>>, ApiError> {
if !state.media_provider.capabilities().genres {
return Err(ApiError::not_implemented(
"genres not supported by this provider",
));
let provider_id = params.provider.as_deref().unwrap_or("");
let caps = state.provider_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 = state.media_provider.list_genres(ct.as_ref()).await?;
let genres = state.provider_registry.list_genres(provider_id, ct.as_ref()).await?;
Ok(Json(genres))
}
@@ -195,6 +215,8 @@ async fn search_items(
.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,
@@ -206,7 +228,7 @@ async fn search_items(
..Default::default()
};
let mut items = state.media_provider.fetch_items(&filter).await?;
let mut items = state.provider_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.