//! API Server Entry Point //! //! Configures and starts the HTTP server with JWT-based authentication. use std::net::SocketAddr; use std::time::Duration as StdDuration; use axum::Router; use axum::http::{HeaderName, HeaderValue}; use std::sync::Arc; use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; 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}; use k_core::logging; use tokio::net::TcpListener; use tracing::info; mod config; mod dto; mod error; mod events; mod extractors; mod poller; mod routes; mod scheduler; mod state; mod webhook; use crate::config::Config; use crate::state::AppState; #[tokio::main] async fn main() -> anyhow::Result<()> { logging::init("api"); let config = Config::from_env(); info!("Starting server on {}:{}", config.host, config.port); // Setup database tracing::info!("Connecting to database: {}", config.database_url); #[cfg(all(feature = "sqlite", not(feature = "postgres")))] let db_type = k_core::db::DbType::Sqlite; #[cfg(all(feature = "postgres", not(feature = "sqlite")))] let db_type = k_core::db::DbType::Postgres; // Both features enabled: fall back to URL inspection at runtime #[cfg(all(feature = "sqlite", feature = "postgres"))] let db_type = if config.database_url.starts_with("postgres") { k_core::db::DbType::Postgres } else { k_core::db::DbType::Sqlite }; let db_config = k_core::db::DatabaseConfig { db_type, url: config.database_url.clone(), max_connections: config.db_max_connections, min_connections: config.db_min_connections, acquire_timeout: StdDuration::from_secs(30), }; let db_pool = k_core::db::connect(&db_config).await?; run_migrations(&db_pool).await?; let user_repo = build_user_repository(&db_pool).await?; let channel_repo = build_channel_repository(&db_pool).await?; let schedule_repo = build_schedule_repository(&db_pool).await?; let user_service = UserService::new(user_repo); let channel_service = ChannelService::new(channel_repo.clone()); // Build provider registry — all configured providers are registered simultaneously. #[cfg(feature = "local-files")] let mut local_index: Option> = None; #[cfg(feature = "local-files")] let mut transcode_manager: Option> = None; #[cfg(feature = "local-files")] let mut sqlite_pool_for_state: Option = None; let mut registry = infra::ProviderRegistry::new(); #[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 { 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(), transcode_dir: config.transcode_dir.clone(), cleanup_ttl_hours: config.transcode_cleanup_ttl_hours, }; 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; }); // Build TranscodeManager if TRANSCODE_DIR is set. let tm = config.transcode_dir.as_ref().map(|td| { std::fs::create_dir_all(td).ok(); tracing::info!("Transcoding enabled; cache dir: {:?}", td); let tm = infra::TranscodeManager::new(td.clone(), config.transcode_cleanup_ttl_hours); // Load persisted TTL from DB. let tm_clone = Arc::clone(&tm); let pool_clone = sqlite_pool.clone(); tokio::spawn(async move { if let Ok(row) = sqlx::query_as::<_, (i64,)>( "SELECT cleanup_ttl_hours FROM transcode_settings WHERE id = 1", ) .fetch_one(&pool_clone) .await { tm_clone.set_cleanup_ttl(row.0 as u32); } }); tm }); registry.register( "local", Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg, tm.clone())), ); transcode_manager = tm; sqlite_pool_for_state = Some(sqlite_pool.clone()); } else { tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR"); } } 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 (event_tx, event_rx) = tokio::sync::broadcast::channel::(64); let bg_channel_repo = channel_repo.clone(); let webhook_channel_repo = channel_repo.clone(); tokio::spawn(webhook::run_webhook_consumer( event_rx, webhook_channel_repo, reqwest::Client::new(), )); let schedule_engine = ScheduleEngineService::new( Arc::clone(®istry) as Arc, channel_repo, schedule_repo, ); #[allow(unused_mut)] let mut state = AppState::new( user_service, channel_service, schedule_engine, registry, config.clone(), event_tx.clone(), ) .await?; #[cfg(feature = "local-files")] { state.local_index = local_index; state.transcode_manager = transcode_manager; state.sqlite_pool = sqlite_pool_for_state; } let server_config = ServerConfig { cors_origins: config.cors_allowed_origins.clone(), }; let bg_channel_repo_poller = bg_channel_repo.clone(); let bg_schedule_engine = Arc::clone(&state.schedule_engine); tokio::spawn(scheduler::run_auto_scheduler(bg_schedule_engine, bg_channel_repo, event_tx.clone())); let bg_schedule_engine_poller = Arc::clone(&state.schedule_engine); tokio::spawn(poller::run_broadcast_poller( bg_schedule_engine_poller, bg_channel_repo_poller, event_tx, )); let app = Router::new() .nest("/api/v1", routes::api_v1_router()) .with_state(state); let app = apply_standard_middleware(app, &server_config); // Wrap with an outer CorsLayer that includes the custom password headers. // Being outermost it handles OPTIONS preflights before k_core's inner layer. let origins: Vec = config .cors_allowed_origins .iter() .filter_map(|o| o.parse().ok()) .collect(); let cors = CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_methods(AllowMethods::any()) .allow_headers(AllowHeaders::list([ axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, HeaderName::from_static("x-channel-password"), HeaderName::from_static("x-block-password"), ])); let app = app.layer(cors); let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let listener = TcpListener::bind(addr).await?; tracing::info!("🚀 API server running at http://{}", addr); tracing::info!("🔒 Authentication mode: JWT (Bearer token)"); #[cfg(feature = "auth-jwt")] tracing::info!(" ✓ JWT auth enabled"); #[cfg(feature = "auth-oidc")] tracing::info!(" ✓ OIDC integration enabled (stateless cookie state)"); tracing::info!("📝 API endpoints available at /api/v1/..."); axum::serve(listener, app).await?; Ok(()) } /// 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(domain::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(domain::DomainError::InfrastructureError( "No media provider configured.".into(), )) } async fn get_stream_url( &self, _: &domain::MediaItemId, _: &domain::StreamQuality, ) -> domain::DomainResult { Err(domain::DomainError::InfrastructureError( "No media provider configured.".into(), )) } }