From 311fdd40063e5285251d76064cf32493c3c0f496 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 19 Mar 2026 22:54:41 +0100 Subject: [PATCH] feat: multi-instance provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - provider_configs: add id TEXT PK; migrate existing rows (provider_type becomes id) - local_files_index: add provider_id column + index; scope all queries per instance - ProviderConfigRow: add id field; add get_by_id to trait - LocalIndex::new: add provider_id param; all SQL scoped by provider_id - factory: thread provider_id through build_local_files_bundle - AppState.local_index: Option> → HashMap> - admin_providers: restructured routes (POST /admin/providers create, PUT/DELETE /{id}, POST /test) - admin_providers: use row.id as registry key for jellyfin and local_files - files.rescan: optional ?provider= query param - frontend: add id to ProviderConfig, update api/hooks, new multi-instance panel UX --- k-tv-backend/api/src/main.rs | 4 +- k-tv-backend/api/src/provider_registry.rs | 26 +- .../api/src/routes/admin_providers.rs | 149 ++++++-- k-tv-backend/api/src/routes/files.rs | 17 +- k-tv-backend/api/src/state.rs | 8 +- k-tv-backend/domain/src/repositories.rs | 4 +- k-tv-backend/infra/src/factory.rs | 3 +- k-tv-backend/infra/src/local_files/index.rs | 24 +- .../src/provider_config_repository/sqlite.rs | 39 ++- ...0260319000001_multi_provider_instances.sql | 17 + .../components/provider-settings-panel.tsx | 318 ++++++++++++++++-- k-tv-frontend/hooks/use-admin-providers.ts | 35 +- k-tv-frontend/lib/api.ts | 29 +- k-tv-frontend/lib/types.ts | 1 + 14 files changed, 563 insertions(+), 111 deletions(-) create mode 100644 k-tv-backend/migrations_sqlite/20260319000001_multi_provider_instances.sql diff --git a/k-tv-backend/api/src/main.rs b/k-tv-backend/api/src/main.rs index 9af9b6d..06b3623 100644 --- a/k-tv-backend/api/src/main.rs +++ b/k-tv-backend/api/src/main.rs @@ -99,8 +99,8 @@ async fn main() -> anyhow::Result<()> { .await?; #[cfg(feature = "local-files")] - if let Some(idx) = bundle.local_index { - *state.local_index.write().await = Some(idx); + if !bundle.local_index.is_empty() { + *state.local_index.write().await = bundle.local_index; } #[cfg(feature = "local-files")] if let Some(tm) = bundle.transcode_manager { diff --git a/k-tv-backend/api/src/provider_registry.rs b/k-tv-backend/api/src/provider_registry.rs index 5fb1589..b39f9b3 100644 --- a/k-tv-backend/api/src/provider_registry.rs +++ b/k-tv-backend/api/src/provider_registry.rs @@ -14,7 +14,7 @@ use infra::factory::build_transcode_settings_repository; pub struct ProviderBundle { pub registry: Arc, #[cfg(feature = "local-files")] - pub local_index: Option>, + pub local_index: std::collections::HashMap>, #[cfg(feature = "local-files")] pub transcode_manager: Option>, } @@ -26,7 +26,7 @@ pub async fn build_provider_registry( provider_config_repo: &Arc, ) -> anyhow::Result { #[cfg(feature = "local-files")] - let mut local_index: Option> = None; + let mut local_index: std::collections::HashMap> = std::collections::HashMap::new(); #[cfg(feature = "local-files")] let mut transcode_manager: Option> = None; @@ -41,8 +41,8 @@ pub async fn build_provider_registry( #[cfg(feature = "jellyfin")] "jellyfin" => { if let Ok(cfg) = serde_json::from_str::(&row.config_json) { - tracing::info!("Loading Jellyfin provider from DB config"); - registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(cfg))); + tracing::info!("Loading Jellyfin provider [{}] from DB config", row.id); + registry.register(&row.id, Arc::new(infra::JellyfinMediaProvider::new(cfg))); } } #[cfg(feature = "local-files")] @@ -56,19 +56,20 @@ pub async fn build_provider_registry( 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 {:?}", files_dir); + 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"); + 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(); @@ -80,11 +81,13 @@ pub async fn build_provider_registry( } }); } - registry.register("local", bundle.provider); - transcode_manager = bundle.transcode_manager; - local_index = Some(bundle.local_index); + 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: {}", e), + Err(e) => tracing::warn!("Failed to build local-files provider [{}]: {}", row.id, e), } } } @@ -115,6 +118,7 @@ pub async fn build_provider_registry( 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); @@ -133,7 +137,7 @@ pub async fn build_provider_registry( } registry.register("local", bundle.provider); transcode_manager = bundle.transcode_manager; - local_index = Some(bundle.local_index); + local_index.insert("local".to_string(), bundle.local_index); } Err(e) => tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR: {}", e), } diff --git a/k-tv-backend/api/src/routes/admin_providers.rs b/k-tv-backend/api/src/routes/admin_providers.rs index 33a8ee0..2ced640 100644 --- a/k-tv-backend/api/src/routes/admin_providers.rs +++ b/k-tv-backend/api/src/routes/admin_providers.rs @@ -1,6 +1,6 @@ //! Admin provider management routes. //! -//! All routes require an admin user. Allows listing, updating, deleting, and +//! All routes require an admin user. Allows listing, creating, updating, deleting, and //! testing media provider configs stored in the DB. Only available when //! CONFIG_SOURCE=db. @@ -26,14 +26,36 @@ use crate::state::AppState; // DTOs // --------------------------------------------------------------------------- +/// Validate that an instance id is a safe slug (alphanumeric + hyphens, 1-40 chars). +fn is_valid_instance_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 40 + && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + #[derive(Debug, Deserialize)] -pub struct ProviderConfigPayload { +pub struct CreateProviderRequest { + pub id: String, + pub provider_type: String, pub config_json: HashMap, pub enabled: bool, } +#[derive(Debug, Deserialize)] +pub struct UpdateProviderRequest { + pub config_json: HashMap, + pub enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub struct TestProviderRequest { + pub provider_type: String, + pub config_json: HashMap, +} + #[derive(Debug, Serialize)] pub struct ProviderConfigResponse { + pub id: String, pub provider_type: String, pub config_json: HashMap, pub enabled: bool, @@ -51,9 +73,9 @@ pub struct TestResult { pub fn router() -> Router { Router::new() - .route("/", get(list_providers)) - .route("/{type}", put(update_provider).delete(delete_provider)) - .route("/{type}/test", post(test_provider)) + .route("/", get(list_providers).post(create_provider)) + .route("/{id}", put(update_provider).delete(delete_provider)) + .route("/test", post(test_provider)) } // --------------------------------------------------------------------------- @@ -97,6 +119,12 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> { let rows = state.provider_config_repo.get_all().await?; let mut new_registry = infra::ProviderRegistry::new(); + #[cfg(feature = "local-files")] + let mut new_local_index: std::collections::HashMap> = + std::collections::HashMap::new(); + #[cfg(feature = "local-files")] + let mut first_transcode_manager: Option> = None; + for row in &rows { if !row.enabled { continue; @@ -108,7 +136,7 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> { serde_json::from_str::(&row.config_json) { new_registry.register( - "jellyfin", + &row.id, Arc::new(infra::JellyfinMediaProvider::new(cfg)), ); } @@ -144,16 +172,19 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> { transcode_dir, cleanup_ttl_hours, base_url, + &row.id, ).await { Ok(bundle) => { let scan_idx = Arc::clone(&bundle.local_index); tokio::spawn(async move { scan_idx.rescan().await; }); - new_registry.register("local", bundle.provider); - *state.local_index.write().await = Some(bundle.local_index); - *state.transcode_manager.write().await = bundle.transcode_manager; + new_registry.register(&row.id, bundle.provider); + new_local_index.insert(row.id.clone(), bundle.local_index); + if first_transcode_manager.is_none() { + first_transcode_manager = bundle.transcode_manager; + } } Err(e) => { - tracing::warn!("local_files provider requires SQLite; skipping: {}", e); + tracing::warn!("local_files provider [{}] requires SQLite; skipping: {}", row.id, e); continue; } } @@ -167,6 +198,11 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> { } *state.provider_registry.write().await = Arc::new(new_registry); + #[cfg(feature = "local-files")] + { + *state.local_index.write().await = new_local_index; + *state.transcode_manager.write().await = first_transcode_manager; + } Ok(()) } @@ -187,6 +223,7 @@ pub async fn list_providers( let response: Vec = rows .iter() .map(|row| ProviderConfigResponse { + id: row.id.clone(), provider_type: row.provider_type.clone(), config_json: mask_config(&row.config_json), enabled: row.enabled, @@ -196,29 +233,49 @@ pub async fn list_providers( Ok(Json(response)) } -pub async fn update_provider( +pub async fn create_provider( State(state): State, AdminUser(_user): AdminUser, - Path(provider_type): Path, - Json(payload): Json, + Json(payload): Json, ) -> Result { if state.config.config_source != ConfigSource::Db { return Ok(conflict_response().into_response()); } - let known = matches!(provider_type.as_str(), "jellyfin" | "local_files"); + if !is_valid_instance_id(&payload.id) { + return Err(ApiError::Validation( + "Instance id must be 1-40 alphanumeric+hyphen characters".to_string(), + )); + } + + let known = matches!(payload.provider_type.as_str(), "jellyfin" | "local_files"); if !known { return Err(ApiError::Validation(format!( "Unknown provider type: {}", - provider_type + payload.provider_type ))); } + // Check for uniqueness + if state + .provider_config_repo + .get_by_id(&payload.id) + .await + .map_err(ApiError::from)? + .is_some() + { + return Ok(( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": format!("Provider instance '{}' already exists", payload.id) })), + ).into_response()); + } + let config_json = serde_json::to_string(&payload.config_json) .map_err(|e| ApiError::Internal(format!("Failed to serialize config: {}", e)))?; let row = ProviderConfigRow { - provider_type: provider_type.clone(), + id: payload.id.clone(), + provider_type: payload.provider_type.clone(), config_json: config_json.clone(), enabled: payload.enabled, updated_at: chrono::Utc::now().to_rfc3339(), @@ -235,7 +292,56 @@ pub async fn update_provider( .map_err(ApiError::from)?; let response = ProviderConfigResponse { - provider_type, + id: payload.id, + provider_type: payload.provider_type, + config_json: mask_config(&config_json), + enabled: payload.enabled, + }; + + Ok((StatusCode::CREATED, Json(response)).into_response()) +} + +pub async fn update_provider( + State(state): State, + AdminUser(_user): AdminUser, + Path(instance_id): Path, + Json(payload): Json, +) -> Result { + if state.config.config_source != ConfigSource::Db { + return Ok(conflict_response().into_response()); + } + + let existing = state + .provider_config_repo + .get_by_id(&instance_id) + .await + .map_err(ApiError::from)? + .ok_or_else(|| ApiError::NotFound(format!("Provider instance '{}' not found", instance_id)))?; + + let config_json = serde_json::to_string(&payload.config_json) + .map_err(|e| ApiError::Internal(format!("Failed to serialize config: {}", e)))?; + + let row = ProviderConfigRow { + id: existing.id.clone(), + provider_type: existing.provider_type.clone(), + config_json: config_json.clone(), + enabled: payload.enabled, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + + state + .provider_config_repo + .upsert(&row) + .await + .map_err(ApiError::from)?; + + rebuild_registry(&state) + .await + .map_err(ApiError::from)?; + + let response = ProviderConfigResponse { + id: existing.id, + provider_type: existing.provider_type, config_json: mask_config(&config_json), enabled: payload.enabled, }; @@ -246,7 +352,7 @@ pub async fn update_provider( pub async fn delete_provider( State(state): State, AdminUser(_user): AdminUser, - Path(provider_type): Path, + Path(instance_id): Path, ) -> Result { if state.config.config_source != ConfigSource::Db { return Ok(conflict_response().into_response()); @@ -254,7 +360,7 @@ pub async fn delete_provider( state .provider_config_repo - .delete(&provider_type) + .delete(&instance_id) .await .map_err(ApiError::from)?; @@ -268,10 +374,9 @@ pub async fn delete_provider( pub async fn test_provider( State(_state): State, AdminUser(_user): AdminUser, - Path(provider_type): Path, - Json(payload): Json, + Json(payload): Json, ) -> Result { - let result = match provider_type.as_str() { + let result = match payload.provider_type.as_str() { "jellyfin" => test_jellyfin(&payload.config_json).await, "local_files" => test_local_files(&payload.config_json), _ => TestResult { diff --git a/k-tv-backend/api/src/routes/files.rs b/k-tv-backend/api/src/routes/files.rs index 53db56d..130e8af 100644 --- a/k-tv-backend/api/src/routes/files.rs +++ b/k-tv-backend/api/src/routes/files.rs @@ -22,6 +22,7 @@ use crate::{error::ApiError, state::AppState}; #[cfg(feature = "local-files")] use axum::{ Json, + extract::Query, http::StatusCode, routing::{delete, post}, }; @@ -143,13 +144,25 @@ async fn stream_file( // Rescan // ============================================================================ +#[cfg(feature = "local-files")] +#[derive(Deserialize)] +struct RescanQuery { + provider: Option, +} + #[cfg(feature = "local-files")] async fn trigger_rescan( State(state): State, CurrentUser(_user): CurrentUser, + Query(query): Query, ) -> Result, ApiError> { - let index = state.local_index.read().await.clone() - .ok_or_else(|| ApiError::not_implemented("no local files provider active"))?; + let map = state.local_index.read().await.clone(); + let index = if let Some(id) = &query.provider { + map.get(id).cloned() + } else { + map.values().next().cloned() + }; + let index = index.ok_or_else(|| ApiError::not_implemented("no local files provider active"))?; let count = index.rescan().await; Ok(Json(serde_json::json!({ "items_found": count }))) } diff --git a/k-tv-backend/api/src/state.rs b/k-tv-backend/api/src/state.rs index a5a543a..64e37f9 100644 --- a/k-tv-backend/api/src/state.rs +++ b/k-tv-backend/api/src/state.rs @@ -9,6 +9,8 @@ use infra::auth::jwt::{JwtConfig, JwtValidator}; #[cfg(feature = "auth-oidc")] use infra::auth::oidc::OidcService; use std::collections::VecDeque; +#[cfg(feature = "local-files")] +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; @@ -40,9 +42,9 @@ pub struct AppState { pub log_history: Arc>>, /// Repository for persisted in-app activity events. pub activity_log_repo: Arc, - /// Index for the local-files provider, used by the rescan route. + /// Indexes for local-files provider instances, keyed by provider instance id. #[cfg(feature = "local-files")] - pub local_index: Arc>>>, + pub local_index: Arc>>>, /// TranscodeManager for FFmpeg HLS transcoding (requires TRANSCODE_DIR). #[cfg(feature = "local-files")] pub transcode_manager: Arc>>>, @@ -147,7 +149,7 @@ impl AppState { log_history, activity_log_repo, #[cfg(feature = "local-files")] - local_index: Arc::new(tokio::sync::RwLock::new(None)), + local_index: Arc::new(tokio::sync::RwLock::new(HashMap::new())), #[cfg(feature = "local-files")] transcode_manager: Arc::new(tokio::sync::RwLock::new(None)), #[cfg(feature = "local-files")] diff --git a/k-tv-backend/domain/src/repositories.rs b/k-tv-backend/domain/src/repositories.rs index a34ce45..b1a3cbb 100644 --- a/k-tv-backend/domain/src/repositories.rs +++ b/k-tv-backend/domain/src/repositories.rs @@ -48,6 +48,7 @@ pub trait UserRepository: Send + Sync { #[derive(Debug, Clone)] pub struct ProviderConfigRow { + pub id: String, pub provider_type: String, pub config_json: String, pub enabled: bool, @@ -57,8 +58,9 @@ pub struct ProviderConfigRow { #[async_trait] pub trait ProviderConfigRepository: Send + Sync { async fn get_all(&self) -> DomainResult>; + async fn get_by_id(&self, id: &str) -> DomainResult>; async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()>; - async fn delete(&self, provider_type: &str) -> DomainResult<()>; + async fn delete(&self, id: &str) -> DomainResult<()>; } /// Repository port for `Channel` persistence. diff --git a/k-tv-backend/infra/src/factory.rs b/k-tv-backend/infra/src/factory.rs index c463789..472a495 100644 --- a/k-tv-backend/infra/src/factory.rs +++ b/k-tv-backend/infra/src/factory.rs @@ -133,6 +133,7 @@ pub async fn build_local_files_bundle( transcode_dir: Option, cleanup_ttl_hours: u32, base_url: String, + provider_id: &str, ) -> FactoryResult { match pool { #[cfg(feature = "sqlite")] @@ -143,7 +144,7 @@ pub async fn build_local_files_bundle( transcode_dir: transcode_dir.clone(), cleanup_ttl_hours, }; - let idx = Arc::new(crate::LocalIndex::new(&cfg, sqlite_pool.clone()).await); + let idx = Arc::new(crate::LocalIndex::new(&cfg, sqlite_pool.clone(), provider_id.to_string()).await); let tm = transcode_dir.as_ref().map(|td| { std::fs::create_dir_all(td).ok(); crate::TranscodeManager::new(td.clone(), cleanup_ttl_hours) diff --git a/k-tv-backend/infra/src/local_files/index.rs b/k-tv-backend/infra/src/local_files/index.rs index b99f3fa..8139576 100644 --- a/k-tv-backend/infra/src/local_files/index.rs +++ b/k-tv-backend/infra/src/local_files/index.rs @@ -36,15 +36,17 @@ pub fn decode_id(id: &MediaItemId) -> Option { pub struct LocalIndex { items: Arc>>, pub root_dir: PathBuf, + provider_id: String, pool: sqlx::SqlitePool, } impl LocalIndex { /// Create the index, immediately loading persisted entries from SQLite. - pub async fn new(config: &LocalFilesConfig, pool: sqlx::SqlitePool) -> Self { + pub async fn new(config: &LocalFilesConfig, pool: sqlx::SqlitePool, provider_id: String) -> Self { let idx = Self { items: Arc::new(RwLock::new(HashMap::new())), root_dir: config.root_dir.clone(), + provider_id, pool, }; idx.load_from_db().await; @@ -65,8 +67,10 @@ impl LocalIndex { } let rows = sqlx::query_as::<_, Row>( - "SELECT id, rel_path, title, duration_secs, year, tags, top_dir FROM local_files_index", + "SELECT id, rel_path, title, duration_secs, year, tags, top_dir \ + FROM local_files_index WHERE provider_id = ?", ) + .bind(&self.provider_id) .fetch_all(&self.pool) .await; @@ -86,7 +90,7 @@ impl LocalIndex { }; map.insert(MediaItemId::new(row.id), item); } - info!("Local files index: loaded {} items from DB", map.len()); + info!("Local files index [{}]: loaded {} items from DB", self.provider_id, map.len()); } Err(e) => { // Table might not exist yet on first run — that's fine. @@ -100,7 +104,7 @@ impl LocalIndex { /// Returns the number of items found. Called on startup (background task) /// and via `POST /files/rescan`. pub async fn rescan(&self) -> u32 { - info!("Local files: scanning {:?}", self.root_dir); + info!("Local files [{}]: scanning {:?}", self.provider_id, self.root_dir); let new_items = scan_dir(&self.root_dir).await; let count = new_items.len() as u32; @@ -119,15 +123,16 @@ impl LocalIndex { error!("Failed to persist local files index: {}", e); } - info!("Local files: indexed {} items", count); + info!("Local files [{}]: indexed {} items", self.provider_id, count); count } async fn save_to_db(&self, items: &[LocalFileItem]) -> Result<(), sqlx::Error> { - // Rebuild the table in one transaction. + // Rebuild the table in one transaction, scoped to this provider. let mut tx = self.pool.begin().await?; - sqlx::query("DELETE FROM local_files_index") + sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?") + .bind(&self.provider_id) .execute(&mut *tx) .await?; @@ -137,8 +142,8 @@ impl LocalIndex { let tags_json = serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into()); sqlx::query( "INSERT INTO local_files_index \ - (id, rel_path, title, duration_secs, year, tags, top_dir, scanned_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (id, rel_path, title, duration_secs, year, tags, top_dir, scanned_at, provider_id) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&item.rel_path) @@ -148,6 +153,7 @@ impl LocalIndex { .bind(&tags_json) .bind(&item.top_dir) .bind(&now) + .bind(&self.provider_id) .execute(&mut *tx) .await?; } diff --git a/k-tv-backend/infra/src/provider_config_repository/sqlite.rs b/k-tv-backend/infra/src/provider_config_repository/sqlite.rs index 0294e56..29ae5dc 100644 --- a/k-tv-backend/infra/src/provider_config_repository/sqlite.rs +++ b/k-tv-backend/infra/src/provider_config_repository/sqlite.rs @@ -15,8 +15,8 @@ impl SqliteProviderConfigRepository { #[async_trait] impl ProviderConfigRepository for SqliteProviderConfigRepository { async fn get_all(&self) -> DomainResult> { - let rows: Vec<(String, String, i64, String)> = sqlx::query_as( - "SELECT provider_type, config_json, enabled, updated_at FROM provider_configs", + let rows: Vec<(String, String, String, i64, String)> = sqlx::query_as( + "SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs", ) .fetch_all(&self.pool) .await @@ -24,7 +24,8 @@ impl ProviderConfigRepository for SqliteProviderConfigRepository { Ok(rows .into_iter() - .map(|(provider_type, config_json, enabled, updated_at)| ProviderConfigRow { + .map(|(id, provider_type, config_json, enabled, updated_at)| ProviderConfigRow { + id, provider_type, config_json, enabled: enabled != 0, @@ -33,15 +34,35 @@ impl ProviderConfigRepository for SqliteProviderConfigRepository { .collect()) } + async fn get_by_id(&self, id: &str) -> DomainResult> { + let row: Option<(String, String, String, i64, String)> = sqlx::query_as( + "SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::RepositoryError(e.to_string()))?; + + Ok(row.map(|(id, provider_type, config_json, enabled, updated_at)| ProviderConfigRow { + id, + provider_type, + config_json, + enabled: enabled != 0, + updated_at, + })) + } + async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> { sqlx::query( - r#"INSERT INTO provider_configs (provider_type, config_json, enabled, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(provider_type) DO UPDATE SET + r#"INSERT INTO provider_configs (id, provider_type, config_json, enabled, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + provider_type = excluded.provider_type, config_json = excluded.config_json, enabled = excluded.enabled, updated_at = excluded.updated_at"#, ) + .bind(&row.id) .bind(&row.provider_type) .bind(&row.config_json) .bind(row.enabled as i64) @@ -52,9 +73,9 @@ impl ProviderConfigRepository for SqliteProviderConfigRepository { Ok(()) } - async fn delete(&self, provider_type: &str) -> DomainResult<()> { - sqlx::query("DELETE FROM provider_configs WHERE provider_type = ?") - .bind(provider_type) + async fn delete(&self, id: &str) -> DomainResult<()> { + sqlx::query("DELETE FROM provider_configs WHERE id = ?") + .bind(id) .execute(&self.pool) .await .map_err(|e| DomainError::RepositoryError(e.to_string()))?; diff --git a/k-tv-backend/migrations_sqlite/20260319000001_multi_provider_instances.sql b/k-tv-backend/migrations_sqlite/20260319000001_multi_provider_instances.sql new file mode 100644 index 0000000..4df9069 --- /dev/null +++ b/k-tv-backend/migrations_sqlite/20260319000001_multi_provider_instances.sql @@ -0,0 +1,17 @@ +-- Recreate provider_configs with per-instance id as PK +CREATE TABLE provider_configs_new ( + id TEXT PRIMARY KEY, + provider_type TEXT NOT NULL, + config_json TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL +); +INSERT INTO provider_configs_new (id, provider_type, config_json, enabled, updated_at) + SELECT provider_type, provider_type, config_json, enabled, updated_at + FROM provider_configs; +DROP TABLE provider_configs; +ALTER TABLE provider_configs_new RENAME TO provider_configs; + +-- Scope local_files_index entries by provider instance +ALTER TABLE local_files_index ADD COLUMN provider_id TEXT NOT NULL DEFAULT 'local'; +CREATE INDEX IF NOT EXISTS idx_local_files_provider ON local_files_index(provider_id); diff --git a/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx b/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx index a05bf95..86abb6d 100644 --- a/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx +++ b/k-tv-frontend/app/(main)/admin/components/provider-settings-panel.tsx @@ -1,15 +1,36 @@ "use client"; import { useState } from "react"; -import { useProviderConfigs, useUpdateProvider, useTestProvider } from "@/hooks/use-admin-providers"; +import { + useProviderConfigs, + useCreateProvider, + useUpdateProvider, + useDeleteProvider, + useTestProvider, +} from "@/hooks/use-admin-providers"; import { useConfig } from "@/hooks/use-config"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { CheckCircle, XCircle, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CheckCircle, XCircle, Loader2, Plus, Trash2 } from "lucide-react"; import { ApiRequestError } from "@/lib/api"; +import type { ProviderConfig } from "@/lib/types"; const PROVIDER_FIELDS: Record< string, @@ -27,28 +48,37 @@ const PROVIDER_FIELDS: Record< ], }; -interface ProviderCardProps { - providerType: string; - existingConfig?: { config_json: Record; enabled: boolean }; +function isValidInstanceId(id: string): boolean { + return id.length >= 1 && id.length <= 40 && /^[a-zA-Z0-9-]+$/.test(id); } -function ProviderCard({ providerType, existingConfig }: ProviderCardProps) { - const fields = PROVIDER_FIELDS[providerType] ?? []; +// --------------------------------------------------------------------------- +// Existing instance card +// --------------------------------------------------------------------------- + +interface ProviderCardProps { + config: ProviderConfig; + existingIds: string[]; +} + +function ProviderCard({ config }: ProviderCardProps) { + const fields = PROVIDER_FIELDS[config.provider_type] ?? []; const [formValues, setFormValues] = useState>( - () => existingConfig?.config_json ?? {}, + () => config.config_json ?? {}, ); - const [enabled, setEnabled] = useState(existingConfig?.enabled ?? true); + const [enabled, setEnabled] = useState(config.enabled); const [conflictError, setConflictError] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); const updateProvider = useUpdateProvider(); + const deleteProvider = useDeleteProvider(); const testProvider = useTestProvider(); const handleSave = async () => { setConflictError(false); try { await updateProvider.mutateAsync({ - type: providerType, + id: config.id, payload: { config_json: formValues, enabled }, }); } catch (e: unknown) { @@ -61,21 +91,44 @@ function ProviderCard({ providerType, existingConfig }: ProviderCardProps) { const handleTest = async () => { setTestResult(null); const result = await testProvider.mutateAsync({ - type: providerType, - payload: { config_json: formValues, enabled: true }, + provider_type: config.provider_type, + config_json: formValues, }); setTestResult(result); }; + const handleDelete = async () => { + if (!confirm(`Delete provider instance "${config.id}"?`)) return; + await deleteProvider.mutateAsync(config.id); + }; + return ( - - {providerType.replace("_", " ")} - +
+ + {config.id} + + + {config.provider_type.replace("_", " ")} + +
Enabled +
@@ -145,36 +198,241 @@ function ProviderCard({ providerType, existingConfig }: ProviderCardProps) { ); } +// --------------------------------------------------------------------------- +// Add Instance dialog +// --------------------------------------------------------------------------- + +interface AddInstanceDialogProps { + open: boolean; + onClose: () => void; + availableTypes: string[]; + existingIds: string[]; +} + +function AddInstanceDialog({ open, onClose, availableTypes, existingIds }: AddInstanceDialogProps) { + const [instanceId, setInstanceId] = useState(""); + const [providerType, setProviderType] = useState(availableTypes[0] ?? ""); + const [formValues, setFormValues] = useState>({}); + const [idError, setIdError] = useState(null); + const [apiError, setApiError] = useState(null); + + const createProvider = useCreateProvider(); + const testProvider = useTestProvider(); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + const fields = PROVIDER_FIELDS[providerType] ?? []; + + const handleTypeChange = (t: string) => { + setProviderType(t); + setFormValues({}); + setTestResult(null); + }; + + const validateId = (id: string): string | null => { + if (!id) return "ID is required"; + if (!isValidInstanceId(id)) return "Only alphanumeric characters and hyphens, 1–40 chars"; + if (existingIds.includes(id)) return "An instance with this ID already exists"; + return null; + }; + + const handleCreate = async () => { + const err = validateId(instanceId); + if (err) { setIdError(err); return; } + setIdError(null); + setApiError(null); + try { + await createProvider.mutateAsync({ + id: instanceId, + provider_type: providerType, + config_json: formValues, + enabled: true, + }); + onClose(); + setInstanceId(""); + setFormValues({}); + setTestResult(null); + } catch (e: unknown) { + if (e instanceof ApiRequestError && e.status === 409) { + setIdError("An instance with this ID already exists"); + } else if (e instanceof ApiRequestError) { + setApiError(e.message); + } + } + }; + + const handleTest = async () => { + setTestResult(null); + const result = await testProvider.mutateAsync({ + provider_type: providerType, + config_json: formValues, + }); + setTestResult(result); + }; + + return ( + { if (!v) onClose(); }}> + + + Add Provider Instance + +
+
+ + { + setInstanceId(e.target.value); + setIdError(null); + }} + placeholder="e.g. jellyfin-main" + className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100 font-mono" + /> + {idError &&

