Compare commits
2 Commits
373e1c7c0a
...
175d0bb0bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 175d0bb0bb | |||
| 311fdd4006 |
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,7 @@ use infra::factory::build_transcode_settings_repository;
|
||||
pub struct ProviderBundle {
|
||||
pub registry: Arc<infra::ProviderRegistry>,
|
||||
#[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")]
|
||||
pub transcode_manager: Option<Arc<infra::TranscodeManager>>,
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub async fn build_provider_registry(
|
||||
provider_config_repo: &Arc<dyn ProviderConfigRepository>,
|
||||
) -> anyhow::Result<ProviderBundle> {
|
||||
#[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")]
|
||||
let mut transcode_manager: Option<Arc<infra::TranscodeManager>> = None;
|
||||
|
||||
@@ -41,8 +41,8 @@ pub async fn build_provider_registry(
|
||||
#[cfg(feature = "jellyfin")]
|
||||
"jellyfin" => {
|
||||
if let Ok(cfg) = serde_json::from_str::<infra::JellyfinConfig>(&row.config_json) {
|
||||
tracing::info!("Loading Jellyfin provider from DB config");
|
||||
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(cfg)));
|
||||
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);
|
||||
registry.register(&row.id, bundle.provider);
|
||||
if transcode_manager.is_none() {
|
||||
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_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),
|
||||
}
|
||||
|
||||
@@ -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<String, String>,
|
||||
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)]
|
||||
pub struct ProviderConfigResponse {
|
||||
pub id: String,
|
||||
pub provider_type: String,
|
||||
pub config_json: HashMap<String, serde_json::Value>,
|
||||
pub enabled: bool,
|
||||
@@ -51,9 +73,9 @@ pub struct TestResult {
|
||||
|
||||
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))
|
||||
.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<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 {
|
||||
if !row.enabled {
|
||||
continue;
|
||||
@@ -108,7 +136,7 @@ async fn rebuild_registry(state: &AppState) -> DomainResult<()> {
|
||||
serde_json::from_str::<infra::JellyfinConfig>(&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<ProviderConfigResponse> = 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<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(provider_type): Path<String>,
|
||||
Json(payload): Json<ProviderConfigPayload>,
|
||||
Json(payload): Json<CreateProviderRequest>,
|
||||
) -> 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 !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<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),
|
||||
enabled: payload.enabled,
|
||||
};
|
||||
@@ -246,7 +352,7 @@ pub async fn update_provider(
|
||||
pub async fn delete_provider(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(provider_type): Path<String>,
|
||||
Path(instance_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
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<AppState>,
|
||||
AdminUser(_user): AdminUser,
|
||||
Path(provider_type): Path<String>,
|
||||
Json(payload): Json<ProviderConfigPayload>,
|
||||
Json(payload): Json<TestProviderRequest>,
|
||||
) -> 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,
|
||||
"local_files" => test_local_files(&payload.config_json),
|
||||
_ => TestResult {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
async fn trigger_rescan(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
Query(query): Query<RescanQuery>,
|
||||
) -> Result<Json<serde_json::Value>, 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 })))
|
||||
}
|
||||
|
||||
@@ -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<Mutex<VecDeque<LogLine>>>,
|
||||
/// Repository for persisted in-app activity events.
|
||||
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")]
|
||||
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).
|
||||
#[cfg(feature = "local-files")]
|
||||
pub transcode_manager: Arc<tokio::sync::RwLock<Option<Arc<infra::TranscodeManager>>>>,
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<Vec<ProviderConfigRow>>;
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>>;
|
||||
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.
|
||||
|
||||
@@ -282,6 +282,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true, // Production mode
|
||||
);
|
||||
|
||||
@@ -295,6 +296,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false, // Development mode
|
||||
);
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ pub async fn build_local_files_bundle(
|
||||
transcode_dir: Option<std::path::PathBuf>,
|
||||
cleanup_ttl_hours: u32,
|
||||
base_url: String,
|
||||
provider_id: &str,
|
||||
) -> FactoryResult<LocalFilesBundle> {
|
||||
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)
|
||||
|
||||
@@ -36,15 +36,17 @@ pub fn decode_id(id: &MediaItemId) -> Option<String> {
|
||||
pub struct LocalIndex {
|
||||
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
|
||||
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?;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ impl SqliteProviderConfigRepository {
|
||||
#[async_trait]
|
||||
impl ProviderConfigRepository for SqliteProviderConfigRepository {
|
||||
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>> {
|
||||
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<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<()> {
|
||||
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()))?;
|
||||
|
||||
@@ -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";
|
||||
|
||||
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<string, string>; 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<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 [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 (
|
||||
<Card className="border-zinc-800 bg-zinc-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-sm font-medium capitalize text-zinc-100">
|
||||
{providerType.replace("_", " ")}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs text-zinc-300 border-zinc-600">
|
||||
{config.id}
|
||||
</Badge>
|
||||
<span className="text-xs text-zinc-500 capitalize">
|
||||
{config.provider_type.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-400">Enabled</span>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<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() {
|
||||
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 (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
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>
|
||||
</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 ? (
|
||||
<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">
|
||||
{availableTypes.map((type) => {
|
||||
const existing = providerConfigs.find((c) => c.provider_type === type);
|
||||
return (
|
||||
<ProviderCard
|
||||
key={type}
|
||||
providerType={type}
|
||||
existingConfig={existing}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{providerConfigs.map((c) => (
|
||||
<ProviderCard key={c.id} config={c} existingIds={existingIds} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddInstanceDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
availableTypes={availableTypes}
|
||||
existingIds={existingIds}
|
||||
/>
|
||||
</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() {
|
||||
const { token } = useAuthContext();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
type,
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
type: string;
|
||||
id: string;
|
||||
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"] }),
|
||||
});
|
||||
}
|
||||
@@ -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<string, string>; enabled: boolean };
|
||||
}) => api.admin.providers.testProvider(token!, type, payload),
|
||||
mutationFn: (payload: {
|
||||
provider_type: string;
|
||||
config_json: Record<string, string>;
|
||||
}) => api.admin.providers.testProvider(token!, payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<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: (
|
||||
token: string,
|
||||
type: string,
|
||||
id: string,
|
||||
payload: { config_json: Record<string, string>; enabled: boolean },
|
||||
) =>
|
||||
request<ProviderConfig>(`/admin/providers/${type}`, {
|
||||
request<ProviderConfig>(`/admin/providers/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
token,
|
||||
}),
|
||||
|
||||
deleteProvider: (token: string, type: string) =>
|
||||
request<void>(`/admin/providers/${type}`, { method: "DELETE", token }),
|
||||
deleteProvider: (token: string, id: string) =>
|
||||
request<void>(`/admin/providers/${id}`, { method: "DELETE", token }),
|
||||
|
||||
testProvider: (
|
||||
token: string,
|
||||
type: string,
|
||||
payload: { config_json: Record<string, string>; enabled: boolean },
|
||||
payload: { provider_type: string; config_json: Record<string, string> },
|
||||
) =>
|
||||
request<ProviderTestResult>(`/admin/providers/${type}/test`, {
|
||||
request<ProviderTestResult>("/admin/providers/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
token,
|
||||
|
||||
@@ -162,6 +162,7 @@ export interface ConfigResponse {
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
provider_type: string;
|
||||
config_json: Record<string, string>;
|
||||
enabled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user