use std::sync::Arc; use application::{ admin::AdminDeps, auth::AuthDeps, channels::{ChannelCommandDeps, ChannelQueryDeps}, config_snapshots::ConfigSnapshotDeps, iptv::IptvDeps, library::{LibraryCommandDeps, LibraryQueryDeps}, providers::ProviderDeps, schedule::ScheduleDeps, }; use domain::ports::{IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol}; use domain::{DomainError, ScheduleEngineService}; use infra_wiring::{Config, ConfigSource, DbPool}; use crate::state::AppState; const EVENT_BUS_CAPACITY: usize = 64; 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 event_bus = Arc::new(adapter_event_publisher::ChannelEventBus::new(EVENT_BUS_CAPACITY)); let event_publisher: Arc = event_bus.clone(); 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( provider_registry.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)?; let (sync_tx, sync_rx) = tokio::sync::watch::channel(()); let auth_deps = Arc::new(AuthDeps { user_command: wire_output.user_command.clone(), user_query: wire_output.user_query.clone(), auth_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 channel_query_deps = Arc::new(ChannelQueryDeps { channel_query: wire_output.channel_query.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(), }); 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 library_query_deps = Arc::new(LibraryQueryDeps { library_query: wire_output.library_query.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 config_arc = Arc::new(config); let bg_schedule_deps = schedule_deps.clone(); tokio::spawn(crate::background::auto_scheduler::run(bg_schedule_deps)); let bg_schedule_deps2 = schedule_deps.clone(); let bg_event_publisher = event_publisher.clone(); tokio::spawn(crate::background::broadcast_poller::run( bg_schedule_deps2, bg_event_publisher, )); let webhook_rx = event_bus.subscriber(); let webhook_channel_query = wire_output.channel_query.clone(); tokio::spawn(crate::background::webhook_consumer::run( webhook_rx, webhook_channel_query, reqwest::Client::new(), )); let bg_sync = library_sync.clone(); let bg_registry = provider_registry.clone(); let bg_settings = wire_output.settings.clone(); tokio::spawn(crate::background::library_sync::run( bg_sync, bg_registry, bg_settings, sync_rx, )); Ok(AppState { auth_deps, channel_command_deps, channel_query_deps, config_snapshot_deps, schedule_deps, library_command_deps, library_query_deps, admin_deps, iptv_deps, provider_deps, #[cfg(feature = "auth-jwt")] jwt_validator, provider_registry, _library_sync: library_sync, _settings_repo: wire_output.settings, _event_bus: event_bus, config: config_arc, sync_trigger: sync_tx, }) } 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, }) } #[cfg(feature = "postgres")] DbPool::Postgres(pg_pool) => { let w = adapter_postgres::wire(pg_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, }) } _ => anyhow::bail!("database backend not compiled into this binary"), } } 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, 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, _: &domain::ports::StreamQuality, ) -> 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_stream_url( &self, item_id: &domain::MediaItemId, quality: &domain::ports::StreamQuality, ) -> 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_stream_url(item_id, quality).await; } } if let Some(provider) = self.primary() { provider.get_stream_url(item_id, quality).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 media_item_to_library_item(item: domain::MediaItem, provider_id: &str) -> domain::LibraryItem { let external_id = item.id().value().to_string(); let id = format!("{}::{}", provider_id, external_id); let now = chrono::Utc::now().to_rfc3339(); domain::LibraryItem::from_persistence( id, provider_id.to_string(), external_id, item.title().to_string(), item.content_type().clone(), item.duration_secs(), item.series_name().map(|s| s.to_string()), item.season_number(), item.episode_number(), item.year(), item.genres().to_vec(), item.tags().to_vec(), item.collection_id().map(|s| s.to_string()), None, None, item.thumbnail_url().map(|s| s.to_string()), now, ) } struct SimpleSyncAdapter { library_command: Arc, } impl SimpleSyncAdapter { fn new(library_command: Arc) -> Self { Self { library_command } } } #[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 library_items: Vec = items .into_iter() .map(|item| media_item_to_library_item(item, provider_id)) .collect(); 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 } }