feat: ConfigSource enum, RwLock provider_registry, is_admin in UserResponse, available_provider_types

This commit is contained in:
2026-03-16 03:30:44 +01:00
parent 0e51b7c0f1
commit 46333853d2
8 changed files with 131 additions and 78 deletions

View File

@@ -5,9 +5,16 @@
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigSource {
Env,
Db,
}
/// Application configuration loaded from environment variables /// Application configuration loaded from environment variables
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub config_source: ConfigSource,
pub database_url: String, pub database_url: String,
pub cookie_secret: String, pub cookie_secret: String,
pub cors_allowed_origins: Vec<String>, pub cors_allowed_origins: Vec<String>,
@@ -134,7 +141,13 @@ impl Config {
let base_url = env::var("BASE_URL") let base_url = env::var("BASE_URL")
.unwrap_or_else(|_| format!("http://localhost:{}", port)); .unwrap_or_else(|_| format!("http://localhost:{}", port));
let config_source = match env::var("CONFIG_SOURCE").as_deref() {
Ok("db") | Ok("DB") => ConfigSource::Db,
_ => ConfigSource::Env,
};
Self { Self {
config_source,
host, host,
port, port,
database_url, database_url,

View File

@@ -32,6 +32,7 @@ pub struct UserResponse {
pub id: Uuid, pub id: Uuid,
pub email: String, pub email: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub is_admin: bool,
} }
/// JWT token response /// JWT token response
@@ -57,6 +58,8 @@ pub struct ConfigResponse {
pub providers: Vec<ProviderInfo>, pub providers: Vec<ProviderInfo>,
/// Capabilities of the primary provider — kept for backward compatibility. /// Capabilities of the primary provider — kept for backward compatibility.
pub provider_capabilities: domain::ProviderCapabilities, pub provider_capabilities: domain::ProviderCapabilities,
/// Provider type strings supported by this build (feature-gated).
pub available_provider_types: Vec<String>,
} }
// ============================================================================ // ============================================================================

View File

@@ -15,7 +15,7 @@ use tracing::info;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use domain::{ChannelService, IMediaProvider, IProviderRegistry, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService}; use domain::{ChannelService, IMediaProvider, IProviderRegistry, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService};
use infra::factory::{build_activity_log_repository, build_channel_repository, build_schedule_repository, build_user_repository}; use infra::factory::{build_activity_log_repository, build_channel_repository, build_provider_config_repository, build_schedule_repository, build_user_repository};
use infra::run_migrations; use infra::run_migrations;
use k_core::http::server::{ServerConfig, apply_standard_middleware}; use k_core::http::server::{ServerConfig, apply_standard_middleware};
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -32,7 +32,7 @@ mod scheduler;
mod state; mod state;
mod webhook; mod webhook;
use crate::config::Config; use crate::config::{Config, ConfigSource};
use crate::state::AppState; use crate::state::AppState;
#[tokio::main] #[tokio::main]
@@ -98,6 +98,25 @@ async fn main() -> anyhow::Result<()> {
let mut registry = infra::ProviderRegistry::new(); let mut registry = infra::ProviderRegistry::new();
let provider_config_repo = build_provider_config_repository(&db_pool).await?;
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::<infra::JellyfinConfig>(&row.config_json) {
tracing::info!("Loading Jellyfin provider from DB config");
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(cfg)));
}
}
_ => {}
}
}
} else {
#[cfg(feature = "jellyfin")] #[cfg(feature = "jellyfin")]
if let (Some(base_url), Some(api_key), Some(user_id)) = ( if let (Some(base_url), Some(api_key), Some(user_id)) = (
&config.jellyfin_base_url, &config.jellyfin_base_url,
@@ -158,13 +177,16 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR"); tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR");
} }
} }
}
if registry.is_empty() { if registry.is_empty() {
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR."); tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR.");
registry.register("noop", Arc::new(NoopMediaProvider)); registry.register("noop", Arc::new(NoopMediaProvider));
} }
let registry = Arc::new(registry); let registry_arc = Arc::new(registry);
let provider_registry: Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>> =
Arc::new(tokio::sync::RwLock::new(Arc::clone(&registry_arc)));
let (event_tx, event_rx) = tokio::sync::broadcast::channel::<domain::DomainEvent>(64); let (event_tx, event_rx) = tokio::sync::broadcast::channel::<domain::DomainEvent>(64);
@@ -177,7 +199,7 @@ async fn main() -> anyhow::Result<()> {
)); ));
let schedule_engine = ScheduleEngineService::new( let schedule_engine = ScheduleEngineService::new(
Arc::clone(&registry) as Arc<dyn IProviderRegistry>, Arc::clone(&registry_arc) as Arc<dyn IProviderRegistry>,
channel_repo, channel_repo,
schedule_repo, schedule_repo,
); );
@@ -187,7 +209,8 @@ async fn main() -> anyhow::Result<()> {
user_service, user_service,
channel_service, channel_service,
schedule_engine, schedule_engine,
registry, provider_registry,
provider_config_repo,
config.clone(), config.clone(),
event_tx.clone(), event_tx.clone(),
log_tx, log_tx,

View File

@@ -86,6 +86,7 @@ pub(super) async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoRespon
id: user.id, id: user.id,
email: user.email.into_inner(), email: user.email.into_inner(),
created_at: user.created_at, created_at: user.created_at,
is_admin: user.is_admin,
})) }))
} }

