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:
@@ -42,10 +42,20 @@ pub struct TokenResponse {
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Per-provider info returned by `GET /config`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProviderInfo {
|
||||
pub id: String,
|
||||
pub capabilities: domain::ProviderCapabilities,
|
||||
}
|
||||
|
||||
/// System configuration response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigResponse {
|
||||
pub allow_registration: bool,
|
||||
/// All registered providers with their capabilities.
|
||||
pub providers: Vec<ProviderInfo>,
|
||||
/// Capabilities of the primary provider — kept for backward compatibility.
|
||||
pub provider_capabilities: domain::ProviderCapabilities,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::http::{HeaderName, HeaderValue};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||
|
||||
use domain::{ChannelService, IMediaProvider, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService};
|
||||
use domain::{ChannelService, IMediaProvider, IProviderRegistry, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService};
|
||||
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
||||
use infra::run_migrations;
|
||||
use k_core::http::server::{ServerConfig, apply_standard_middleware};
|
||||
@@ -72,11 +72,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
let user_service = UserService::new(user_repo);
|
||||
let channel_service = ChannelService::new(channel_repo.clone());
|
||||
|
||||
// Build media provider — Jellyfin → local-files → noop, first match wins.
|
||||
// Build provider registry — all configured providers are registered simultaneously.
|
||||
#[cfg(feature = "local-files")]
|
||||
let mut local_index: Option<Arc<infra::LocalIndex>> = None;
|
||||
|
||||
let mut maybe_provider: Option<Arc<dyn IMediaProvider>> = None;
|
||||
let mut registry = infra::ProviderRegistry::new();
|
||||
|
||||
#[cfg(feature = "jellyfin")]
|
||||
if let (Some(base_url), Some(api_key), Some(user_id)) = (
|
||||
@@ -85,7 +85,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
&config.jellyfin_user_id,
|
||||
) {
|
||||
tracing::info!("Media provider: Jellyfin at {}", base_url);
|
||||
maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig {
|
||||
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig {
|
||||
base_url: base_url.clone(),
|
||||
api_key: api_key.clone(),
|
||||
user_id: user_id.clone(),
|
||||
@@ -93,35 +93,33 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
if maybe_provider.is_none() {
|
||||
if let Some(dir) = &config.local_files_dir {
|
||||
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
||||
tracing::info!("Media provider: local files at {:?}", dir);
|
||||
let lf_cfg = infra::LocalFilesConfig {
|
||||
root_dir: dir.clone(),
|
||||
base_url: config.base_url.clone(),
|
||||
};
|
||||
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
||||
local_index = Some(Arc::clone(&idx));
|
||||
let scan_idx = Arc::clone(&idx);
|
||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||
maybe_provider = Some(Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
||||
} else {
|
||||
tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR");
|
||||
}
|
||||
if let Some(dir) = &config.local_files_dir {
|
||||
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
||||
tracing::info!("Media provider: local files at {:?}", dir);
|
||||
let lf_cfg = infra::LocalFilesConfig {
|
||||
root_dir: dir.clone(),
|
||||
base_url: config.base_url.clone(),
|
||||
};
|
||||
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
||||
local_index = Some(Arc::clone(&idx));
|
||||
let scan_idx = Arc::clone(&idx);
|
||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||
registry.register("local", Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
||||
} else {
|
||||
tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
let media_provider: Arc<dyn IMediaProvider> = maybe_provider.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR."
|
||||
);
|
||||
Arc::new(NoopMediaProvider)
|
||||
});
|
||||
if registry.is_empty() {
|
||||
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR.");
|
||||
registry.register("noop", Arc::new(NoopMediaProvider));
|
||||
}
|
||||
|
||||
let registry = Arc::new(registry);
|
||||
|
||||
let bg_channel_repo = channel_repo.clone();
|
||||
let schedule_engine = ScheduleEngineService::new(
|
||||
Arc::clone(&media_provider),
|
||||
Arc::clone(®istry) as Arc<dyn IProviderRegistry>,
|
||||
channel_repo,
|
||||
schedule_repo,
|
||||
);
|
||||
@@ -131,7 +129,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
user_service,
|
||||
channel_service,
|
||||
schedule_engine,
|
||||
media_provider,
|
||||
registry,
|
||||
config.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use domain::{IProviderRegistry as _, ProviderCapabilities, StreamingProtocol};
|
||||
|
||||
use crate::dto::ConfigResponse;
|
||||
use crate::dto::{ConfigResponse, ProviderInfo};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -8,8 +9,35 @@ pub fn router() -> Router<AppState> {
|
||||
}
|
||||
|
||||
async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
|
||||
let providers: Vec<ProviderInfo> = state
|
||||
.provider_registry
|
||||
.provider_ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
state.provider_registry.capabilities(&id).map(|caps| ProviderInfo {
|
||||
id: id.clone(),
|
||||
capabilities: caps,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let primary_capabilities = state
|
||||
.provider_registry
|
||||
.capabilities(state.provider_registry.primary_id())
|
||||
.unwrap_or(ProviderCapabilities {
|
||||
collections: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
});
|
||||
|
||||
Json(ConfigResponse {
|
||||
allow_registration: state.config.allow_registration,
|
||||
provider_capabilities: state.media_provider.capabilities(),
|
||||
providers,
|
||||
provider_capabilities: primary_capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,14 +11,14 @@ use infra::auth::oidc::OidcService;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService};
|
||||
use domain::{ChannelService, ScheduleEngineService, UserService};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub user_service: Arc<UserService>,
|
||||
pub channel_service: Arc<ChannelService>,
|
||||
pub schedule_engine: Arc<ScheduleEngineService>,
|
||||
pub media_provider: Arc<dyn IMediaProvider>,
|
||||
pub provider_registry: Arc<infra::ProviderRegistry>,
|
||||
pub cookie_key: Key,
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
pub oidc_service: Option<Arc<OidcService>>,
|
||||
@@ -35,7 +35,7 @@ impl AppState {
|
||||
user_service: UserService,
|
||||
channel_service: ChannelService,
|
||||
schedule_engine: ScheduleEngineService,
|
||||
media_provider: Arc<dyn IMediaProvider>,
|
||||
provider_registry: Arc<infra::ProviderRegistry>,
|
||||
config: Config,
|
||||
) -> anyhow::Result<Self> {
|
||||
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
||||
@@ -101,7 +101,7 @@ impl AppState {
|
||||
user_service: Arc::new(user_service),
|
||||
channel_service: Arc::new(channel_service),
|
||||
schedule_engine: Arc::new(schedule_engine),
|
||||
media_provider,
|
||||
provider_registry,
|
||||
cookie_key,
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
oidc_service,
|
||||
|
||||
Reference in New Issue
Block a user