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

@@ -2,8 +2,9 @@ use std::sync::Arc;
use std::time::Duration as StdDuration;
use domain::{
ChannelService, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItemId,
ProviderCapabilities, ScheduleEngineService, StreamQuality, StreamingProtocol, UserService,
ChannelService, DomainError, DomainResult, IMediaProvider, IProviderRegistry, MediaFilter,
MediaItemId, ProviderCapabilities, ScheduleEngineService, StreamQuality, StreamingProtocol,
UserService,
};
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
use infra::run_migrations;
@@ -69,7 +70,7 @@ async fn main() -> anyhow::Result<()> {
let _user_service = UserService::new(user_repo);
let channel_service = ChannelService::new(channel_repo.clone());
let mut maybe_provider: Option<Arc<dyn IMediaProvider>> = None;
let mut registry = infra::ProviderRegistry::new();
#[cfg(feature = "jellyfin")]
{
@@ -78,41 +79,34 @@ async fn main() -> anyhow::Result<()> {
let user_id = std::env::var("JELLYFIN_USER_ID").ok();
if let (Some(base_url), Some(api_key), Some(user_id)) = (base_url, api_key, user_id) {
info!("Media provider: Jellyfin at {}", base_url);
maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new(
infra::JellyfinConfig {
base_url,
api_key,
user_id,
},
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(
infra::JellyfinConfig { base_url, api_key, user_id },
)));
}
}
#[cfg(feature = "local-files")]
if maybe_provider.is_none() {
if let Some(dir) = std::env::var("LOCAL_FILES_DIR").ok().map(std::path::PathBuf::from) {
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
let base_url = std::env::var("BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string());
let lf_cfg = infra::LocalFilesConfig {
root_dir: dir,
base_url,
};
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
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)));
}
if let Some(dir) = std::env::var("LOCAL_FILES_DIR").ok().map(std::path::PathBuf::from) {
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
let base_url = std::env::var("BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string());
let lf_cfg = infra::LocalFilesConfig { root_dir: dir, base_url };
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
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)));
}
}
let media_provider: Arc<dyn IMediaProvider> = maybe_provider.unwrap_or_else(|| {
if registry.is_empty() {
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.");
Arc::new(NoopMediaProvider)
});
registry.register("noop", Arc::new(NoopMediaProvider));
}
let registry = Arc::new(registry);
let schedule_engine = ScheduleEngineService::new(
Arc::clone(&media_provider),
Arc::clone(&registry) as Arc<dyn IProviderRegistry>,
channel_repo,
schedule_repo,
);
@@ -120,7 +114,7 @@ async fn main() -> anyhow::Result<()> {
let server = KTvMcpServer {
channel_service: Arc::new(channel_service),
schedule_engine: Arc::new(schedule_engine),
media_provider,
provider_registry: registry,
owner_id,
};

View File

@@ -1,8 +1,7 @@
use std::sync::Arc;
use domain::{
ChannelService, ContentType, IMediaProvider, ProgrammingBlock, ScheduleConfig,
ScheduleEngineService,
ChannelService, ContentType, ProgrammingBlock, ScheduleConfig, ScheduleEngineService,
};
use rmcp::{
ServerHandler,
@@ -19,7 +18,7 @@ use crate::tools::{channels, library, schedule};
pub struct KTvMcpServer {
pub channel_service: Arc<ChannelService>,
pub schedule_engine: Arc<ScheduleEngineService>,
pub media_provider: Arc<dyn IMediaProvider>,
pub provider_registry: Arc<infra::ProviderRegistry>,
pub owner_id: Uuid,
}
@@ -244,7 +243,7 @@ impl KTvMcpServer {
#[tool(description = "List media collections/libraries available in the configured provider")]
async fn list_collections(&self) -> String {
library::list_collections(&self.media_provider).await
library::list_collections(&self.provider_registry).await
}
#[tool(
@@ -252,7 +251,7 @@ impl KTvMcpServer {
)]
async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String {
let ct = p.content_type.as_deref().and_then(parse_content_type);
library::list_genres(&self.media_provider, ct).await
library::list_genres(&self.provider_registry, ct).await
}
#[tool(
@@ -261,7 +260,7 @@ impl KTvMcpServer {
async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String {
let ct = p.content_type.as_deref().and_then(parse_content_type);
library::search_media(
&self.media_provider,
&self.provider_registry,
ct,
p.genres.unwrap_or_default(),
p.search_term,

View File

@@ -1,27 +1,27 @@
use domain::{ContentType, IMediaProvider, MediaFilter};
use domain::{ContentType, IProviderRegistry, MediaFilter};
use std::sync::Arc;
use crate::error::{domain_err, ok_json};
pub async fn list_collections(provider: &Arc<dyn IMediaProvider>) -> String {
match provider.list_collections().await {
pub async fn list_collections(registry: &Arc<infra::ProviderRegistry>) -> String {
match registry.list_collections("").await {
Ok(cols) => ok_json(&cols),
Err(e) => domain_err(e),
}
}
pub async fn list_genres(
provider: &Arc<dyn IMediaProvider>,
registry: &Arc<infra::ProviderRegistry>,
content_type: Option<ContentType>,
) -> String {
match provider.list_genres(content_type.as_ref()).await {
match registry.list_genres("", content_type.as_ref()).await {
Ok(genres) => ok_json(&genres),
Err(e) => domain_err(e),
}
}
pub async fn search_media(
provider: &Arc<dyn IMediaProvider>,
registry: &Arc<infra::ProviderRegistry>,
content_type: Option<ContentType>,
genres: Vec<String>,
search_term: Option<String>,
@@ -36,7 +36,7 @@ pub async fn search_media(
collections,
..Default::default()
};
match provider.fetch_items(&filter).await {
match registry.fetch_items("", &filter).await {
Ok(items) => ok_json(&items),
Err(e) => domain_err(e),
}