use std::sync::Arc; use application::{ admin::AdminDeps, auth::AuthDeps, channels::ChannelCommandDeps, config::ConfigDeps, config_snapshots::ConfigSnapshotDeps, iptv::IptvDeps, library::LibraryCommandDeps, providers::ProviderDeps, schedule::ScheduleDeps, }; use domain::ports::{IMediaProvider, IProviderRegistry, ProviderCapabilities}; use domain::{DomainError, ScheduleEngineService}; use infra_wiring::{Config, ConfigSource, DbPool}; use crate::state::AppState; const DEV_JWT_SECRET: &str = "k-template-dev-secret-not-for-production-use-only"; pub async fn build_app_state(config: Config) -> anyhow::Result { let pool = DbPool::connect(&config.database_url).await?; pool.run_migrations().await?; let wire_output = wire_repositories(&pool)?; let auth_service: Arc = Arc::new(adapter_auth::PasswordAuthService); let sqlite_pool = match &pool { #[cfg(feature = "sqlite")] DbPool::Sqlite(p) => p.clone(), }; let event_publisher: Arc = Arc::new(adapter_event_publisher::SqliteEventPublisher::new(sqlite_pool)); let provider_registry = build_provider_registry(&config).await; let library_sync: Arc = build_library_sync(wire_output.library_command.clone()); let schedule_engine = Arc::new(ScheduleEngineService::new( wire_output.library_query.clone(), wire_output.channel_query.clone(), wire_output.schedule_query.clone(), wire_output.schedule_command.clone(), )); #[cfg(feature = "auth-jwt")] let jwt_validator = build_jwt_validator(&config)?; #[cfg(feature = "auth-jwt")] let token_service: Arc = { let validator = jwt_validator .as_ref() .expect("JWT validator required") .as_ref() .clone(); Arc::new(adapter_auth::JwtTokenService::new(validator)) }; let auth_deps = Arc::new(AuthDeps { user_command: wire_output.user_command.clone(), user_query: wire_output.user_query.clone(), auth_service, token_service, event_publisher: event_publisher.clone(), }); let channel_command_deps = Arc::new(ChannelCommandDeps { channel_command: wire_output.channel_command.clone(), channel_query: wire_output.channel_query.clone(), event_publisher: event_publisher.clone(), }); let config_snapshot_deps = Arc::new(ConfigSnapshotDeps { channel_command: wire_output.channel_command.clone(), channel_query: wire_output.channel_query.clone(), }); let schedule_deps = Arc::new(ScheduleDeps { schedule_engine: schedule_engine.clone(), channel_query: wire_output.channel_query.clone(), schedule_query: wire_output.schedule_query.clone(), schedule_command: wire_output.schedule_command.clone(), event_publisher: event_publisher.clone(), provider_registry: provider_registry.clone(), }); let library_command_deps = Arc::new(LibraryCommandDeps { library_command: wire_output.library_command.clone(), library_query: wire_output.library_query.clone(), library_sync: library_sync.clone(), provider_registry: provider_registry.clone(), event_publisher: event_publisher.clone(), }); let admin_deps = Arc::new(AdminDeps { settings_repo: wire_output.settings.clone(), activity_query: wire_output.activity_query.clone(), }); let iptv_deps = Arc::new(IptvDeps { channel_query: wire_output.channel_query.clone(), schedule_query: wire_output.schedule_query.clone(), }); let provider_deps = Arc::new(ProviderDeps { provider_config_command: wire_output.provider_config_command.clone(), provider_config_query: wire_output.provider_config_query.clone(), }); let mut available_provider_types = Vec::new(); #[cfg(feature = "jellyfin")] available_provider_types.push("jellyfin".to_string()); #[cfg(feature = "local-files")] available_provider_types.push("local_files".to_string()); let config_deps = Arc::new(ConfigDeps { provider_registry: provider_registry.clone(), allow_registration: config.allow_registration, available_provider_types, }); let config_arc = Arc::new(config); Ok(AppState { auth_deps, channel_command_deps, config_snapshot_deps, config_deps, schedule_deps, library_command_deps, admin_deps, iptv_deps, provider_deps, channel_query: wire_output.channel_query.clone(), library_query: wire_output.library_query.clone(), schedule_query: wire_output.schedule_query.clone(), settings_repo: wire_output.settings.clone(), activity_query: wire_output.activity_query.clone(), provider_config_query: wire_output.provider_config_query.clone(), provider_config_command: wire_output.provider_config_command.clone(), #[cfg(feature = "auth-jwt")] jwt_validator, config: config_arc, }) } struct WireOutput { user_command: Arc, user_query: Arc, channel_command: Arc, channel_query: Arc, schedule_command: Arc, schedule_query: Arc, library_command: Arc, library_query: Arc, activity_query: Arc, settings: Arc, provider_config_command: Arc, provider_config_query: Arc, } fn wire_repositories(pool: &DbPool) -> anyhow::Result { match pool { #[cfg(feature = "sqlite")] DbPool::Sqlite(sqlite_pool) => { let w = adapter_sqlite::wire(sqlite_pool.clone()); Ok(WireOutput { user_command: w.user_command, user_query: w.user_query, channel_command: w.channel_command, channel_query: w.channel_query, schedule_command: w.schedule_command, schedule_query: w.schedule_query, library_command: w.library_command, library_query: w.library_query, activity_query: w.activity_query, settings: w.settings, provider_config_command: w.provider_config_command, provider_config_query: w.provider_config_query, }) } } } async fn build_provider_registry(config: &Config) -> Arc { let mut providers: Vec<(String, Arc)> = Vec::new(); match config.config_source { ConfigSource::Env => { #[cfg(feature = "jellyfin")] if let (Some(url), Some(api_key), Some(user_id)) = ( &config.jellyfin_url, &config.jellyfin_api_key, &config.jellyfin_user_id, ) { tracing::info!("Media provider: Jellyfin at {}", url); providers.push(( "jellyfin".to_string(), Arc::new(adapter_jellyfin::JellyfinMediaProvider::new( adapter_jellyfin::JellyfinConfig { base_url: url.clone(), api_key: api_key.clone(), user_id: user_id.clone(), }, )), )); } } ConfigSource::Db => { tracing::info!("CONFIG_SOURCE=db: provider configs loaded from database at runtime"); } } if providers.is_empty() { tracing::warn!("No media provider configured — using NoopMediaProvider"); providers.push(("noop".to_string(), Arc::new(NoopMediaProvider))); } Arc::new(SimpleProviderRegistry::new(providers)) } fn build_library_sync( library_command: Arc, ) -> Arc { Arc::new(SimpleSyncAdapter::new(library_command)) } #[cfg(feature = "auth-jwt")] fn build_jwt_validator(config: &Config) -> anyhow::Result>> { let secret = match &config.jwt_secret { Some(s) if !s.is_empty() => s.clone(), _ => { if config.is_production { anyhow::bail!("JWT_SECRET is required in production"); } tracing::warn!("JWT_SECRET not set — using insecure development secret"); DEV_JWT_SECRET.to_string() } }; let jwt_config = adapter_auth::JwtConfig::new( secret, config.jwt_issuer.clone(), config.jwt_audience.clone(), Some(config.jwt_expiry_hours), Some(config.jwt_refresh_expiry_days), config.is_production, ) .map_err(|e| anyhow::anyhow!("JWT config error: {}", e))?; Ok(Some(Arc::new(adapter_auth::JwtValidator::new(jwt_config)))) } 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, rescan: 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_source_uri(&self, _: &domain::MediaItemId) -> domain::DomainResult { Err(DomainError::InfrastructureError( "No media provider configured.".into(), )) } } struct SimpleProviderRegistry { providers: Vec<(String, Arc)>, } impl SimpleProviderRegistry { fn new(providers: Vec<(String, Arc)>) -> Self { Self { providers } } fn get(&self, id: &str) -> Option<&Arc> { self.providers.iter().find(|(k, _)| k == id).map(|(_, v)| v) } fn primary(&self) -> Option<&Arc> { self.providers.first().map(|(_, v)| v) } fn extract_provider_id(item_id: &str) -> Option<&str> { item_id.find("::").map(|pos| &item_id[..pos]) } } #[async_trait::async_trait] impl IProviderRegistry for SimpleProviderRegistry { async fn fetch_items( &self, provider_id: &str, filter: &domain::MediaFilter, ) -> domain::DomainResult> { let id = if provider_id.is_empty() { self.providers.first().map(|(k, _)| k.as_str()).unwrap_or("") } else { provider_id }; let provider = self .get(id) .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; provider.fetch_items(filter).await } async fn fetch_by_id( &self, item_id: &domain::MediaItemId, ) -> domain::DomainResult> { let id_str = item_id.value(); if let Some(pid) = Self::extract_provider_id(id_str) { if let Some(provider) = self.get(pid) { return provider.fetch_by_id(item_id).await; } } if let Some(provider) = self.primary() { provider.fetch_by_id(item_id).await } else { Ok(None) } } async fn get_source_uri( &self, item_id: &domain::MediaItemId, ) -> domain::DomainResult { let id_str = item_id.value(); if let Some(pid) = Self::extract_provider_id(id_str) { if let Some(provider) = self.get(pid) { return provider.get_source_uri(item_id).await; } } if let Some(provider) = self.primary() { provider.get_source_uri(item_id).await } else { Err(DomainError::InfrastructureError( "No provider available".into(), )) } } fn provider_ids(&self) -> Vec { self.providers.iter().map(|(k, _)| k.clone()).collect() } fn primary_id(&self) -> &str { self.providers .first() .map(|(k, _)| k.as_str()) .unwrap_or("") } fn capabilities(&self, provider_id: &str) -> Option { self.get(provider_id).map(|p| p.capabilities()) } async fn list_collections( &self, provider_id: &str, ) -> domain::DomainResult> { let id = if provider_id.is_empty() { self.primary_id() } else { provider_id }; let provider = self .get(id) .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; provider.list_collections().await } async fn list_series( &self, provider_id: &str, collection_id: Option<&str>, ) -> domain::DomainResult> { let id = if provider_id.is_empty() { self.primary_id() } else { provider_id }; let provider = self .get(id) .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; provider.list_series(collection_id).await } async fn list_genres( &self, provider_id: &str, content_type: Option<&domain::ContentType>, ) -> domain::DomainResult> { let id = if provider_id.is_empty() { self.primary_id() } else { provider_id }; let provider = self .get(id) .ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?; provider.list_genres(content_type).await } } fn provider_item_to_library_item( item: domain::MediaItem, provider_id: &str, role_config: &adapter_common::role_detector::RoleDetectionConfig, ) -> domain::MediaItem { let external_id = item.id().value().to_string(); let now = chrono::Utc::now().to_rfc3339(); let role = adapter_common::role_detector::detect_role(&item, role_config); domain::MediaItem::from_persistence(domain::MediaItemRow { id: domain::MediaItemId::new(format!("{}::{}", provider_id, external_id)), provider_id: provider_id.to_string(), external_id, title: item.title().to_string(), content_type: item.content_type().clone(), duration_secs: item.duration_secs(), description: item.description().map(|s| s.to_string()), series_name: item.series_name().map(|s| s.to_string()), season_number: item.season_number(), episode_number: item.episode_number(), year: item.year(), genres: item.genres().to_vec(), tags: item.tags().to_vec(), collection_id: item.collection_id().map(|s| s.to_string()), collection_name: item.collection_name().map(|s| s.to_string()), collection_type: item.collection_type().map(|s| s.to_string()), thumbnail_url: item.thumbnail_url().map(|s| s.to_string()), synced_at: Some(now), role, chapters: item.chapters().to_vec(), }) } struct SimpleSyncAdapter { library_command: Arc, role_config: adapter_common::role_detector::RoleDetectionConfig, } impl SimpleSyncAdapter { fn new(library_command: Arc) -> Self { Self { library_command, role_config: adapter_common::role_detector::RoleDetectionConfig::default(), } } } #[async_trait::async_trait] impl domain::ports::LibrarySyncAdapter for SimpleSyncAdapter { async fn sync_provider( &self, provider: &dyn IMediaProvider, provider_id: &str, ) -> domain::LibrarySyncResult { use std::time::Instant; let start = Instant::now(); let log_id = match self.library_command.log_sync_start(provider_id).await { Ok(id) => id, Err(e) => { return domain::LibrarySyncResult::with_error( provider_id, 0, format!("Failed to log sync start: {e}"), ); } }; let filter = domain::MediaFilter::default(); let items = match provider.fetch_items(&filter).await { Ok(items) => items, Err(e) => { let result = domain::LibrarySyncResult::with_error( provider_id, start.elapsed().as_millis() as u64, format!("Failed to fetch items: {e}"), ); let _ = self.library_command.log_sync_finish(log_id, &result).await; return result; } }; let items_found = items.len() as u32; if let Err(e) = self.library_command.clear_provider(provider_id).await { let result = domain::LibrarySyncResult::with_error( provider_id, start.elapsed().as_millis() as u64, format!("Failed to clear provider items: {e}"), ); let _ = self.library_command.log_sync_finish(log_id, &result).await; return result; } let mut library_items: Vec = items .into_iter() .map(|item| provider_item_to_library_item(item, provider_id, &self.role_config)) .collect(); for item in &mut library_items { if adapter_common::ffprobe::should_probe_chapters( item.content_type(), item.duration_secs(), ) { if let Ok(uri) = provider.get_source_uri(item.id()).await { let chapters = adapter_common::ffprobe::extract_chapters(&uri).await; item.set_chapters(chapters); } } } if let Err(e) = self .library_command .upsert_items(provider_id, library_items) .await { let result = domain::LibrarySyncResult::with_error( provider_id, start.elapsed().as_millis() as u64, format!("Failed to upsert items: {e}"), ); let _ = self.library_command.log_sync_finish(log_id, &result).await; return result; } let result = domain::LibrarySyncResult::new( provider_id, items_found, start.elapsed().as_millis() as u64, ); let _ = self.library_command.log_sync_finish(log_id, &result).await; result } }