use std::sync::Arc; use domain::{ DomainError, IMediaProvider, ProviderCapabilities, ProviderConfigRepository, StreamingProtocol, StreamQuality, }; use k_core::db::DatabasePool; use crate::config::{Config, ConfigSource}; #[cfg(feature = "local-files")] use infra::factory::build_transcode_settings_repository; pub struct ProviderBundle { pub registry: Arc, #[cfg(feature = "local-files")] pub local_index: std::collections::HashMap>, #[cfg(feature = "local-files")] pub transcode_manager: Option>, } pub async fn build_provider_registry( config: &Config, #[cfg_attr(not(feature = "local-files"), allow(unused_variables))] db_pool: &Arc, provider_config_repo: &Arc, ) -> anyhow::Result { #[cfg(feature = "local-files")] let mut local_index: std::collections::HashMap> = std::collections::HashMap::new(); #[cfg(feature = "local-files")] let mut transcode_manager: Option> = None; let mut registry = infra::ProviderRegistry::new(); if config.config_source == ConfigSource::Db { tracing::info!("CONFIG_SOURCE=db: loading provider configs from database"); let rows = provider_config_repo.get_all().await?; for row in &rows { if !row.enabled { continue; } match row.provider_type.as_str() { #[cfg(feature = "jellyfin")] "jellyfin" => { if let Ok(cfg) = serde_json::from_str::(&row.config_json) { tracing::info!("Loading Jellyfin provider [{}] from DB config", row.id); registry.register(&row.id, Arc::new(infra::JellyfinMediaProvider::new(cfg))); } } #[cfg(feature = "local-files")] "local_files" => { if let Ok(cfg_map) = serde_json::from_str::>(&row.config_json) && let Some(files_dir) = cfg_map.get("files_dir") { let transcode_dir = cfg_map.get("transcode_dir") .filter(|s| !s.is_empty()) .map(std::path::PathBuf::from); let cleanup_ttl_hours: u32 = cfg_map.get("cleanup_ttl_hours") .and_then(|s| s.parse().ok()) .unwrap_or(24); tracing::info!("Loading local-files provider [{}] from DB config at {:?}", row.id, files_dir); match infra::factory::build_local_files_bundle( db_pool, std::path::PathBuf::from(files_dir), transcode_dir, cleanup_ttl_hours, config.base_url.clone(), &row.id, ).await { Ok(bundle) => { let scan_idx = Arc::clone(&bundle.local_index); tokio::spawn(async move { scan_idx.rescan().await; }); if let Some(ref tm) = bundle.transcode_manager { tracing::info!("Transcoding enabled for [{}]", row.id); // Load persisted TTL override from transcode_settings table. let tm_clone = Arc::clone(tm); let repo = build_transcode_settings_repository(db_pool).await.ok(); tokio::spawn(async move { if let Some(r) = repo && let Ok(Some(ttl)) = r.load_cleanup_ttl().await { tm_clone.set_cleanup_ttl(ttl); } }); } registry.register(&row.id, bundle.provider); if transcode_manager.is_none() { transcode_manager = bundle.transcode_manager; } local_index.insert(row.id.clone(), bundle.local_index); } Err(e) => tracing::warn!("Failed to build local-files provider [{}]: {}", row.id, e), } } } _ => {} } } } else { #[cfg(feature = "jellyfin")] if let (Some(base_url), Some(api_key), Some(user_id)) = ( &config.jellyfin_base_url, &config.jellyfin_api_key, &config.jellyfin_user_id, ) { tracing::info!("Media provider: Jellyfin at {}", base_url); 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(), }))); } #[cfg(feature = "local-files")] if let Some(dir) = &config.local_files_dir { tracing::info!("Media provider: local files at {:?}", dir); match infra::factory::build_local_files_bundle( db_pool, dir.clone(), config.transcode_dir.clone(), config.transcode_cleanup_ttl_hours, config.base_url.clone(), "local", ).await { Ok(bundle) => { let scan_idx = Arc::clone(&bundle.local_index); tokio::spawn(async move { scan_idx.rescan().await; }); if let Some(ref tm) = bundle.transcode_manager { tracing::info!("Transcoding enabled; cache dir: {:?}", config.transcode_dir); let tm_clone = Arc::clone(tm); let repo = build_transcode_settings_repository(db_pool).await.ok(); tokio::spawn(async move { if let Some(r) = repo && let Ok(Some(ttl)) = r.load_cleanup_ttl().await { tm_clone.set_cleanup_ttl(ttl); } }); } registry.register("local", bundle.provider); transcode_manager = bundle.transcode_manager; local_index.insert("local".to_string(), bundle.local_index); } Err(e) => tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR: {}", e), } } } if registry.is_empty() { tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR."); registry.register("noop", Arc::new(NoopMediaProvider)); } Ok(ProviderBundle { registry: Arc::new(registry), #[cfg(feature = "local-files")] local_index, #[cfg(feature = "local-files")] transcode_manager, }) } /// Stand-in provider used when no real media source is configured. /// Returns a descriptive error for every call so schedule endpoints fail /// gracefully rather than panicking at startup. struct NoopMediaProvider; #[async_trait::async_trait] impl IMediaProvider for NoopMediaProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { collections: false, series: false, genres: false, tags: false, decade: false, search: false, streaming_protocol: StreamingProtocol::DirectFile, rescan: false, transcode: false, } } async fn fetch_items( &self, _: &domain::MediaFilter, ) -> domain::DomainResult> { Err(DomainError::InfrastructureError( "No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.".into(), )) } async fn fetch_by_id( &self, _: &domain::MediaItemId, ) -> domain::DomainResult> { Err(DomainError::InfrastructureError( "No media provider configured.".into(), )) } async fn get_stream_url( &self, _: &domain::MediaItemId, _: &StreamQuality, ) -> domain::DomainResult { Err(DomainError::InfrastructureError( "No media provider configured.".into(), )) } }