- 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
210 lines
8.8 KiB
Rust
210 lines
8.8 KiB
Rust
use std::sync::Arc;
|
|
|
|
use domain::{
|
|
DomainError, IMediaProvider, ProviderCapabilities, ProviderConfigRepository,
|
|
StreamingProtocol, StreamQuality,
|
|
};
|
|
use k_core::db::DatabasePool;
|
|
|
|
use crate::config::{Config, ConfigSource};
|
|
|
|
#[cfg(feature = "local-files")]
|
|
use infra::factory::build_transcode_settings_repository;
|
|
|
|
pub struct ProviderBundle {
|
|
pub registry: Arc<infra::ProviderRegistry>,
|
|
#[cfg(feature = "local-files")]
|
|
pub local_index: std::collections::HashMap<String, Arc<infra::LocalIndex>>,
|
|
#[cfg(feature = "local-files")]
|
|
pub transcode_manager: Option<Arc<infra::TranscodeManager>>,
|
|
}
|
|
|
|
pub async fn build_provider_registry(
|
|
config: &Config,
|
|
#[cfg_attr(not(feature = "local-files"), allow(unused_variables))]
|
|
db_pool: &Arc<DatabasePool>,
|
|
provider_config_repo: &Arc<dyn ProviderConfigRepository>,
|
|
) -> anyhow::Result<ProviderBundle> {
|
|
#[cfg(feature = "local-files")]
|
|
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;
|
|
|
|
let mut registry = infra::ProviderRegistry::new();
|
|
|
|
if config.config_source == ConfigSource::Db {
|
|
tracing::info!("CONFIG_SOURCE=db: loading provider configs from database");
|
|
let rows = provider_config_repo.get_all().await?;
|
|
for row in &rows {
|
|
if !row.enabled { continue; }
|
|
match row.provider_type.as_str() {
|
|
#[cfg(feature = "jellyfin")]
|
|
"jellyfin" => {
|
|
if let Ok(cfg) = serde_json::from_str::<infra::JellyfinConfig>(&row.config_json) {
|
|
tracing::info!("Loading Jellyfin provider [{}] from DB config", row.id);
|
|
registry.register(&row.id, Arc::new(infra::JellyfinMediaProvider::new(cfg)));
|
|
}
|
|
}
|
|
#[cfg(feature = "local-files")]
|
|
"local_files" => {
|
|
if let Ok(cfg_map) = serde_json::from_str::<std::collections::HashMap<String, String>>(&row.config_json)
|
|
&& let Some(files_dir) = cfg_map.get("files_dir")
|
|
{
|
|
let transcode_dir = cfg_map.get("transcode_dir")
|
|
.filter(|s| !s.is_empty())
|
|
.map(std::path::PathBuf::from);
|
|
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 {:?}", 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 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();
|
|
tokio::spawn(async move {
|
|
if let Some(r) = repo
|
|
&& let Ok(Some(ttl)) = r.load_cleanup_ttl().await
|
|
{
|
|
tm_clone.set_cleanup_ttl(ttl);
|
|
}
|
|
});
|
|
}
|
|
registry.register(&row.id, bundle.provider);
|
|
if transcode_manager.is_none() {
|
|
transcode_manager = bundle.transcode_manager;
|
|
}
|
|
local_index.insert(row.id.clone(), bundle.local_index);
|
|
}
|
|
Err(e) => tracing::warn!("Failed to build local-files provider [{}]: {}", row.id, e),
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
} else {
|
|
#[cfg(feature = "jellyfin")]
|
|
if let (Some(base_url), Some(api_key), Some(user_id)) = (
|
|
&config.jellyfin_base_url,
|
|
&config.jellyfin_api_key,
|
|
&config.jellyfin_user_id,
|
|
) {
|
|
tracing::info!("Media provider: Jellyfin at {}", base_url);
|
|
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig {
|
|
base_url: base_url.clone(),
|
|
api_key: api_key.clone(),
|
|
user_id: user_id.clone(),
|
|
})));
|
|
}
|
|
|
|
#[cfg(feature = "local-files")]
|
|
if let Some(dir) = &config.local_files_dir {
|
|
tracing::info!("Media provider: local files at {:?}", dir);
|
|
match infra::factory::build_local_files_bundle(
|
|
db_pool,
|
|
dir.clone(),
|
|
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);
|
|
tokio::spawn(async move { scan_idx.rescan().await; });
|
|
if let Some(ref tm) = bundle.transcode_manager {
|
|
tracing::info!("Transcoding enabled; cache dir: {:?}", config.transcode_dir);
|
|
let tm_clone = Arc::clone(tm);
|
|
let repo = build_transcode_settings_repository(db_pool).await.ok();
|
|
tokio::spawn(async move {
|
|
if let Some(r) = repo
|
|
&& let Ok(Some(ttl)) = r.load_cleanup_ttl().await
|
|
{
|
|
tm_clone.set_cleanup_ttl(ttl);
|
|
}
|
|
});
|
|
}
|
|
registry.register("local", bundle.provider);
|
|
transcode_manager = bundle.transcode_manager;
|
|
local_index.insert("local".to_string(), bundle.local_index);
|
|
}
|
|
Err(e) => tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR: {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
if registry.is_empty() {
|
|
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR.");
|
|
registry.register("noop", Arc::new(NoopMediaProvider));
|
|
}
|
|
|
|
Ok(ProviderBundle {
|
|
registry: Arc::new(registry),
|
|
#[cfg(feature = "local-files")]
|
|
local_index,
|
|
#[cfg(feature = "local-files")]
|
|
transcode_manager,
|
|
})
|
|
}
|
|
|
|
/// Stand-in provider used when no real media source is configured.
|
|
/// Returns a descriptive error for every call so schedule endpoints fail
|
|
/// gracefully rather than panicking at startup.
|
|
struct NoopMediaProvider;
|
|
|
|
#[async_trait::async_trait]
|
|
impl IMediaProvider for NoopMediaProvider {
|
|
fn capabilities(&self) -> ProviderCapabilities {
|
|
ProviderCapabilities {
|
|
collections: false,
|
|
series: false,
|
|
genres: false,
|
|
tags: false,
|
|
decade: false,
|
|
search: false,
|
|
streaming_protocol: StreamingProtocol::DirectFile,
|
|
rescan: false,
|
|
transcode: false,
|
|
}
|
|
}
|
|
|
|
async fn fetch_items(
|
|
&self,
|
|
_: &domain::MediaFilter,
|
|
) -> domain::DomainResult<Vec<domain::MediaItem>> {
|
|
Err(DomainError::InfrastructureError(
|
|
"No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.".into(),
|
|
))
|
|
}
|
|
|
|
async fn fetch_by_id(
|
|
&self,
|
|
_: &domain::MediaItemId,
|
|
) -> domain::DomainResult<Option<domain::MediaItem>> {
|
|
Err(DomainError::InfrastructureError(
|
|
"No media provider configured.".into(),
|
|
))
|
|
}
|
|
|
|
async fn get_stream_url(
|
|
&self,
|
|
_: &domain::MediaItemId,
|
|
_: &StreamQuality,
|
|
) -> domain::DomainResult<String> {
|
|
Err(DomainError::InfrastructureError(
|
|
"No media provider configured.".into(),
|
|
))
|
|
}
|
|
}
|