{idError}

} +

Alphanumeric + hyphens, 1–40 chars

+
+ +
+ + +
+ + {fields.map((field) => ( +
+ + + setFormValues((prev) => ({ ...prev, [field.key]: e.target.value })) + } + placeholder={ + field.type === "password" ? "••••••••" : `Enter ${field.label.toLowerCase()}` + } + className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100" + /> +
+ ))} + + {testResult && ( +
+ {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} +
+ )} + + {apiError && ( +

{apiError}

+ )} + +
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + export function ProviderSettingsPanel() { const { data: config } = useConfig(); const { data: providerConfigs = [] } = useProviderConfigs(); + const [addOpen, setAddOpen] = useState(false); const availableTypes = config?.available_provider_types ?? []; + const existingIds = providerConfigs.map((c) => c.id); return (
-
-

Provider Configuration

-

- Configure media providers. Requires CONFIG_SOURCE=db on the server. -

+
+
+

Provider Instances

+

+ Manage media provider instances. Requires CONFIG_SOURCE=db on the server. +

+
+ {availableTypes.length > 0 && ( + + )}
+ {availableTypes.length === 0 ? (

No providers available in this build.

+ ) : providerConfigs.length === 0 ? ( +

+ No provider instances configured. Click "Add Instance" to get started. +

) : (
- {availableTypes.map((type) => { - const existing = providerConfigs.find((c) => c.provider_type === type); - return ( - - ); - })} + {providerConfigs.map((c) => ( + + ))}
)} + + setAddOpen(false)} + availableTypes={availableTypes} + existingIds={existingIds} + />
); } diff --git a/k-tv-frontend/hooks/use-admin-providers.ts b/k-tv-frontend/hooks/use-admin-providers.ts index 686fcff..8876647 100644 --- a/k-tv-frontend/hooks/use-admin-providers.ts +++ b/k-tv-frontend/hooks/use-admin-providers.ts @@ -13,17 +13,31 @@ export function useProviderConfigs() { }); } +export function useCreateProvider() { + const { token } = useAuthContext(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + id: string; + provider_type: string; + config_json: Record; + enabled: boolean; + }) => api.admin.providers.createProvider(token!, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }), + }); +} + export function useUpdateProvider() { const { token } = useAuthContext(); const qc = useQueryClient(); return useMutation({ mutationFn: ({ - type, + id, payload, }: { - type: string; + id: string; payload: { config_json: Record; enabled: boolean }; - }) => api.admin.providers.updateProvider(token!, type, payload), + }) => api.admin.providers.updateProvider(token!, id, payload), onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }), }); } @@ -32,8 +46,8 @@ export function useDeleteProvider() { const { token } = useAuthContext(); const qc = useQueryClient(); return useMutation({ - mutationFn: (type: string) => - api.admin.providers.deleteProvider(token!, type), + mutationFn: (id: string) => + api.admin.providers.deleteProvider(token!, id), onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }), }); } @@ -41,12 +55,9 @@ export function useDeleteProvider() { export function useTestProvider() { const { token } = useAuthContext(); return useMutation({ - mutationFn: ({ - type, - payload, - }: { - type: string; - payload: { config_json: Record; enabled: boolean }; - }) => api.admin.providers.testProvider(token!, type, payload), + mutationFn: (payload: { + provider_type: string; + config_json: Record; + }) => api.admin.providers.testProvider(token!, payload), }); } diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts index 6af1182..93b1fd3 100644 --- a/k-tv-frontend/lib/api.ts +++ b/k-tv-frontend/lib/api.ts @@ -238,8 +238,10 @@ export const api = { }, files: { - rescan: (token: string) => - request<{ items_found: number }>("/files/rescan", { method: "POST", token }), + rescan: (token: string, provider?: string) => { + const qs = provider ? `?provider=${encodeURIComponent(provider)}` : ""; + return request<{ items_found: number }>(`/files/rescan${qs}`, { method: "POST", token }); + }, }, transcode: { @@ -268,26 +270,35 @@ export const api = { getProviders: (token: string) => request("/admin/providers", { token }), + createProvider: ( + token: string, + payload: { id: string; provider_type: string; config_json: Record; enabled: boolean }, + ) => + request("/admin/providers", { + method: "POST", + body: JSON.stringify(payload), + token, + }), + updateProvider: ( token: string, - type: string, + id: string, payload: { config_json: Record; enabled: boolean }, ) => - request(`/admin/providers/${type}`, { + request(`/admin/providers/${id}`, { method: "PUT", body: JSON.stringify(payload), token, }), - deleteProvider: (token: string, type: string) => - request(`/admin/providers/${type}`, { method: "DELETE", token }), + deleteProvider: (token: string, id: string) => + request(`/admin/providers/${id}`, { method: "DELETE", token }), testProvider: ( token: string, - type: string, - payload: { config_json: Record; enabled: boolean }, + payload: { provider_type: string; config_json: Record }, ) => - request(`/admin/providers/${type}/test`, { + request("/admin/providers/test", { method: "POST", body: JSON.stringify(payload), token, diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index 9741d3d..ecb487c 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -162,6 +162,7 @@ export interface ConfigResponse { } export interface ProviderConfig { + id: string; provider_type: string; config_json: Record; enabled: boolean;