View File

@@ -9,21 +9,21 @@ pub fn router() -> Router<AppState> {
} }
async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> { async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
let providers: Vec<ProviderInfo> = state let registry = state.provider_registry.read().await;
.provider_registry
let providers: Vec<ProviderInfo> = registry
.provider_ids() .provider_ids()
.into_iter() .into_iter()
.filter_map(|id| { .filter_map(|id| {
state.provider_registry.capabilities(&id).map(|caps| ProviderInfo { registry.capabilities(&id).map(|caps| ProviderInfo {
id: id.clone(), id: id.clone(),
capabilities: caps, capabilities: caps,
}) })
}) })
.collect(); .collect();
let primary_capabilities = state let primary_capabilities = registry
.provider_registry .capabilities(registry.primary_id())
.capabilities(state.provider_registry.primary_id())
.unwrap_or(ProviderCapabilities { .unwrap_or(ProviderCapabilities {
collections: false, collections: false,
series: false, series: false,
@@ -36,9 +36,16 @@ async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
transcode: false, transcode: false,
}); });
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());
Json(ConfigResponse { Json(ConfigResponse {
allow_registration: state.config.allow_registration, allow_registration: state.config.allow_registration,
providers, providers,
provider_capabilities: primary_capabilities, provider_capabilities: primary_capabilities,
available_provider_types,
}) })
} }

View File

@@ -151,13 +151,14 @@ async fn list_collections(
Query(params): Query<CollectionsQuery>, Query(params): Query<CollectionsQuery>,
) -> Result<Json<Vec<CollectionResponse>>, ApiError> { ) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
let provider_id = params.provider.as_deref().unwrap_or(""); let provider_id = params.provider.as_deref().unwrap_or("");
let caps = state.provider_registry.capabilities(provider_id).ok_or_else(|| { let registry = state.provider_registry.read().await;
let caps = registry.capabilities(provider_id).ok_or_else(|| {
ApiError::validation(format!("Unknown provider '{}'", provider_id)) ApiError::validation(format!("Unknown provider '{}'", provider_id))
})?; })?;
if !caps.collections { if !caps.collections {
return Err(ApiError::not_implemented("collections not supported by this provider")); return Err(ApiError::not_implemented("collections not supported by this provider"));
} }
let collections = state.provider_registry.list_collections(provider_id).await?; let collections = registry.list_collections(provider_id).await?;
Ok(Json(collections.into_iter().map(Into::into).collect())) Ok(Json(collections.into_iter().map(Into::into).collect()))
} }
@@ -168,14 +169,14 @@ async fn list_series(
Query(params): Query<SeriesQuery>, Query(params): Query<SeriesQuery>,
) -> Result<Json<Vec<SeriesResponse>>, ApiError> { ) -> Result<Json<Vec<SeriesResponse>>, ApiError> {
let provider_id = params.provider.as_deref().unwrap_or(""); let provider_id = params.provider.as_deref().unwrap_or("");
let caps = state.provider_registry.capabilities(provider_id).ok_or_else(|| { let registry = state.provider_registry.read().await;
let caps = registry.capabilities(provider_id).ok_or_else(|| {
ApiError::validation(format!("Unknown provider '{}'", provider_id)) ApiError::validation(format!("Unknown provider '{}'", provider_id))
})?; })?;
if !caps.series { if !caps.series {
return Err(ApiError::not_implemented("series not supported by this provider")); return Err(ApiError::not_implemented("series not supported by this provider"));
} }
let series = state let series = registry
.provider_registry
.list_series(provider_id, params.collection.as_deref()) .list_series(provider_id, params.collection.as_deref())
.await?; .await?;
Ok(Json(series.into_iter().map(Into::into).collect())) Ok(Json(series.into_iter().map(Into::into).collect()))
@@ -188,14 +189,15 @@ async fn list_genres(
Query(params): Query<GenresQuery>, Query(params): Query<GenresQuery>,
) -> Result<Json<Vec<String>>, ApiError> { ) -> Result<Json<Vec<String>>, ApiError> {
let provider_id = params.provider.as_deref().unwrap_or(""); let provider_id = params.provider.as_deref().unwrap_or("");
let caps = state.provider_registry.capabilities(provider_id).ok_or_else(|| { let registry = state.provider_registry.read().await;
let caps = registry.capabilities(provider_id).ok_or_else(|| {
ApiError::validation(format!("Unknown provider '{}'", provider_id)) ApiError::validation(format!("Unknown provider '{}'", provider_id))
})?; })?;
if !caps.genres { if !caps.genres {
return Err(ApiError::not_implemented("genres not supported by this provider")); return Err(ApiError::not_implemented("genres not supported by this provider"));
} }
let ct = parse_content_type(params.content_type.as_deref())?; let ct = parse_content_type(params.content_type.as_deref())?;
let genres = state.provider_registry.list_genres(provider_id, ct.as_ref()).await?; let genres = registry.list_genres(provider_id, ct.as_ref()).await?;
Ok(Json(genres)) Ok(Json(genres))
} }
@@ -228,7 +230,8 @@ async fn search_items(
..Default::default() ..Default::default()
}; };
let mut items = state.provider_registry.fetch_items(provider_id, &filter).await?; let registry = state.provider_registry.read().await;
let mut items = registry.fetch_items(provider_id, &filter).await?;
// Apply the same ordering the schedule engine uses so the preview reflects // Apply the same ordering the schedule engine uses so the preview reflects
// what will actually be scheduled rather than raw provider order. // what will actually be scheduled rather than raw provider order.

