feat: multi-instance provider support
- 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:🆕 add provider_id param; all SQL scoped by provider_id
- factory: thread provider_id through build_local_files_bundle
- AppState.local_index: Option<Arc<LocalIndex>> → HashMap<String, Arc<LocalIndex>>
- 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=<id> query param
- frontend: add id to ProviderConfig, update api/hooks, new multi-instance panel UX
This commit is contained in:
@@ -99,8 +99,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
if let Some(idx) = bundle.local_index {
|
if !bundle.local_index.is_empty() {
|
||||||
*state.local_index.write().await = Some(idx);
|
*state.local_index.write().await = bundle.local_index;
|
||||||
}
|
}
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
if let Some(tm) = bundle.transcode_manager {
|
if let Some(tm) = bundle.transcode_manager {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use infra::factory::build_transcode_settings_repository;
|
|||||||
pub struct ProviderBundle {
|
pub struct ProviderBundle {
|
||||||
pub registry: Arc<infra::ProviderRegistry>,
|
pub registry: Arc<infra::ProviderRegistry>,
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
pub local_index: Option<Arc<infra::LocalIndex>>,
|
pub local_index: std::collections::HashMap<String, Arc<infra::LocalIndex>>,
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
pub transcode_manager: Option<Arc<infra::TranscodeManager>>,
|
pub transcode_manager: Option<Arc<infra::TranscodeManager>>,
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ pub async fn build_provider_registry(
|
|||||||
provider_config_repo: &Arc<dyn ProviderConfigRepository>,
|
provider_config_repo: &Arc<dyn ProviderConfigRepository>,
|
||||||
) -> anyhow::Result<ProviderBundle> {
|
) -> anyhow::Result<ProviderBundle> {
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
let mut local_index: Option<Arc<infra::LocalIndex>> = None;
|
let mut local_index: std::collections::HashMap<String, Arc<infra::LocalIndex>> = std::collections::HashMap::new();
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
let mut transcode_manager: Option<Arc<infra::TranscodeManager>> = None;
|
let mut transcode_manager: Option<Arc<infra::TranscodeManager>> = None;
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ pub async fn build_provider_registry(
|
|||||||
#[cfg(feature = "jellyfin")]
|
#[cfg(feature = "jellyfin")]
|
||||||
"jellyfin" => {
|
"jellyfin" => {
|
||||||
if let Ok(cfg) = serde_json::from_str::<infra::JellyfinConfig>(&row.config_json) {
|
if let Ok(cfg) = serde_json::from_str::<infra::JellyfinConfig>(&row.config_json) {
|
||||||
tracing::info!("Loading Jellyfin provider from DB config");
|
tracing::info!("Loading Jellyfin provider [{}] from DB config", row.id);
|
||||||
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(cfg)));
|
registry.register(&row.id, Arc::new(infra::JellyfinMediaProvider::new(cfg)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(feature = "local-files")]
|
#[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")
|
let cleanup_ttl_hours: u32 = cfg_map.get("cleanup_ttl_hours")
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(24);
|
.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(
|
match infra::factory::build_local_files_bundle(
|
||||||
db_pool,
|
db_pool,
|
||||||
std::path::PathBuf::from(files_dir),
|
std::path::PathBuf::from(files_dir),
|
||||||
transcode_dir,
|
transcode_dir,
|
||||||
cleanup_ttl_hours,
|
cleanup_ttl_hours,
|
||||||
config.base_url.clone(),
|
config.base_url.clone(),
|
||||||
|
&row.id,
|
||||||
).await {
|
).await {
|
||||||
Ok(bundle) => {
|
Ok(bundle) => {
|
||||||
let scan_idx = Arc::clone(&bundle.local_index);
|
let scan_idx = Arc::clone(&bundle.local_index);
|
||||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||||
if let Some(ref tm) = bundle.transcode_manager {
|
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.
|
// Load persisted TTL override from transcode_settings table.
|
||||||
let tm_clone = Arc::clone(tm);
|
let tm_clone = Arc::clone(tm);
|
||||||
let repo = build_transcode_settings_repository(db_pool).await.ok();
|
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);
|
registry.register(&row.id, bundle.provider);
|
||||||
|
if transcode_manager.is_none() {
|
||||||
transcode_manager = bundle.transcode_manager;
|
transcode_manager = bundle.transcode_manager;
|
||||||
local_index = Some(bundle.local_index);
|
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("Failed to build local-files provider: {}", e),
|
local_index.insert(row.id.clone(), bundle.local_index);
|
||||||
|
}
|
||||||
|
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_dir.clone(),
|
||||||
config.transcode_cleanup_ttl_hours,
|
config.transcode_cleanup_ttl_hours,
|
||||||
config.base_url.clone(),
|
config.base_url.clone(),
|
||||||
|
"local",
|
||||||
).await {
|
).await {
|
||||||
Ok(bundle) => {
|
Ok(bundle) => {
|
||||||
let scan_idx = Arc::clone(&bundle.local_index);
|
let scan_idx = Arc::clone(&bundle.local_index);
|
||||||
@@ -133,7 +137,7 @@ pub async fn build_provider_registry(
|
|||||||
}
|
}
|
||||||
registry.register("local", bundle.provider);
|
registry.register("local", bundle.provider);
|
||||||
transcode_manager = bundle.transcode_manager;
|
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),
|
Err(e) => tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR: {}", e),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Admin provider management routes.
|
//! 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
|
//! testing media provider configs stored in the DB. Only available when
|
||||||
//! CONFIG_SOURCE=db.
|
//! CONFIG_SOURCE=db.
|
||||||
|
|
||||||
@@ -26,14 +26,36 @@ use crate::state::AppState;
|
|||||||
// DTOs
|
// 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ProviderConfigPayload {
|
pub struct CreateProviderRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub provider_type: String,
|
||||||
pub config_json: HashMap<String, String>,
|
pub config_json: HashMap<String, String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateProviderRequest {
|
||||||
|
pub config_json: HashMap<String, String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TestProviderRequest {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub config_json: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ProviderConfigResponse {
|
pub struct ProviderConfigResponse {
|
||||||
|
pub id: String,
|
||||||
pub provider_type: String,
|
pub provider_type: String,
|
||||||
pub config_json: HashMap<String, serde_json::Value>,
|
pub config_json: HashMap<String, serde_json::Value>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -51,9 +73,9 @@ pub struct TestResult {
|
|||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_providers))
|
.route("/", get(list_providers).post(create_provider))
|
||||||
.route("/{type}", put(update_provider).delete(delete_provider))
|
.route("/{id}", put(update_provider).delete(delete_provider))
|
||||||
.route("/{type}/test", post(test_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 rows = state.provider_config_repo.get_all().await?;
|
||||||
let mut new_registry = infra::ProviderRegistry::new();
|
let mut new_registry = infra::ProviderRegistry::new();
|
||||||
|
|
||||||
|
#[cfg(feature = "local-files")]
|
||||||
|
let mut new_local_index: std::collections::HashMap<String, Arc<infra::LocalIndex>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
#[cfg(feature = "local-files")]
|
||||||
|
let mut first_transcode_manager: Option<Arc<infra::TranscodeManager>> = None;
|
||||||
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
if !row.enabled {
|
if !row.enabled {
|
||||||
continue;
|
continue;
|
||||||
@@ -108,7 +136,7 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> {
|
|||||||
serde_json::from_str::<infra::JellyfinConfig>(&row.config_json)
|
serde_json::from_str::<infra::JellyfinConfig>(&row.config_json)
|
||||||
{
|
{
|
||||||
new_registry.register(
|
new_registry.register(
|
||||||
"jellyfin",
|
&row.id,
|
||||||
Arc::new(infra::JellyfinMediaProvider::new(cfg)),
|
Arc::new(infra::JellyfinMediaProvider::new(cfg)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,16 +172,19 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> {
|
|||||||
transcode_dir,
|
transcode_dir,
|
||||||
cleanup_ttl_hours,
|
cleanup_ttl_hours,
|
||||||
base_url,
|
base_url,
|
||||||
|
&row.id,
|
||||||
).await {
|
).await {
|
||||||
Ok(bundle) => {
|
Ok(bundle) => {
|
||||||
let scan_idx = Arc::clone(&bundle.local_index);
|
let scan_idx = Arc::clone(&bundle.local_index);
|
||||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||||
new_registry.register("local", bundle.provider);
|
new_registry.register(&row.id, bundle.provider);
|
||||||
*state.local_index.write().await = Some(bundle.local_index);
|
new_local_index.insert(row.id.clone(), bundle.local_index);
|
||||||
*state.transcode_manager.write().await = bundle.transcode_manager;
|
if first_transcode_manager.is_none() {
|
||||||
|
first_transcode_manager = bundle.transcode_manager;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("local_files provider requires SQLite; skipping: {}", e);
|
tracing::warn!("local_files provider [{}] requires SQLite; skipping: {}", row.id, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +198,11 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
*state.provider_registry.write().await = Arc::new(new_registry);
|
*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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +223,7 @@ pub async fn list_providers(
|
|||||||
let response: Vec<ProviderConfigResponse> = rows
|
let response: Vec<ProviderConfigResponse> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| ProviderConfigResponse {
|
.map(|row| ProviderConfigResponse {
|
||||||
|
id: row.id.clone(),
|
||||||
provider_type: row.provider_type.clone(),
|
provider_type: row.provider_type.clone(),
|
||||||
config_json: mask_config(&row.config_json),
|
config_json: mask_config(&row.config_json),
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
@@ -196,29 +233,49 @@ pub async fn list_providers(
|
|||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_provider(
|
pub async fn create_provider(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Path(provider_type): Path<String>,
|
Json(payload): Json<CreateProviderRequest>,
|
||||||
Json(payload): Json<ProviderConfigPayload>,
|
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
if state.config.config_source != ConfigSource::Db {
|
if state.config.config_source != ConfigSource::Db {
|
||||||
return Ok(conflict_response().into_response());
|
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 {
|
if !known {
|
||||||
return Err(ApiError::Validation(format!(
|
return Err(ApiError::Validation(format!(
|
||||||
"Unknown provider type: {}",
|
"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)
|
let config_json = serde_json::to_string(&payload.config_json)
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize config: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to serialize config: {}", e)))?;
|
||||||
|
|
||||||
let row = ProviderConfigRow {
|
let row = ProviderConfigRow {
|
||||||
provider_type: provider_type.clone(),
|
id: payload.id.clone(),
|
||||||
|
provider_type: payload.provider_type.clone(),
|
||||||
config_json: config_json.clone(),
|
config_json: config_json.clone(),
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
@@ -235,7 +292,56 @@ pub async fn update_provider(
|
|||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
let response = ProviderConfigResponse {
|
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<AppState>,
|
||||||
|
AdminUser(_user): AdminUser,
|
||||||
|
Path(instance_id): Path<String>,
|
||||||
|
Json(payload): Json<UpdateProviderRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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),
|
config_json: mask_config(&config_json),
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
};
|
};
|
||||||
@@ -246,7 +352,7 @@ pub async fn update_provider(
|
|||||||
pub async fn delete_provider(
|
pub async fn delete_provider(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Path(provider_type): Path<String>,
|
Path(instance_id): Path<String>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
if state.config.config_source != ConfigSource::Db {
|
if state.config.config_source != ConfigSource::Db {
|
||||||
return Ok(conflict_response().into_response());
|
return Ok(conflict_response().into_response());
|
||||||
@@ -254,7 +360,7 @@ pub async fn delete_provider(
|
|||||||
|
|
||||||
state
|
state
|
||||||
.provider_config_repo
|
.provider_config_repo
|
||||||
.delete(&provider_type)
|
.delete(&instance_id)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
@@ -268,10 +374,9 @@ pub async fn delete_provider(
|
|||||||
pub async fn test_provider(
|
pub async fn test_provider(
|
||||||
State(_state): State<AppState>,
|
State(_state): State<AppState>,
|
||||||
AdminUser(_user): AdminUser,
|
AdminUser(_user): AdminUser,
|
||||||
Path(provider_type): Path<String>,
|
Json(payload): Json<TestProviderRequest>,
|
||||||
Json(payload): Json<ProviderConfigPayload>,
|
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let result = match provider_type.as_str() {
|
let result = match payload.provider_type.as_str() {
|
||||||
"jellyfin" => test_jellyfin(&payload.config_json).await,
|
"jellyfin" => test_jellyfin(&payload.config_json).await,
|
||||||
"local_files" => test_local_files(&payload.config_json),
|
"local_files" => test_local_files(&payload.config_json),
|
||||||
_ => TestResult {
|
_ => TestResult {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::{error::ApiError, state::AppState};
|
|||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
|
extract::Query,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{delete, post},
|
routing::{delete, post},
|
||||||
};
|
};
|
||||||
@@ -143,13 +144,25 @@ async fn stream_file(
|
|||||||
// Rescan
|
// Rescan
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(feature = "local-files")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RescanQuery {
|
||||||
|
provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
async fn trigger_rescan(
|
async fn trigger_rescan(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Query(query): Query<RescanQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let index = state.local_index.read().await.clone()
|
let map = state.local_index.read().await.clone();
|
||||||
.ok_or_else(|| ApiError::not_implemented("no local files provider active"))?;
|
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;
|
let count = index.rescan().await;
|
||||||
Ok(Json(serde_json::json!({ "items_found": count })))
|
Ok(Json(serde_json::json!({ "items_found": count })))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use infra::auth::jwt::{JwtConfig, JwtValidator};
|
|||||||
#[cfg(feature = "auth-oidc")]
|
#[cfg(feature = "auth-oidc")]
|
||||||
use infra::auth::oidc::OidcService;
|
use infra::auth::oidc::OidcService;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
#[cfg(feature = "local-files")]
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
@@ -40,9 +42,9 @@ pub struct AppState {
|
|||||||
pub log_history: Arc<Mutex<VecDeque<LogLine>>>,
|
pub log_history: Arc<Mutex<VecDeque<LogLine>>>,
|
||||||
/// Repository for persisted in-app activity events.
|
/// Repository for persisted in-app activity events.
|
||||||
pub activity_log_repo: Arc<dyn ActivityLogRepository>,
|
pub activity_log_repo: Arc<dyn ActivityLogRepository>,
|
||||||
/// 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")]
|
#[cfg(feature = "local-files")]
|
||||||
pub local_index: Arc<tokio::sync::RwLock<Option<Arc<infra::LocalIndex>>>>,
|
pub local_index: Arc<tokio::sync::RwLock<HashMap<String, Arc<infra::LocalIndex>>>>,
|
||||||
/// TranscodeManager for FFmpeg HLS transcoding (requires TRANSCODE_DIR).
|
/// TranscodeManager for FFmpeg HLS transcoding (requires TRANSCODE_DIR).
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
pub transcode_manager: Arc<tokio::sync::RwLock<Option<Arc<infra::TranscodeManager>>>>,
|
pub transcode_manager: Arc<tokio::sync::RwLock<Option<Arc<infra::TranscodeManager>>>>,
|
||||||
@@ -147,7 +149,7 @@ impl AppState {
|
|||||||
log_history,
|
log_history,
|
||||||
activity_log_repo,
|
activity_log_repo,
|
||||||
#[cfg(feature = "local-files")]
|
#[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")]
|
#[cfg(feature = "local-files")]
|
||||||
transcode_manager: Arc::new(tokio::sync::RwLock::new(None)),
|
transcode_manager: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProviderConfigRow {
|
pub struct ProviderConfigRow {
|
||||||
|
pub id: String,
|
||||||
pub provider_type: String,
|
pub provider_type: String,
|
||||||
pub config_json: String,
|
pub config_json: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -57,8 +58,9 @@ pub struct ProviderConfigRow {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ProviderConfigRepository: Send + Sync {
|
pub trait ProviderConfigRepository: Send + Sync {
|
||||||
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>>;
|
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>>;
|
||||||
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>>;
|
||||||
async fn upsert(&self, row: &ProviderConfigRow) -> 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.
|
/// Repository port for `Channel` persistence.
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub async fn build_local_files_bundle(
|
|||||||
transcode_dir: Option<std::path::PathBuf>,
|
transcode_dir: Option<std::path::PathBuf>,
|
||||||
cleanup_ttl_hours: u32,
|
cleanup_ttl_hours: u32,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
provider_id: &str,
|
||||||
) -> FactoryResult<LocalFilesBundle> {
|
) -> FactoryResult<LocalFilesBundle> {
|
||||||
match pool {
|
match pool {
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
@@ -143,7 +144,7 @@ pub async fn build_local_files_bundle(
|
|||||||
transcode_dir: transcode_dir.clone(),
|
transcode_dir: transcode_dir.clone(),
|
||||||
cleanup_ttl_hours,
|
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| {
|
let tm = transcode_dir.as_ref().map(|td| {
|
||||||
std::fs::create_dir_all(td).ok();
|
std::fs::create_dir_all(td).ok();
|
||||||
crate::TranscodeManager::new(td.clone(), cleanup_ttl_hours)
|
crate::TranscodeManager::new(td.clone(), cleanup_ttl_hours)
|
||||||
|
|||||||
@@ -36,15 +36,17 @@ pub fn decode_id(id: &MediaItemId) -> Option<String> {
|
|||||||
pub struct LocalIndex {
|
pub struct LocalIndex {
|
||||||
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
|
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
|
||||||
pub root_dir: PathBuf,
|
pub root_dir: PathBuf,
|
||||||
|
provider_id: String,
|
||||||
pool: sqlx::SqlitePool,
|
pool: sqlx::SqlitePool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalIndex {
|
impl LocalIndex {
|
||||||
/// Create the index, immediately loading persisted entries from SQLite.
|
/// 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 {
|
let idx = Self {
|
||||||
items: Arc::new(RwLock::new(HashMap::new())),
|
items: Arc::new(RwLock::new(HashMap::new())),
|
||||||
root_dir: config.root_dir.clone(),
|
root_dir: config.root_dir.clone(),
|
||||||
|
provider_id,
|
||||||
pool,
|
pool,
|
||||||
};
|
};
|
||||||
idx.load_from_db().await;
|
idx.load_from_db().await;
|
||||||
@@ -65,8 +67,10 @@ impl LocalIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, Row>(
|
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)
|
.fetch_all(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -86,7 +90,7 @@ impl LocalIndex {
|
|||||||
};
|
};
|
||||||
map.insert(MediaItemId::new(row.id), item);
|
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) => {
|
Err(e) => {
|
||||||
// Table might not exist yet on first run — that's fine.
|
// 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)
|
/// Returns the number of items found. Called on startup (background task)
|
||||||
/// and via `POST /files/rescan`.
|
/// and via `POST /files/rescan`.
|
||||||
pub async fn rescan(&self) -> u32 {
|
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 new_items = scan_dir(&self.root_dir).await;
|
||||||
let count = new_items.len() as u32;
|
let count = new_items.len() as u32;
|
||||||
|
|
||||||
@@ -119,15 +123,16 @@ impl LocalIndex {
|
|||||||
error!("Failed to persist local files index: {}", e);
|
error!("Failed to persist local files index: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Local files: indexed {} items", count);
|
info!("Local files [{}]: indexed {} items", self.provider_id, count);
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_to_db(&self, items: &[LocalFileItem]) -> Result<(), sqlx::Error> {
|
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?;
|
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)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -137,8 +142,8 @@ impl LocalIndex {
|
|||||||
let tags_json = serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into());
|
let tags_json = serde_json::to_string(&item.tags).unwrap_or_else(|_| "[]".into());
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO local_files_index \
|
"INSERT INTO local_files_index \
|
||||||
(id, rel_path, title, duration_secs, year, tags, top_dir, scanned_at) \
|
(id, rel_path, title, duration_secs, year, tags, top_dir, scanned_at, provider_id) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&item.rel_path)
|
.bind(&item.rel_path)
|
||||||
@@ -148,6 +153,7 @@ impl LocalIndex {
|
|||||||
.bind(&tags_json)
|
.bind(&tags_json)
|
||||||
.bind(&item.top_dir)
|
.bind(&item.top_dir)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(&self.provider_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ impl SqliteProviderConfigRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProviderConfigRepository for SqliteProviderConfigRepository {
|
impl ProviderConfigRepository for SqliteProviderConfigRepository {
|
||||||
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
|
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
|
||||||
let rows: Vec<(String, String, i64, String)> = sqlx::query_as(
|
let rows: Vec<(String, String, String, i64, String)> = sqlx::query_as(
|
||||||
"SELECT provider_type, config_json, enabled, updated_at FROM provider_configs",
|
"SELECT id, provider_type, config_json, enabled, updated_at FROM provider_configs",
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -24,7 +24,8 @@ impl ProviderConfigRepository for SqliteProviderConfigRepository {
|
|||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(provider_type, config_json, enabled, updated_at)| ProviderConfigRow {
|
.map(|(id, provider_type, config_json, enabled, updated_at)| ProviderConfigRow {
|
||||||
|
id,
|
||||||
provider_type,
|
provider_type,
|
||||||
config_json,
|
config_json,
|
||||||
enabled: enabled != 0,
|
enabled: enabled != 0,
|
||||||
@@ -33,15 +34,35 @@ impl ProviderConfigRepository for SqliteProviderConfigRepository {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>> {
|
||||||
|
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<()> {
|
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO provider_configs (provider_type, config_json, enabled, updated_at)
|
r#"INSERT INTO provider_configs (id, provider_type, config_json, enabled, updated_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(provider_type) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
provider_type = excluded.provider_type,
|
||||||
config_json = excluded.config_json,
|
config_json = excluded.config_json,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
updated_at = excluded.updated_at"#,
|
updated_at = excluded.updated_at"#,
|
||||||
)
|
)
|
||||||
|
.bind(&row.id)
|
||||||
.bind(&row.provider_type)
|
.bind(&row.provider_type)
|
||||||
.bind(&row.config_json)
|
.bind(&row.config_json)
|
||||||
.bind(row.enabled as i64)
|
.bind(row.enabled as i64)
|
||||||
@@ -52,9 +73,9 @@ impl ProviderConfigRepository for SqliteProviderConfigRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, provider_type: &str) -> DomainResult<()> {
|
async fn delete(&self, id: &str) -> DomainResult<()> {
|
||||||
sqlx::query("DELETE FROM provider_configs WHERE provider_type = ?")
|
sqlx::query("DELETE FROM provider_configs WHERE id = ?")
|
||||||
.bind(provider_type)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
|
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,15 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { useConfig } from "@/hooks/use-config";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { ApiRequestError } from "@/lib/api";
|
||||||
|
import type { ProviderConfig } from "@/lib/types";
|
||||||
|
|
||||||
const PROVIDER_FIELDS: Record<
|
const PROVIDER_FIELDS: Record<
|
||||||
string,
|
string,
|
||||||
@@ -27,28 +48,37 @@ const PROVIDER_FIELDS: Record<
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderCardProps {
|
function isValidInstanceId(id: string): boolean {
|
||||||
providerType: string;
|
return id.length >= 1 && id.length <= 40 && /^[a-zA-Z0-9-]+$/.test(id);
|
||||||
existingConfig?: { config_json: Record<string, string>; enabled: boolean };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<Record<string, string>>(
|
const [formValues, setFormValues] = useState<Record<string, string>>(
|
||||||
() => 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 [conflictError, setConflictError] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
const updateProvider = useUpdateProvider();
|
const updateProvider = useUpdateProvider();
|
||||||
|
const deleteProvider = useDeleteProvider();
|
||||||
const testProvider = useTestProvider();
|
const testProvider = useTestProvider();
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setConflictError(false);
|
setConflictError(false);
|
||||||
try {
|
try {
|
||||||
await updateProvider.mutateAsync({
|
await updateProvider.mutateAsync({
|
||||||
type: providerType,
|
id: config.id,
|
||||||
payload: { config_json: formValues, enabled },
|
payload: { config_json: formValues, enabled },
|
||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -61,21 +91,44 @@ function ProviderCard({ providerType, existingConfig }: ProviderCardProps) {
|
|||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
const result = await testProvider.mutateAsync({
|
const result = await testProvider.mutateAsync({
|
||||||
type: providerType,
|
provider_type: config.provider_type,
|
||||||
payload: { config_json: formValues, enabled: true },
|
config_json: formValues,
|
||||||
});
|
});
|
||||||
setTestResult(result);
|
setTestResult(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm(`Delete provider instance "${config.id}"?`)) return;
|
||||||
|
await deleteProvider.mutateAsync(config.id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-zinc-800 bg-zinc-900">
|
<Card className="border-zinc-800 bg-zinc-900">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
<CardTitle className="text-sm font-medium capitalize text-zinc-100">
|
<div className="flex items-center gap-2">
|
||||||
{providerType.replace("_", " ")}
|
<Badge variant="outline" className="font-mono text-xs text-zinc-300 border-zinc-600">
|
||||||
</CardTitle>
|
{config.id}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-zinc-500 capitalize">
|
||||||
|
{config.provider_type.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-zinc-400">Enabled</span>
|
<span className="text-xs text-zinc-400">Enabled</span>
|
||||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteProvider.isPending}
|
||||||
|
className="h-7 w-7 text-zinc-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
{deleteProvider.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@@ -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<Record<string, string>>({});
|
||||||
|
const [idError, setIdError] = useState<string | null>(null);
|
||||||
|
const [apiError, setApiError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="border-zinc-800 bg-zinc-950 text-zinc-100 max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">Add Provider Instance</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-zinc-400">
|
||||||
|
Instance ID <span className="text-red-400">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={instanceId}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && <p className="text-xs text-red-400">{idError}</p>}
|
||||||
|
<p className="text-xs text-zinc-600">Alphanumeric + hyphens, 1–40 chars</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-zinc-400">
|
||||||
|
Provider Type <span className="text-red-400">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select value={providerType} onValueChange={handleTypeChange}>
|
||||||
|
<SelectTrigger className="h-8 border-zinc-700 bg-zinc-800 text-xs text-zinc-100">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="border-zinc-700 bg-zinc-900">
|
||||||
|
{availableTypes.map((t) => (
|
||||||
|
<SelectItem key={t} value={t} className="text-xs capitalize text-zinc-200">
|
||||||
|
{t.replace("_", " ")}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div key={field.key} className="space-y-1">
|
||||||
|
<Label className="text-xs text-zinc-400">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-1 text-red-400">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type={field.type ?? "text"}
|
||||||
|
value={formValues[field.key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded px-3 py-2 text-xs ${
|
||||||
|
testResult.ok
|
||||||
|
? "bg-green-950/30 text-green-400"
|
||||||
|
: "bg-red-950/30 text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.ok ? (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<p className="text-xs text-red-400">{apiError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testProvider.isPending}
|
||||||
|
className="border-zinc-700 text-xs"
|
||||||
|
>
|
||||||
|
{testProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createProvider.isPending}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{createProvider.isPending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function ProviderSettingsPanel() {
|
export function ProviderSettingsPanel() {
|
||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
const { data: providerConfigs = [] } = useProviderConfigs();
|
const { data: providerConfigs = [] } = useProviderConfigs();
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
const availableTypes = config?.available_provider_types ?? [];
|
const availableTypes = config?.available_provider_types ?? [];
|
||||||
|
const existingIds = providerConfigs.map((c) => c.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-zinc-100">Provider Configuration</h2>
|
<h2 className="text-sm font-semibold text-zinc-100">Provider Instances</h2>
|
||||||
<p className="mt-0.5 text-xs text-zinc-500">
|
<p className="mt-0.5 text-xs text-zinc-500">
|
||||||
Configure media providers. Requires <code>CONFIG_SOURCE=db</code> on the server.
|
Manage media provider instances. Requires <code>CONFIG_SOURCE=db</code> on the server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{availableTypes.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
className="border-zinc-700 text-xs gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add Instance
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{availableTypes.length === 0 ? (
|
{availableTypes.length === 0 ? (
|
||||||
<p className="text-xs text-zinc-500">No providers available in this build.</p>
|
<p className="text-xs text-zinc-500">No providers available in this build.</p>
|
||||||
|
) : providerConfigs.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
No provider instances configured. Click "Add Instance" to get started.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{availableTypes.map((type) => {
|
{providerConfigs.map((c) => (
|
||||||
const existing = providerConfigs.find((c) => c.provider_type === type);
|
<ProviderCard key={c.id} config={c} existingIds={existingIds} />
|
||||||
return (
|
))}
|
||||||
<ProviderCard
|
|
||||||
key={type}
|
|
||||||
providerType={type}
|
|
||||||
existingConfig={existing}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AddInstanceDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
availableTypes={availableTypes}
|
||||||
|
existingIds={existingIds}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => api.admin.providers.createProvider(token!, payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateProvider() {
|
export function useUpdateProvider() {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
type,
|
id,
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
type: string;
|
id: string;
|
||||||
payload: { config_json: Record<string, string>; enabled: boolean };
|
payload: { config_json: Record<string, string>; enabled: boolean };
|
||||||
}) => api.admin.providers.updateProvider(token!, type, payload),
|
}) => api.admin.providers.updateProvider(token!, id, payload),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -32,8 +46,8 @@ export function useDeleteProvider() {
|
|||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (type: string) =>
|
mutationFn: (id: string) =>
|
||||||
api.admin.providers.deleteProvider(token!, type),
|
api.admin.providers.deleteProvider(token!, id),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "providers"] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,12 +55,9 @@ export function useDeleteProvider() {
|
|||||||
export function useTestProvider() {
|
export function useTestProvider() {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: (payload: {
|
||||||
type,
|
provider_type: string;
|
||||||
payload,
|
config_json: Record<string, string>;
|
||||||
}: {
|
}) => api.admin.providers.testProvider(token!, payload),
|
||||||
type: string;
|
|
||||||
payload: { config_json: Record<string, string>; enabled: boolean };
|
|
||||||
}) => api.admin.providers.testProvider(token!, type, payload),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,8 +238,10 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
rescan: (token: string) =>
|
rescan: (token: string, provider?: string) => {
|
||||||
request<{ items_found: number }>("/files/rescan", { method: "POST", token }),
|
const qs = provider ? `?provider=${encodeURIComponent(provider)}` : "";
|
||||||
|
return request<{ items_found: number }>(`/files/rescan${qs}`, { method: "POST", token });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
transcode: {
|
transcode: {
|
||||||
@@ -268,26 +270,35 @@ export const api = {
|
|||||||
getProviders: (token: string) =>
|
getProviders: (token: string) =>
|
||||||
request<ProviderConfig[]>("/admin/providers", { token }),
|
request<ProviderConfig[]>("/admin/providers", { token }),
|
||||||
|
|
||||||
|
createProvider: (
|
||||||
|
token: string,
|
||||||
|
payload: { id: string; provider_type: string; config_json: Record<string, string>; enabled: boolean },
|
||||||
|
) =>
|
||||||
|
request<ProviderConfig>("/admin/providers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
updateProvider: (
|
updateProvider: (
|
||||||
token: string,
|
token: string,
|
||||||
type: string,
|
id: string,
|
||||||
payload: { config_json: Record<string, string>; enabled: boolean },
|
payload: { config_json: Record<string, string>; enabled: boolean },
|
||||||
) =>
|
) =>
|
||||||
request<ProviderConfig>(`/admin/providers/${type}`, {
|
request<ProviderConfig>(`/admin/providers/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
token,
|
token,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteProvider: (token: string, type: string) =>
|
deleteProvider: (token: string, id: string) =>
|
||||||
request<void>(`/admin/providers/${type}`, { method: "DELETE", token }),
|
request<void>(`/admin/providers/${id}`, { method: "DELETE", token }),
|
||||||
|
|
||||||
testProvider: (
|
testProvider: (
|
||||||
token: string,
|
token: string,
|
||||||
type: string,
|
payload: { provider_type: string; config_json: Record<string, string> },
|
||||||
payload: { config_json: Record<string, string>; enabled: boolean },
|
|
||||||
) =>
|
) =>
|
||||||
request<ProviderTestResult>(`/admin/providers/${type}/test`, {
|
request<ProviderTestResult>("/admin/providers/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export interface ConfigResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
|
id: string;
|
||||||
provider_type: string;
|
provider_type: string;
|
||||||
config_json: Record<string, string>;
|
config_json: Record<string, string>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user