feat: admin provider routes (list/update/delete/test) with admin middleware
This commit is contained in:
363
k-tv-backend/api/src/routes/admin_providers.rs
Normal file
363
k-tv-backend/api/src/routes/admin_providers.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
//! Admin provider management routes.
|
||||
//!
|
||||
//! All routes require an admin user. Allows listing, updating, deleting, and
|
||||
//! testing media provider configs stored in the DB. Only available when
|
||||
//! CONFIG_SOURCE=db.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post, put, delete};
|
||||
use axum::Json;
|
||||
use domain::errors::DomainResult;
|
||||
use domain::ProviderConfigRow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfigSource;
|
||||
use crate::error::ApiError;
|
||||
use crate::extractors::AdminUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProviderConfigPayload {
|
||||
pub config_json: HashMap<String, String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProviderConfigResponse {
|
||||
pub provider_type: String,
|
||||
pub config_json: HashMap<String, serde_json::Value>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TestResult {
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_providers))
|
||||
.route("/{type}", put(update_provider).delete(delete_provider))
|
||||
.route("/{type}/test", post(test_provider))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn mask_config(raw: &str) -> HashMap<String, serde_json::Value> {
|
||||
let parsed: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_str(raw).unwrap_or_default();
|
||||
parsed
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let secret_key = ["key", "password", "secret", "token"]
|
||||
.iter()
|
||||
.any(|kw| k.to_lowercase().contains(kw));
|
||||
let masked = if secret_key {
|
||||
match &v {
|
||||
serde_json::Value::String(s) if !s.is_empty() => {
|
||||
serde_json::Value::String("***".to_string())
|
||||
}
|
||||
_ => v,
|
||||
}
|
||||
} else {
|
||||
v
|
||||
};
|
||||
(k, masked)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn conflict_response() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"error": "UI config disabled — set CONFIG_SOURCE=db on the server"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn rebuild_registry(state: &AppState) -> DomainResult<()> {
|
||||
let rows = state.provider_config_repo.get_all().await?;
|
||||
let mut new_registry = infra::ProviderRegistry::new();
|
||||
|
||||
for row in &rows {
|
||||
if !row.enabled {
|
||||
continue;
|
||||
}
|
||||
match row.provider_type.as_str() {
|
||||
#[cfg(feature = "jellyfin")]
|
||||
"jellyfin" => {
|
||||
if let Ok(cfg) =
|
||||
serde_json::from_str::<infra::JellyfinConfig>(&row.config_json)
|
||||
{
|
||||
new_registry.register(
|
||||
"jellyfin",
|
||||
Arc::new(infra::JellyfinMediaProvider::new(cfg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if new_registry.is_empty() {
|
||||
new_registry.register("noop", Arc::new(NoopMediaProvider));
|
||||
}
|
||||
|
||||
*state.provider_registry.write().await = Arc::new(new_registry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_providers(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let rows = state
|
||||
.provider_config_repo
|
||||
.get_all()
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let response: Vec<ProviderConfigResponse> = rows
|
||||
.iter()
|
||||
.map(|row| ProviderConfigResponse {
|
||||
provider_type: row.provider_type.clone(),
|
||||
config_json: mask_config(&row.config_json),
|
||||
enabled: row.enabled,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn update_provider(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(provider_type): Path<String>,
|
||||
Json(payload): Json<ProviderConfigPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if state.config.config_source != ConfigSource::Db {
|
||||
return Ok(conflict_response().into_response());
|
||||
}
|
||||
|
||||
let known = matches!(provider_type.as_str(), "jellyfin" | "local_files");
|
||||
if !known {
|
||||
return Err(ApiError::Validation(format!(
|
||||
"Unknown provider type: {}",
|
||||
provider_type
|
||||
)));
|
||||
}
|
||||
|
||||
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(),
|
||||
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 {
|
||||
provider_type,
|
||||
config_json: mask_config(&config_json),
|
||||
enabled: payload.enabled,
|
||||
};
|
||||
|
||||
Ok(Json(response).into_response())
|
||||
}
|
||||
|
||||
pub async fn delete_provider(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(provider_type): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if state.config.config_source != ConfigSource::Db {
|
||||
return Ok(conflict_response().into_response());
|
||||
}
|
||||
|
||||
state
|
||||
.provider_config_repo
|
||||
.delete(&provider_type)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
rebuild_registry(&state)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
pub async fn test_provider(
|
||||
State(_state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(provider_type): Path<String>,
|
||||
Json(payload): Json<ProviderConfigPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let result = match provider_type.as_str() {
|
||||
"jellyfin" => test_jellyfin(&payload.config_json).await,
|
||||
"local_files" => test_local_files(&payload.config_json),
|
||||
_ => TestResult {
|
||||
ok: false,
|
||||
message: "Unknown provider type".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn test_jellyfin(config: &HashMap<String, String>) -> TestResult {
|
||||
let base_url = match config.get("base_url") {
|
||||
Some(u) => u.trim_end_matches('/').to_string(),
|
||||
None => {
|
||||
return TestResult {
|
||||
ok: false,
|
||||
message: "Missing field: base_url".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let api_key = match config.get("api_key") {
|
||||
Some(k) => k.clone(),
|
||||
None => {
|
||||
return TestResult {
|
||||
ok: false,
|
||||
message: "Missing field: api_key".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{}/System/Info", base_url);
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.get(&url)
|
||||
.header("X-Emby-Token", &api_key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
TestResult {
|
||||
ok: true,
|
||||
message: format!("Connected successfully (HTTP {})", status.as_u16()),
|
||||
}
|
||||
} else {
|
||||
TestResult {
|
||||
ok: false,
|
||||
message: format!("Jellyfin returned HTTP {}", status.as_u16()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => TestResult {
|
||||
ok: false,
|
||||
message: format!("Connection failed: {}", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn test_local_files(config: &HashMap<String, String>) -> TestResult {
|
||||
let path = match config.get("files_dir") {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
return TestResult {
|
||||
ok: false,
|
||||
message: "Missing field: files_dir".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let p = std::path::Path::new(&path);
|
||||
if p.exists() && p.is_dir() {
|
||||
TestResult {
|
||||
ok: true,
|
||||
message: format!("Directory exists: {}", path),
|
||||
}
|
||||
} else {
|
||||
TestResult {
|
||||
ok: false,
|
||||
message: format!("Path does not exist or is not a directory: {}", path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NoopMediaProvider (local copy — avoids pub-ing it from main.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct NoopMediaProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::IMediaProvider for NoopMediaProvider {
|
||||
fn capabilities(&self) -> domain::ProviderCapabilities {
|
||||
domain::ProviderCapabilities {
|
||||
collections: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: domain::StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_items(
|
||||
&self,
|
||||
_: &domain::MediaFilter,
|
||||
) -> domain::DomainResult<Vec<domain::MediaItem>> {
|
||||
Err(domain::DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_by_id(
|
||||
&self,
|
||||
_: &domain::MediaItemId,
|
||||
) -> domain::DomainResult<Option<domain::MediaItem>> {
|
||||
Err(domain::DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_: &domain::MediaItemId,
|
||||
_: &domain::StreamQuality,
|
||||
) -> domain::DomainResult<String> {
|
||||
Err(domain::DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use crate::state::AppState;
|
||||
use axum::Router;
|
||||
|
||||
pub mod admin;
|
||||
pub mod admin_providers;
|
||||
pub mod auth;
|
||||
pub mod channels;
|
||||
pub mod config;
|
||||
@@ -17,6 +18,7 @@ pub mod library;
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.nest("/admin", admin::router())
|
||||
.nest("/admin/providers", admin_providers::router())
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/channels", channels::router())
|
||||
.nest("/config", config::router())
|
||||
|
||||
Reference in New Issue
Block a user