View File

@@ -15,14 +15,15 @@ use tokio::sync::broadcast;
use crate::config::Config; use crate::config::Config;
use crate::events::EventBus; use crate::events::EventBus;
use crate::log_layer::LogLine; use crate::log_layer::LogLine;
use domain::{ActivityLogRepository, ChannelService, ScheduleEngineService, UserService}; use domain::{ActivityLogRepository, ChannelService, ProviderConfigRepository, ScheduleEngineService, UserService};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub user_service: Arc<UserService>, pub user_service: Arc<UserService>,
pub channel_service: Arc<ChannelService>, pub channel_service: Arc<ChannelService>,
pub schedule_engine: Arc<ScheduleEngineService>, pub schedule_engine: Arc<ScheduleEngineService>,
pub provider_registry: Arc<infra::ProviderRegistry>, pub provider_registry: Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
pub provider_config_repo: Arc<dyn ProviderConfigRepository>,
pub cookie_key: Key, pub cookie_key: Key,
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
pub oidc_service: Option<Arc<OidcService>>, pub oidc_service: Option<Arc<OidcService>>,
@@ -52,7 +53,8 @@ impl AppState {
user_service: UserService, user_service: UserService,
channel_service: ChannelService, channel_service: ChannelService,
schedule_engine: ScheduleEngineService, schedule_engine: ScheduleEngineService,
provider_registry: Arc<infra::ProviderRegistry>, provider_registry: Arc<tokio::sync::RwLock<Arc<infra::ProviderRegistry>>>,
provider_config_repo: Arc<dyn ProviderConfigRepository>,
config: Config, config: Config,
event_tx: EventBus, event_tx: EventBus,
log_tx: broadcast::Sender<LogLine>, log_tx: broadcast::Sender<LogLine>,
@@ -123,6 +125,7 @@ impl AppState {
channel_service: Arc::new(channel_service), channel_service: Arc::new(channel_service),
schedule_engine: Arc::new(schedule_engine), schedule_engine: Arc::new(schedule_engine),
provider_registry, provider_registry,
provider_config_repo,
cookie_key, cookie_key,
#[cfg(feature = "auth-oidc")] #[cfg(feature = "auth-oidc")]
oidc_service, oidc_service,

View File

@@ -1,5 +1,5 @@
/// Connection details for a single Jellyfin instance. /// Connection details for a single Jellyfin instance.
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JellyfinConfig { pub struct JellyfinConfig {
/// e.g. `"http://192.168.1.10:8096"` — no trailing slash /// e.g. `"http://192.168.1.10:8096"` — no trailing slash
pub base_url: String, pub base_url: String,