feat: implement multi-provider support in media library
- Introduced IProviderRegistry to manage multiple media providers. - Updated AppState to use provider_registry instead of a single media_provider. - Refactored library routes to support provider-specific queries for collections, series, genres, and items. - Enhanced ProgrammingBlock to include provider_id for algorithmic and manual content types. - Modified frontend components to allow selection of providers and updated API calls to include provider parameters. - Adjusted hooks and types to accommodate provider-specific functionality.
This commit is contained in:
@@ -42,10 +42,20 @@ pub struct TokenResponse {
|
|||||||
pub expires_in: u64,
|
pub expires_in: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-provider info returned by `GET /config`.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProviderInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub capabilities: domain::ProviderCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
/// System configuration response
|
/// System configuration response
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ConfigResponse {
|
pub struct ConfigResponse {
|
||||||
pub allow_registration: bool,
|
pub allow_registration: bool,
|
||||||
|
/// All registered providers with their capabilities.
|
||||||
|
pub providers: Vec<ProviderInfo>,
|
||||||
|
/// Capabilities of the primary provider — kept for backward compatibility.
|
||||||
pub provider_capabilities: domain::ProviderCapabilities,
|
pub provider_capabilities: domain::ProviderCapabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use axum::http::{HeaderName, HeaderValue};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||||
|
|
||||||
use domain::{ChannelService, IMediaProvider, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService};
|
use domain::{ChannelService, IMediaProvider, IProviderRegistry, ProviderCapabilities, ScheduleEngineService, StreamingProtocol, UserService};
|
||||||
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
||||||
use infra::run_migrations;
|
use infra::run_migrations;
|
||||||
use k_core::http::server::{ServerConfig, apply_standard_middleware};
|
use k_core::http::server::{ServerConfig, apply_standard_middleware};
|
||||||
@@ -72,11 +72,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let user_service = UserService::new(user_repo);
|
let user_service = UserService::new(user_repo);
|
||||||
let channel_service = ChannelService::new(channel_repo.clone());
|
let channel_service = ChannelService::new(channel_repo.clone());
|
||||||
|
|
||||||
// Build media provider — Jellyfin → local-files → noop, first match wins.
|
// Build provider registry — all configured providers are registered simultaneously.
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
let mut local_index: Option<Arc<infra::LocalIndex>> = None;
|
let mut local_index: Option<Arc<infra::LocalIndex>> = None;
|
||||||
|
|
||||||
let mut maybe_provider: Option<Arc<dyn IMediaProvider>> = None;
|
let mut registry = infra::ProviderRegistry::new();
|
||||||
|
|
||||||
#[cfg(feature = "jellyfin")]
|
#[cfg(feature = "jellyfin")]
|
||||||
if let (Some(base_url), Some(api_key), Some(user_id)) = (
|
if let (Some(base_url), Some(api_key), Some(user_id)) = (
|
||||||
@@ -85,7 +85,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
&config.jellyfin_user_id,
|
&config.jellyfin_user_id,
|
||||||
) {
|
) {
|
||||||
tracing::info!("Media provider: Jellyfin at {}", base_url);
|
tracing::info!("Media provider: Jellyfin at {}", base_url);
|
||||||
maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig {
|
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig {
|
||||||
base_url: base_url.clone(),
|
base_url: base_url.clone(),
|
||||||
api_key: api_key.clone(),
|
api_key: api_key.clone(),
|
||||||
user_id: user_id.clone(),
|
user_id: user_id.clone(),
|
||||||
@@ -93,35 +93,33 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
if maybe_provider.is_none() {
|
if let Some(dir) = &config.local_files_dir {
|
||||||
if let Some(dir) = &config.local_files_dir {
|
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
||||||
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
tracing::info!("Media provider: local files at {:?}", dir);
|
||||||
tracing::info!("Media provider: local files at {:?}", dir);
|
let lf_cfg = infra::LocalFilesConfig {
|
||||||
let lf_cfg = infra::LocalFilesConfig {
|
root_dir: dir.clone(),
|
||||||
root_dir: dir.clone(),
|
base_url: config.base_url.clone(),
|
||||||
base_url: config.base_url.clone(),
|
};
|
||||||
};
|
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
||||||
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
local_index = Some(Arc::clone(&idx));
|
||||||
local_index = Some(Arc::clone(&idx));
|
let scan_idx = Arc::clone(&idx);
|
||||||
let scan_idx = Arc::clone(&idx);
|
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
registry.register("local", Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
||||||
maybe_provider = Some(Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
} else {
|
||||||
} else {
|
tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR");
|
||||||
tracing::warn!("local-files requires SQLite; ignoring LOCAL_FILES_DIR");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let media_provider: Arc<dyn IMediaProvider> = maybe_provider.unwrap_or_else(|| {
|
if registry.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR.");
|
||||||
"No media provider configured. Set JELLYFIN_BASE_URL / LOCAL_FILES_DIR."
|
registry.register("noop", Arc::new(NoopMediaProvider));
|
||||||
);
|
}
|
||||||
Arc::new(NoopMediaProvider)
|
|
||||||
});
|
let registry = Arc::new(registry);
|
||||||
|
|
||||||
let bg_channel_repo = channel_repo.clone();
|
let bg_channel_repo = channel_repo.clone();
|
||||||
let schedule_engine = ScheduleEngineService::new(
|
let schedule_engine = ScheduleEngineService::new(
|
||||||
Arc::clone(&media_provider),
|
Arc::clone(®istry) as Arc<dyn IProviderRegistry>,
|
||||||
channel_repo,
|
channel_repo,
|
||||||
schedule_repo,
|
schedule_repo,
|
||||||
);
|
);
|
||||||
@@ -131,7 +129,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
user_service,
|
user_service,
|
||||||
channel_service,
|
channel_service,
|
||||||
schedule_engine,
|
schedule_engine,
|
||||||
media_provider,
|
registry,
|
||||||
config.clone(),
|
config.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use axum::{Json, Router, extract::State, routing::get};
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use domain::{IProviderRegistry as _, ProviderCapabilities, StreamingProtocol};
|
||||||
|
|
||||||
use crate::dto::ConfigResponse;
|
use crate::dto::{ConfigResponse, ProviderInfo};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
@@ -8,8 +9,35 @@ pub fn router() -> Router<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
|
async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
|
||||||
|
let providers: Vec<ProviderInfo> = state
|
||||||
|
.provider_registry
|
||||||
|
.provider_ids()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
state.provider_registry.capabilities(&id).map(|caps| ProviderInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
capabilities: caps,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let primary_capabilities = state
|
||||||
|
.provider_registry
|
||||||
|
.capabilities(state.provider_registry.primary_id())
|
||||||
|
.unwrap_or(ProviderCapabilities {
|
||||||
|
collections: false,
|
||||||
|
series: false,
|
||||||
|
genres: false,
|
||||||
|
tags: false,
|
||||||
|
decade: false,
|
||||||
|
search: false,
|
||||||
|
streaming_protocol: StreamingProtocol::DirectFile,
|
||||||
|
rescan: false,
|
||||||
|
});
|
||||||
|
|
||||||
Json(ConfigResponse {
|
Json(ConfigResponse {
|
||||||
allow_registration: state.config.allow_registration,
|
allow_registration: state.config.allow_registration,
|
||||||
provider_capabilities: state.media_provider.capabilities(),
|
providers,
|
||||||
|
provider_capabilities: primary_capabilities,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use axum::{
|
|||||||
extract::{Query, RawQuery, State},
|
extract::{Query, RawQuery, State},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
|
use domain::IProviderRegistry as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use domain::{Collection, ContentType, MediaFilter, SeriesSummary};
|
use domain::{Collection, ContentType, MediaFilter, SeriesSummary};
|
||||||
@@ -93,10 +94,18 @@ struct LibraryItemResponse {
|
|||||||
// Query params
|
// Query params
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CollectionsQuery {
|
||||||
|
/// Provider key to query (default: primary).
|
||||||
|
provider: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SeriesQuery {
|
struct SeriesQuery {
|
||||||
/// Scope results to a specific collection (provider library ID).
|
/// Scope results to a specific collection (provider library ID).
|
||||||
collection: Option<String>,
|
collection: Option<String>,
|
||||||
|
/// Provider key to query (default: primary).
|
||||||
|
provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -104,6 +113,8 @@ struct GenresQuery {
|
|||||||
/// Limit genres to a content type: "movie", "episode", or "short".
|
/// Limit genres to a content type: "movie", "episode", or "short".
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
content_type: Option<String>,
|
content_type: Option<String>,
|
||||||
|
/// Provider key to query (default: primary).
|
||||||
|
provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -125,6 +136,8 @@ struct ItemsQuery {
|
|||||||
/// Applies the same ordering the schedule engine would use so the preview
|
/// Applies the same ordering the schedule engine would use so the preview
|
||||||
/// reflects what will actually be scheduled.
|
/// reflects what will actually be scheduled.
|
||||||
strategy: Option<String>,
|
strategy: Option<String>,
|
||||||
|
/// Provider key to query (default: primary).
|
||||||
|
provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -135,13 +148,16 @@ struct ItemsQuery {
|
|||||||
async fn list_collections(
|
async fn list_collections(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
|
Query(params): Query<CollectionsQuery>,
|
||||||
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
|
||||||
if !state.media_provider.capabilities().collections {
|
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||||
return Err(ApiError::not_implemented(
|
let caps = state.provider_registry.capabilities(provider_id).ok_or_else(|| {
|
||||||
"collections not supported by this provider",
|
ApiError::validation(format!("Unknown provider '{}'", provider_id))
|
||||||
));
|
})?;
|
||||||
|
if !caps.collections {
|
||||||
|
return Err(ApiError::not_implemented("collections not supported by this provider"));
|
||||||
}
|
}
|
||||||
let collections = state.media_provider.list_collections().await?;
|
let collections = state.provider_registry.list_collections(provider_id).await?;
|
||||||
Ok(Json(collections.into_iter().map(Into::into).collect()))
|
Ok(Json(collections.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,14 +167,16 @@ async fn list_series(
|
|||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<SeriesQuery>,
|
Query(params): Query<SeriesQuery>,
|
||||||
) -> Result<Json<Vec<SeriesResponse>>, ApiError> {
|
) -> Result<Json<Vec<SeriesResponse>>, ApiError> {
|
||||||
if !state.media_provider.capabilities().series {
|
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||||
return Err(ApiError::not_implemented(
|
let caps = state.provider_registry.capabilities(provider_id).ok_or_else(|| {
|
||||||
"series not supported by this provider",
|
ApiError::validation(format!("Unknown provider '{}'", provider_id))
|
||||||
));
|
})?;
|
||||||
|
if !caps.series {
|
||||||
|
return Err(ApiError::not_implemented("series not supported by this provider"));
|
||||||
}
|
}
|
||||||
let series = state
|
let series = state
|
||||||
.media_provider
|
.provider_registry
|
||||||
.list_series(params.collection.as_deref())
|
.list_series(provider_id, params.collection.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(series.into_iter().map(Into::into).collect()))
|
Ok(Json(series.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
@@ -169,13 +187,15 @@ async fn list_genres(
|
|||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(_user): CurrentUser,
|
||||||
Query(params): Query<GenresQuery>,
|
Query(params): Query<GenresQuery>,
|
||||||
) -> Result<Json<Vec<String>>, ApiError> {
|
) -> Result<Json<Vec<String>>, ApiError> {
|
||||||
if !state.media_provider.capabilities().genres {
|
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||||
return Err(ApiError::not_implemented(
|
let caps = state.provider_registry.capabilities(provider_id).ok_or_else(|| {
|
||||||
"genres not supported by this provider",
|
ApiError::validation(format!("Unknown provider '{}'", provider_id))
|
||||||
));
|
})?;
|
||||||
|
if !caps.genres {
|
||||||
|
return Err(ApiError::not_implemented("genres not supported by this provider"));
|
||||||
}
|
}
|
||||||
let ct = parse_content_type(params.content_type.as_deref())?;
|
let ct = parse_content_type(params.content_type.as_deref())?;
|
||||||
let genres = state.media_provider.list_genres(ct.as_ref()).await?;
|
let genres = state.provider_registry.list_genres(provider_id, ct.as_ref()).await?;
|
||||||
Ok(Json(genres))
|
Ok(Json(genres))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +215,8 @@ async fn search_items(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let limit = params.limit.unwrap_or(50).min(200);
|
let limit = params.limit.unwrap_or(50).min(200);
|
||||||
|
|
||||||
|
let provider_id = params.provider.as_deref().unwrap_or("");
|
||||||
|
|
||||||
let filter = MediaFilter {
|
let filter = MediaFilter {
|
||||||
content_type: parse_content_type(params.content_type.as_deref())?,
|
content_type: parse_content_type(params.content_type.as_deref())?,
|
||||||
search_term: params.q,
|
search_term: params.q,
|
||||||
@@ -206,7 +228,7 @@ async fn search_items(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = state.media_provider.fetch_items(&filter).await?;
|
let mut items = state.provider_registry.fetch_items(provider_id, &filter).await?;
|
||||||
|
|
||||||
// Apply the same ordering the schedule engine uses so the preview reflects
|
// Apply the same ordering the schedule engine uses so the preview reflects
|
||||||
// what will actually be scheduled rather than raw provider order.
|
// what will actually be scheduled rather than raw provider order.
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ use infra::auth::oidc::OidcService;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService};
|
use domain::{ChannelService, ScheduleEngineService, UserService};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub user_service: Arc<UserService>,
|
pub user_service: Arc<UserService>,
|
||||||
pub channel_service: Arc<ChannelService>,
|
pub channel_service: Arc<ChannelService>,
|
||||||
pub schedule_engine: Arc<ScheduleEngineService>,
|
pub schedule_engine: Arc<ScheduleEngineService>,
|
||||||
pub media_provider: Arc<dyn IMediaProvider>,
|
pub provider_registry: Arc<infra::ProviderRegistry>,
|
||||||
pub cookie_key: Key,
|
pub cookie_key: Key,
|
||||||
#[cfg(feature = "auth-oidc")]
|
#[cfg(feature = "auth-oidc")]
|
||||||
pub oidc_service: Option<Arc<OidcService>>,
|
pub oidc_service: Option<Arc<OidcService>>,
|
||||||
@@ -35,7 +35,7 @@ impl AppState {
|
|||||||
user_service: UserService,
|
user_service: UserService,
|
||||||
channel_service: ChannelService,
|
channel_service: ChannelService,
|
||||||
schedule_engine: ScheduleEngineService,
|
schedule_engine: ScheduleEngineService,
|
||||||
media_provider: Arc<dyn IMediaProvider>,
|
provider_registry: Arc<infra::ProviderRegistry>,
|
||||||
config: Config,
|
config: Config,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
|
||||||
@@ -101,7 +101,7 @@ impl AppState {
|
|||||||
user_service: Arc::new(user_service),
|
user_service: Arc::new(user_service),
|
||||||
channel_service: Arc::new(channel_service),
|
channel_service: Arc::new(channel_service),
|
||||||
schedule_engine: Arc::new(schedule_engine),
|
schedule_engine: Arc::new(schedule_engine),
|
||||||
media_provider,
|
provider_registry,
|
||||||
cookie_key,
|
cookie_key,
|
||||||
#[cfg(feature = "auth-oidc")]
|
#[cfg(feature = "auth-oidc")]
|
||||||
oidc_service,
|
oidc_service,
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ impl ProgrammingBlock {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
start_time,
|
start_time,
|
||||||
duration_mins,
|
duration_mins,
|
||||||
content: BlockContent::Algorithmic { filter, strategy },
|
content: BlockContent::Algorithmic { filter, strategy, provider_id: String::new() },
|
||||||
loop_on_finish: true,
|
loop_on_finish: true,
|
||||||
ignore_recycle_policy: false,
|
ignore_recycle_policy: false,
|
||||||
access_mode: AccessMode::default(),
|
access_mode: AccessMode::default(),
|
||||||
@@ -233,7 +233,7 @@ impl ProgrammingBlock {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
start_time,
|
start_time,
|
||||||
duration_mins,
|
duration_mins,
|
||||||
content: BlockContent::Manual { items },
|
content: BlockContent::Manual { items, provider_id: String::new() },
|
||||||
loop_on_finish: true,
|
loop_on_finish: true,
|
||||||
ignore_recycle_policy: false,
|
ignore_recycle_policy: false,
|
||||||
access_mode: AccessMode::default(),
|
access_mode: AccessMode::default(),
|
||||||
@@ -247,11 +247,21 @@ impl ProgrammingBlock {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum BlockContent {
|
pub enum BlockContent {
|
||||||
/// The user hand-picked specific items in a specific order.
|
/// The user hand-picked specific items in a specific order.
|
||||||
Manual { items: Vec<MediaItemId> },
|
/// Item IDs are prefixed with the provider key (e.g. `"jellyfin::abc123"`)
|
||||||
|
/// so the registry can route each fetch to the correct provider.
|
||||||
|
Manual {
|
||||||
|
items: Vec<MediaItemId>,
|
||||||
|
/// Registry key of the provider these items come from. Empty string = primary.
|
||||||
|
#[serde(default)]
|
||||||
|
provider_id: String,
|
||||||
|
},
|
||||||
/// The engine selects items from the provider using the given filter and strategy.
|
/// The engine selects items from the provider using the given filter and strategy.
|
||||||
Algorithmic {
|
Algorithmic {
|
||||||
filter: MediaFilter,
|
filter: MediaFilter,
|
||||||
strategy: FillStrategy,
|
strategy: FillStrategy,
|
||||||
|
/// Registry key of the provider to query. Empty string = primary.
|
||||||
|
#[serde(default)]
|
||||||
|
provider_id: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod value_objects;
|
|||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use entities::*;
|
pub use entities::*;
|
||||||
pub use errors::{DomainError, DomainResult};
|
pub use errors::{DomainError, DomainResult};
|
||||||
pub use ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamingProtocol, StreamQuality};
|
pub use ports::{Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamingProtocol, StreamQuality};
|
||||||
pub use repositories::*;
|
pub use repositories::*;
|
||||||
pub use iptv::{generate_m3u, generate_xmltv};
|
pub use iptv::{generate_m3u, generate_xmltv};
|
||||||
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
||||||
|
|||||||
@@ -162,3 +162,47 @@ pub trait IMediaProvider: Send + Sync {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Registry port
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Port for routing media operations across multiple named providers.
|
||||||
|
///
|
||||||
|
/// The registry holds all configured providers (Jellyfin, local files, …) and
|
||||||
|
/// dispatches each call to the right one. Item IDs are prefixed with the
|
||||||
|
/// provider key (e.g. `"jellyfin::abc123"`, `"local::base64path"`) so every
|
||||||
|
/// fetch and stream call is self-routing. An empty prefix falls back to the
|
||||||
|
/// primary (first-registered) provider for backward compatibility.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait IProviderRegistry: Send + Sync {
|
||||||
|
/// Fetch items from a named provider (used by Algorithmic blocks).
|
||||||
|
/// Empty `provider_id` uses the primary provider.
|
||||||
|
/// Returned item IDs are stamped with the provider prefix.
|
||||||
|
async fn fetch_items(&self, provider_id: &str, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>>;
|
||||||
|
|
||||||
|
/// Fetch a single item by its (possibly prefixed) ID.
|
||||||
|
/// Routes to the correct provider by parsing the prefix.
|
||||||
|
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
|
||||||
|
|
||||||
|
/// Get a playback URL. Routes via prefix in `item_id`.
|
||||||
|
async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String>;
|
||||||
|
|
||||||
|
/// List all registered provider keys in registration order.
|
||||||
|
fn provider_ids(&self) -> Vec<String>;
|
||||||
|
|
||||||
|
/// Key of the primary (first-registered) provider.
|
||||||
|
fn primary_id(&self) -> &str;
|
||||||
|
|
||||||
|
/// Capability matrix for a specific provider. Returns `None` if the key is unknown.
|
||||||
|
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities>;
|
||||||
|
|
||||||
|
/// List collections for a provider. Empty `provider_id` = primary.
|
||||||
|
async fn list_collections(&self, provider_id: &str) -> DomainResult<Vec<Collection>>;
|
||||||
|
|
||||||
|
/// List series for a provider. Empty `provider_id` = primary.
|
||||||
|
async fn list_series(&self, provider_id: &str, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>>;
|
||||||
|
|
||||||
|
/// List genres for a provider. Empty `provider_id` = primary.
|
||||||
|
async fn list_genres(&self, provider_id: &str, content_type: Option<&ContentType>) -> DomainResult<Vec<String>>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::entities::{
|
|||||||
ScheduledSlot,
|
ScheduledSlot,
|
||||||
};
|
};
|
||||||
use crate::errors::{DomainError, DomainResult};
|
use crate::errors::{DomainError, DomainResult};
|
||||||
use crate::ports::{IMediaProvider, StreamQuality};
|
use crate::ports::{IProviderRegistry, StreamQuality};
|
||||||
use crate::repositories::{ChannelRepository, ScheduleRepository};
|
use crate::repositories::{ChannelRepository, ScheduleRepository};
|
||||||
use crate::value_objects::{
|
use crate::value_objects::{
|
||||||
BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
||||||
@@ -26,19 +26,19 @@ mod recycle;
|
|||||||
/// `ScheduledSlot`s via the `IMediaProvider`, and applying the `RecyclePolicy`
|
/// `ScheduledSlot`s via the `IMediaProvider`, and applying the `RecyclePolicy`
|
||||||
/// to avoid replaying recently aired items.
|
/// to avoid replaying recently aired items.
|
||||||
pub struct ScheduleEngineService {
|
pub struct ScheduleEngineService {
|
||||||
media_provider: Arc<dyn IMediaProvider>,
|
provider_registry: Arc<dyn IProviderRegistry>,
|
||||||
channel_repo: Arc<dyn ChannelRepository>,
|
channel_repo: Arc<dyn ChannelRepository>,
|
||||||
schedule_repo: Arc<dyn ScheduleRepository>,
|
schedule_repo: Arc<dyn ScheduleRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScheduleEngineService {
|
impl ScheduleEngineService {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
media_provider: Arc<dyn IMediaProvider>,
|
provider_registry: Arc<dyn IProviderRegistry>,
|
||||||
channel_repo: Arc<dyn ChannelRepository>,
|
channel_repo: Arc<dyn ChannelRepository>,
|
||||||
schedule_repo: Arc<dyn ScheduleRepository>,
|
schedule_repo: Arc<dyn ScheduleRepository>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
media_provider,
|
provider_registry,
|
||||||
channel_repo,
|
channel_repo,
|
||||||
schedule_repo,
|
schedule_repo,
|
||||||
}
|
}
|
||||||
@@ -223,9 +223,9 @@ impl ScheduleEngineService {
|
|||||||
self.schedule_repo.find_active(channel_id, at).await
|
self.schedule_repo.find_active(channel_id, at).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delegate stream URL resolution to the configured media provider.
|
/// Delegate stream URL resolution to the provider registry (routes via ID prefix).
|
||||||
pub async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
pub async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
||||||
self.media_provider.get_stream_url(item_id, quality).await
|
self.provider_registry.get_stream_url(item_id, quality).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all slots that overlap the given time window — the EPG data.
|
/// Return all slots that overlap the given time window — the EPG data.
|
||||||
@@ -256,12 +256,12 @@ impl ScheduleEngineService {
|
|||||||
last_item_id: Option<&MediaItemId>,
|
last_item_id: Option<&MediaItemId>,
|
||||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||||
match &block.content {
|
match &block.content {
|
||||||
BlockContent::Manual { items } => {
|
BlockContent::Manual { items, .. } => {
|
||||||
self.resolve_manual(items, start, end, block.id).await
|
self.resolve_manual(items, start, end, block.id).await
|
||||||
}
|
}
|
||||||
BlockContent::Algorithmic { filter, strategy } => {
|
BlockContent::Algorithmic { filter, strategy, provider_id } => {
|
||||||
self.resolve_algorithmic(
|
self.resolve_algorithmic(
|
||||||
filter, strategy, start, end, history, policy, generation,
|
provider_id, filter, strategy, start, end, history, policy, generation,
|
||||||
block.id, last_item_id,
|
block.id, last_item_id,
|
||||||
block.loop_on_finish,
|
block.loop_on_finish,
|
||||||
block.ignore_recycle_policy,
|
block.ignore_recycle_policy,
|
||||||
@@ -287,7 +287,7 @@ impl ScheduleEngineService {
|
|||||||
if cursor >= end {
|
if cursor >= end {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(item) = self.media_provider.fetch_by_id(item_id).await? {
|
if let Some(item) = self.provider_registry.fetch_by_id(item_id).await? {
|
||||||
let item_end =
|
let item_end =
|
||||||
(cursor + Duration::seconds(item.duration_secs as i64)).min(end);
|
(cursor + Duration::seconds(item.duration_secs as i64)).min(end);
|
||||||
slots.push(ScheduledSlot {
|
slots.push(ScheduledSlot {
|
||||||
@@ -312,6 +312,7 @@ impl ScheduleEngineService {
|
|||||||
/// previous generation. Used only by `Sequential` for series continuity.
|
/// previous generation. Used only by `Sequential` for series continuity.
|
||||||
async fn resolve_algorithmic(
|
async fn resolve_algorithmic(
|
||||||
&self,
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
filter: &MediaFilter,
|
filter: &MediaFilter,
|
||||||
strategy: &FillStrategy,
|
strategy: &FillStrategy,
|
||||||
start: DateTime<Utc>,
|
start: DateTime<Utc>,
|
||||||
@@ -327,7 +328,7 @@ impl ScheduleEngineService {
|
|||||||
// `candidates` — all items matching the filter, in provider order.
|
// `candidates` — all items matching the filter, in provider order.
|
||||||
// Kept separate from `pool` so Sequential can rotate through the full
|
// Kept separate from `pool` so Sequential can rotate through the full
|
||||||
// ordered list while still honouring cooldowns.
|
// ordered list while still honouring cooldowns.
|
||||||
let candidates = self.media_provider.fetch_items(filter).await?;
|
let candidates = self.provider_registry.fetch_items(provider_id, filter).await?;
|
||||||
|
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub mod auth;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod factory;
|
pub mod factory;
|
||||||
pub mod jellyfin;
|
pub mod jellyfin;
|
||||||
|
pub mod provider_registry;
|
||||||
mod channel_repository;
|
mod channel_repository;
|
||||||
mod schedule_repository;
|
mod schedule_repository;
|
||||||
mod user_repository;
|
mod user_repository;
|
||||||
@@ -26,6 +27,7 @@ pub mod local_files;
|
|||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
pub use db::run_migrations;
|
pub use db::run_migrations;
|
||||||
|
pub use provider_registry::ProviderRegistry;
|
||||||
|
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
pub use user_repository::SqliteUserRepository;
|
pub use user_repository::SqliteUserRepository;
|
||||||
|
|||||||
@@ -115,9 +115,7 @@ fn extract_year(s: &str) -> Option<u16> {
|
|||||||
if !chars[i..i + 4].iter().all(|c| c.is_ascii_digit()) {
|
if !chars[i..i + 4].iter().all(|c| c.is_ascii_digit()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Must start with 19 or 20.
|
// Parse and range-check.
|
||||||
let prefix = chars[i] as u8 * 10 + chars[i + 1] as u8 - b'0' * 11;
|
|
||||||
// Simpler: just parse and range-check.
|
|
||||||
let s4: String = chars[i..i + 4].iter().collect();
|
let s4: String = chars[i..i + 4].iter().collect();
|
||||||
let num: u16 = s4.parse().ok()?;
|
let num: u16 = s4.parse().ok()?;
|
||||||
if !(1900..=2099).contains(&num) {
|
if !(1900..=2099).contains(&num) {
|
||||||
@@ -126,7 +124,6 @@ fn extract_year(s: &str) -> Option<u16> {
|
|||||||
// Word-boundary: char before and after must not be digits.
|
// Word-boundary: char before and after must not be digits.
|
||||||
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
||||||
let after_ok = i + 4 >= n || !chars[i + 4].is_ascii_digit();
|
let after_ok = i + 4 >= n || !chars[i + 4].is_ascii_digit();
|
||||||
let _ = prefix;
|
|
||||||
if before_ok && after_ok {
|
if before_ok && after_ok {
|
||||||
return Some(num);
|
return Some(num);
|
||||||
}
|
}
|
||||||
|
|||||||
167
k-tv-backend/infra/src/provider_registry.rs
Normal file
167
k-tv-backend/infra/src/provider_registry.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
//! Provider registry — routes media operations to the correct named provider.
|
||||||
|
//!
|
||||||
|
//! Item IDs are prefixed with the provider key separated by `::`, e.g.
|
||||||
|
//! `"jellyfin::abc123"` or `"local::base64path"`. The registry strips the
|
||||||
|
//! prefix before calling the underlying provider and re-stamps returned IDs
|
||||||
|
//! so every item is self-routing throughout its lifetime.
|
||||||
|
//!
|
||||||
|
//! An empty prefix (un-prefixed IDs from old data, or new blocks with no
|
||||||
|
//! `provider_id` set) falls back to the primary (first-registered) provider.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::errors::{DomainError, DomainResult};
|
||||||
|
use domain::ports::{
|
||||||
|
Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary,
|
||||||
|
StreamQuality,
|
||||||
|
};
|
||||||
|
use domain::{ContentType, MediaFilter, MediaItem, MediaItemId};
|
||||||
|
|
||||||
|
/// Registry of named media providers.
|
||||||
|
///
|
||||||
|
/// Providers are registered with a short key (e.g. `"jellyfin"`, `"local"`).
|
||||||
|
/// The first registered provider is the *primary* — it handles un-prefixed IDs
|
||||||
|
/// and empty `provider_id` strings for backward compatibility.
|
||||||
|
pub struct ProviderRegistry {
|
||||||
|
/// Ordered list of `(key, provider)` pairs. Order determines the primary.
|
||||||
|
providers: Vec<(String, Arc<dyn IMediaProvider>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { providers: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a provider under `id`. The first registered becomes the primary.
|
||||||
|
pub fn register(&mut self, id: impl Into<String>, provider: Arc<dyn IMediaProvider>) {
|
||||||
|
self.providers.push((id.into(), provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.providers.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn prefix_id(provider_id: &str, raw_id: &str) -> MediaItemId {
|
||||||
|
MediaItemId::new(format!("{}::{}", provider_id, raw_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `"provider_key::raw_id"` into `(key, raw_id)`.
|
||||||
|
/// Un-prefixed IDs return `("", full_id)` → primary provider fallback.
|
||||||
|
fn parse_prefix(id: &MediaItemId) -> (&str, &str) {
|
||||||
|
let s: &str = id.as_ref();
|
||||||
|
match s.find("::") {
|
||||||
|
Some(pos) => (&s[..pos], &s[pos + 2..]),
|
||||||
|
None => ("", s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a provider key to the provider, defaulting to primary on empty key.
|
||||||
|
/// Returns `(resolved_key, provider)` so the caller can re-stamp IDs.
|
||||||
|
fn resolve_provider<'a>(
|
||||||
|
&'a self,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> DomainResult<(&'a str, &'a Arc<dyn IMediaProvider>)> {
|
||||||
|
if provider_id.is_empty() {
|
||||||
|
self.providers
|
||||||
|
.first()
|
||||||
|
.map(|(id, p)| (id.as_str(), p))
|
||||||
|
.ok_or_else(|| DomainError::InfrastructureError("No providers registered".into()))
|
||||||
|
} else {
|
||||||
|
self.providers
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| id == provider_id)
|
||||||
|
.map(|(id, p)| (id.as_str(), p))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DomainError::InfrastructureError(
|
||||||
|
format!("Provider '{}' not found", provider_id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_items(provider_id: &str, items: Vec<MediaItem>) -> Vec<MediaItem> {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut item| {
|
||||||
|
item.id = Self::prefix_id(provider_id, item.id.as_ref());
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProviderRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IProviderRegistry for ProviderRegistry {
|
||||||
|
async fn fetch_items(&self, provider_id: &str, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
||||||
|
let (pid, provider) = self.resolve_provider(provider_id)?;
|
||||||
|
let items = provider.fetch_items(filter).await?;
|
||||||
|
Ok(Self::wrap_items(pid, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
||||||
|
let (prefix, raw) = Self::parse_prefix(item_id);
|
||||||
|
let (pid, provider) = self.resolve_provider(prefix)?;
|
||||||
|
let raw_id = MediaItemId::new(raw);
|
||||||
|
let result = provider.fetch_by_id(&raw_id).await?;
|
||||||
|
Ok(result.map(|mut item| {
|
||||||
|
item.id = Self::prefix_id(pid, item.id.as_ref());
|
||||||
|
item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
||||||
|
let (prefix, raw) = Self::parse_prefix(item_id);
|
||||||
|
let (_, provider) = self.resolve_provider(prefix)?;
|
||||||
|
let raw_id = MediaItemId::new(raw);
|
||||||
|
provider.get_stream_url(&raw_id, quality).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_ids(&self) -> Vec<String> {
|
||||||
|
self.providers.iter().map(|(id, _)| id.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary_id(&self) -> &str {
|
||||||
|
self.providers
|
||||||
|
.first()
|
||||||
|
.map(|(id, _)| id.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities> {
|
||||||
|
let target = if provider_id.is_empty() {
|
||||||
|
self.providers.first().map(|(id, _)| id.as_str())?
|
||||||
|
} else {
|
||||||
|
provider_id
|
||||||
|
};
|
||||||
|
self.providers
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| id == target)
|
||||||
|
.map(|(_, p)| p.capabilities())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_collections(&self, provider_id: &str) -> DomainResult<Vec<Collection>> {
|
||||||
|
let (_, provider) = self.resolve_provider(provider_id)?;
|
||||||
|
provider.list_collections().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_series(&self, provider_id: &str, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
|
||||||
|
let (_, provider) = self.resolve_provider(provider_id)?;
|
||||||
|
provider.list_series(collection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_genres(&self, provider_id: &str, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
|
||||||
|
let (_, provider) = self.resolve_provider(provider_id)?;
|
||||||
|
provider.list_genres(content_type).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration as StdDuration;
|
use std::time::Duration as StdDuration;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
ChannelService, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItemId,
|
ChannelService, DomainError, DomainResult, IMediaProvider, IProviderRegistry, MediaFilter,
|
||||||
ProviderCapabilities, ScheduleEngineService, StreamQuality, StreamingProtocol, UserService,
|
MediaItemId, ProviderCapabilities, ScheduleEngineService, StreamQuality, StreamingProtocol,
|
||||||
|
UserService,
|
||||||
};
|
};
|
||||||
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
||||||
use infra::run_migrations;
|
use infra::run_migrations;
|
||||||
@@ -69,7 +70,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let _user_service = UserService::new(user_repo);
|
let _user_service = UserService::new(user_repo);
|
||||||
let channel_service = ChannelService::new(channel_repo.clone());
|
let channel_service = ChannelService::new(channel_repo.clone());
|
||||||
|
|
||||||
let mut maybe_provider: Option<Arc<dyn IMediaProvider>> = None;
|
let mut registry = infra::ProviderRegistry::new();
|
||||||
|
|
||||||
#[cfg(feature = "jellyfin")]
|
#[cfg(feature = "jellyfin")]
|
||||||
{
|
{
|
||||||
@@ -78,41 +79,34 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let user_id = std::env::var("JELLYFIN_USER_ID").ok();
|
let user_id = std::env::var("JELLYFIN_USER_ID").ok();
|
||||||
if let (Some(base_url), Some(api_key), Some(user_id)) = (base_url, api_key, user_id) {
|
if let (Some(base_url), Some(api_key), Some(user_id)) = (base_url, api_key, user_id) {
|
||||||
info!("Media provider: Jellyfin at {}", base_url);
|
info!("Media provider: Jellyfin at {}", base_url);
|
||||||
maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new(
|
registry.register("jellyfin", Arc::new(infra::JellyfinMediaProvider::new(
|
||||||
infra::JellyfinConfig {
|
infra::JellyfinConfig { base_url, api_key, user_id },
|
||||||
base_url,
|
|
||||||
api_key,
|
|
||||||
user_id,
|
|
||||||
},
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "local-files")]
|
#[cfg(feature = "local-files")]
|
||||||
if maybe_provider.is_none() {
|
if let Some(dir) = std::env::var("LOCAL_FILES_DIR").ok().map(std::path::PathBuf::from) {
|
||||||
if let Some(dir) = std::env::var("LOCAL_FILES_DIR").ok().map(std::path::PathBuf::from) {
|
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
||||||
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
let base_url = std::env::var("BASE_URL")
|
||||||
let base_url = std::env::var("BASE_URL")
|
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
let lf_cfg = infra::LocalFilesConfig { root_dir: dir, base_url };
|
||||||
let lf_cfg = infra::LocalFilesConfig {
|
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
||||||
root_dir: dir,
|
let scan_idx = Arc::clone(&idx);
|
||||||
base_url,
|
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||||
};
|
registry.register("local", Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
||||||
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
|
||||||
let scan_idx = Arc::clone(&idx);
|
|
||||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
|
||||||
maybe_provider = Some(Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let media_provider: Arc<dyn IMediaProvider> = maybe_provider.unwrap_or_else(|| {
|
if registry.is_empty() {
|
||||||
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.");
|
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.");
|
||||||
Arc::new(NoopMediaProvider)
|
registry.register("noop", Arc::new(NoopMediaProvider));
|
||||||
});
|
}
|
||||||
|
|
||||||
|
let registry = Arc::new(registry);
|
||||||
|
|
||||||
let schedule_engine = ScheduleEngineService::new(
|
let schedule_engine = ScheduleEngineService::new(
|
||||||
Arc::clone(&media_provider),
|
Arc::clone(®istry) as Arc<dyn IProviderRegistry>,
|
||||||
channel_repo,
|
channel_repo,
|
||||||
schedule_repo,
|
schedule_repo,
|
||||||
);
|
);
|
||||||
@@ -120,7 +114,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let server = KTvMcpServer {
|
let server = KTvMcpServer {
|
||||||
channel_service: Arc::new(channel_service),
|
channel_service: Arc::new(channel_service),
|
||||||
schedule_engine: Arc::new(schedule_engine),
|
schedule_engine: Arc::new(schedule_engine),
|
||||||
media_provider,
|
provider_registry: registry,
|
||||||
owner_id,
|
owner_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
ChannelService, ContentType, IMediaProvider, ProgrammingBlock, ScheduleConfig,
|
ChannelService, ContentType, ProgrammingBlock, ScheduleConfig, ScheduleEngineService,
|
||||||
ScheduleEngineService,
|
|
||||||
};
|
};
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ServerHandler,
|
ServerHandler,
|
||||||
@@ -19,7 +18,7 @@ use crate::tools::{channels, library, schedule};
|
|||||||
pub struct KTvMcpServer {
|
pub struct KTvMcpServer {
|
||||||
pub channel_service: Arc<ChannelService>,
|
pub channel_service: Arc<ChannelService>,
|
||||||
pub schedule_engine: Arc<ScheduleEngineService>,
|
pub schedule_engine: Arc<ScheduleEngineService>,
|
||||||
pub media_provider: Arc<dyn IMediaProvider>,
|
pub provider_registry: Arc<infra::ProviderRegistry>,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +243,7 @@ impl KTvMcpServer {
|
|||||||
|
|
||||||
#[tool(description = "List media collections/libraries available in the configured provider")]
|
#[tool(description = "List media collections/libraries available in the configured provider")]
|
||||||
async fn list_collections(&self) -> String {
|
async fn list_collections(&self) -> String {
|
||||||
library::list_collections(&self.media_provider).await
|
library::list_collections(&self.provider_registry).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
@@ -252,7 +251,7 @@ impl KTvMcpServer {
|
|||||||
)]
|
)]
|
||||||
async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String {
|
async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String {
|
||||||
let ct = p.content_type.as_deref().and_then(parse_content_type);
|
let ct = p.content_type.as_deref().and_then(parse_content_type);
|
||||||
library::list_genres(&self.media_provider, ct).await
|
library::list_genres(&self.provider_registry, ct).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
@@ -261,7 +260,7 @@ impl KTvMcpServer {
|
|||||||
async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String {
|
async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String {
|
||||||
let ct = p.content_type.as_deref().and_then(parse_content_type);
|
let ct = p.content_type.as_deref().and_then(parse_content_type);
|
||||||
library::search_media(
|
library::search_media(
|
||||||
&self.media_provider,
|
&self.provider_registry,
|
||||||
ct,
|
ct,
|
||||||
p.genres.unwrap_or_default(),
|
p.genres.unwrap_or_default(),
|
||||||
p.search_term,
|
p.search_term,
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
use domain::{ContentType, IMediaProvider, MediaFilter};
|
use domain::{ContentType, IProviderRegistry, MediaFilter};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::error::{domain_err, ok_json};
|
use crate::error::{domain_err, ok_json};
|
||||||
|
|
||||||
pub async fn list_collections(provider: &Arc<dyn IMediaProvider>) -> String {
|
pub async fn list_collections(registry: &Arc<infra::ProviderRegistry>) -> String {
|
||||||
match provider.list_collections().await {
|
match registry.list_collections("").await {
|
||||||
Ok(cols) => ok_json(&cols),
|
Ok(cols) => ok_json(&cols),
|
||||||
Err(e) => domain_err(e),
|
Err(e) => domain_err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_genres(
|
pub async fn list_genres(
|
||||||
provider: &Arc<dyn IMediaProvider>,
|
registry: &Arc<infra::ProviderRegistry>,
|
||||||
content_type: Option<ContentType>,
|
content_type: Option<ContentType>,
|
||||||
) -> String {
|
) -> String {
|
||||||
match provider.list_genres(content_type.as_ref()).await {
|
match registry.list_genres("", content_type.as_ref()).await {
|
||||||
Ok(genres) => ok_json(&genres),
|
Ok(genres) => ok_json(&genres),
|
||||||
Err(e) => domain_err(e),
|
Err(e) => domain_err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_media(
|
pub async fn search_media(
|
||||||
provider: &Arc<dyn IMediaProvider>,
|
registry: &Arc<infra::ProviderRegistry>,
|
||||||
content_type: Option<ContentType>,
|
content_type: Option<ContentType>,
|
||||||
genres: Vec<String>,
|
genres: Vec<String>,
|
||||||
search_term: Option<String>,
|
search_term: Option<String>,
|
||||||
@@ -36,7 +36,7 @@ pub async fn search_media(
|
|||||||
collections,
|
collections,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match provider.fetch_items(&filter).await {
|
match registry.fetch_items("", &filter).await {
|
||||||
Ok(items) => ok_json(&items),
|
Ok(items) => ok_json(&items),
|
||||||
Err(e) => domain_err(e),
|
Err(e) => domain_err(e),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
FillStrategy,
|
FillStrategy,
|
||||||
ContentType,
|
ContentType,
|
||||||
MediaFilter,
|
MediaFilter,
|
||||||
ProviderCapabilities,
|
ProviderInfo,
|
||||||
RecyclePolicy,
|
RecyclePolicy,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -57,10 +57,12 @@ const blockSchema = z.object({
|
|||||||
type: z.literal("algorithmic"),
|
type: z.literal("algorithmic"),
|
||||||
filter: mediaFilterSchema,
|
filter: mediaFilterSchema,
|
||||||
strategy: z.enum(["best_fit", "sequential", "random"]),
|
strategy: z.enum(["best_fit", "sequential", "random"]),
|
||||||
|
provider_id: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("manual"),
|
type: z.literal("manual"),
|
||||||
items: z.array(z.string()),
|
items: z.array(z.string()),
|
||||||
|
provider_id: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
loop_on_finish: z.boolean().optional(),
|
loop_on_finish: z.boolean().optional(),
|
||||||
@@ -239,7 +241,8 @@ interface AlgorithmicFilterEditorProps {
|
|||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
setFilter: (patch: Partial<MediaFilter>) => void;
|
setFilter: (patch: Partial<MediaFilter>) => void;
|
||||||
setStrategy: (strategy: FillStrategy) => void;
|
setStrategy: (strategy: FillStrategy) => void;
|
||||||
capabilities?: ProviderCapabilities;
|
setProviderId: (id: string) => void;
|
||||||
|
providers: ProviderInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlgorithmicFilterEditor({
|
function AlgorithmicFilterEditor({
|
||||||
@@ -248,16 +251,23 @@ function AlgorithmicFilterEditor({
|
|||||||
errors,
|
errors,
|
||||||
setFilter,
|
setFilter,
|
||||||
setStrategy,
|
setStrategy,
|
||||||
capabilities,
|
setProviderId,
|
||||||
|
providers,
|
||||||
}: AlgorithmicFilterEditorProps) {
|
}: AlgorithmicFilterEditorProps) {
|
||||||
const [showGenres, setShowGenres] = useState(false);
|
const [showGenres, setShowGenres] = useState(false);
|
||||||
|
|
||||||
const { data: collections, isLoading: loadingCollections } = useCollections();
|
const providerId = content.provider_id ?? "";
|
||||||
|
const capabilities = providers.find((p) => p.id === providerId)?.capabilities
|
||||||
|
?? providers[0]?.capabilities;
|
||||||
|
|
||||||
|
const { data: collections, isLoading: loadingCollections } = useCollections(providerId || undefined);
|
||||||
const { data: series, isLoading: loadingSeries } = useSeries(undefined, {
|
const { data: series, isLoading: loadingSeries } = useSeries(undefined, {
|
||||||
enabled: capabilities?.series !== false,
|
enabled: capabilities?.series !== false,
|
||||||
|
provider: providerId || undefined,
|
||||||
});
|
});
|
||||||
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, {
|
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, {
|
||||||
enabled: capabilities?.genres !== false,
|
enabled: capabilities?.genres !== false,
|
||||||
|
provider: providerId || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEpisode = content.filter.content_type === "episode";
|
const isEpisode = content.filter.content_type === "episode";
|
||||||
@@ -270,6 +280,16 @@ function AlgorithmicFilterEditor({
|
|||||||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
||||||
|
|
||||||
|
{providers.length > 1 && (
|
||||||
|
<Field label="Provider">
|
||||||
|
<NativeSelect value={providerId} onChange={(v) => setProviderId(v)}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.id}</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Media type">
|
<Field label="Media type">
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
@@ -430,7 +450,7 @@ function AlgorithmicFilterEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
|
{/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
|
||||||
<FilterPreview filter={content.filter} strategy={content.strategy} />
|
<FilterPreview filter={content.filter} strategy={content.strategy} provider={providerId || undefined} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -448,10 +468,10 @@ interface BlockEditorProps {
|
|||||||
onChange: (block: ProgrammingBlock) => void;
|
onChange: (block: ProgrammingBlock) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
capabilities?: ProviderCapabilities;
|
providers: ProviderInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, capabilities }: BlockEditorProps) {
|
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, providers }: BlockEditorProps) {
|
||||||
const [expanded, setExpanded] = useState(isSelected);
|
const [expanded, setExpanded] = useState(isSelected);
|
||||||
const elRef = useRef<HTMLDivElement>(null);
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -470,12 +490,13 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
const pfx = `blocks.${index}`;
|
const pfx = `blocks.${index}`;
|
||||||
|
|
||||||
const setContentType = (type: "algorithmic" | "manual") => {
|
const setContentType = (type: "algorithmic" | "manual") => {
|
||||||
|
const pid = content.provider_id ?? "";
|
||||||
onChange({
|
onChange({
|
||||||
...block,
|
...block,
|
||||||
content:
|
content:
|
||||||
type === "algorithmic"
|
type === "algorithmic"
|
||||||
? { type: "algorithmic", filter: defaultFilter(), strategy: "random" }
|
? { type: "algorithmic", filter: defaultFilter(), strategy: "random", provider_id: pid }
|
||||||
: { type: "manual", items: [] },
|
: { type: "manual", items: [], provider_id: pid },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -489,6 +510,10 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
onChange({ ...block, content: { ...content, strategy } });
|
onChange({ ...block, content: { ...content, strategy } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setProviderId = (id: string) => {
|
||||||
|
onChange({ ...block, content: { ...content, provider_id: id } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={elRef}
|
ref={elRef}
|
||||||
@@ -569,7 +594,8 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
errors={errors}
|
errors={errors}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
setStrategy={setStrategy}
|
setStrategy={setStrategy}
|
||||||
capabilities={capabilities}
|
setProviderId={setProviderId}
|
||||||
|
providers={providers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{content.strategy === "sequential" && (
|
{content.strategy === "sequential" && (
|
||||||
@@ -734,7 +760,7 @@ interface EditChannelSheetProps {
|
|||||||
) => void;
|
) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
capabilities?: ProviderCapabilities;
|
providers?: ProviderInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditChannelSheet({
|
export function EditChannelSheet({
|
||||||
@@ -744,7 +770,7 @@ export function EditChannelSheet({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
isPending,
|
isPending,
|
||||||
error,
|
error,
|
||||||
capabilities,
|
providers = [],
|
||||||
}: EditChannelSheetProps) {
|
}: EditChannelSheetProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -1044,7 +1070,7 @@ export function EditChannelSheet({
|
|||||||
onChange={(b) => updateBlock(idx, b)}
|
onChange={(b) => updateBlock(idx, b)}
|
||||||
onRemove={() => removeBlock(idx)}
|
onRemove={() => removeBlock(idx)}
|
||||||
onSelect={() => setSelectedBlockId(block.id)}
|
onSelect={() => setSelectedBlockId(block.id)}
|
||||||
capabilities={capabilities}
|
providers={providers}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { MediaFilter, LibraryItemResponse } from "@/lib/types";
|
|||||||
interface FilterPreviewProps {
|
interface FilterPreviewProps {
|
||||||
filter: MediaFilter;
|
filter: MediaFilter;
|
||||||
strategy?: string;
|
strategy?: string;
|
||||||
|
provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDuration(secs: number): string {
|
function fmtDuration(secs: number): string {
|
||||||
@@ -32,10 +33,10 @@ function ItemRow({ item }: { item: LibraryItemResponse }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot = { filter: MediaFilter; strategy?: string };
|
type Snapshot = { filter: MediaFilter; strategy?: string; provider?: string };
|
||||||
|
|
||||||
export function FilterPreview({ filter, strategy }: FilterPreviewProps) {
|
export function FilterPreview({ filter, strategy, provider }: FilterPreviewProps) {
|
||||||
// Capture both filter and strategy at click time so edits don't silently
|
// Capture filter, strategy, and provider at click time so edits don't silently
|
||||||
// re-fetch while the user is still configuring the block.
|
// re-fetch while the user is still configuring the block.
|
||||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||||
|
|
||||||
@@ -43,14 +44,16 @@ export function FilterPreview({ filter, strategy }: FilterPreviewProps) {
|
|||||||
snapshot?.filter ?? null,
|
snapshot?.filter ?? null,
|
||||||
!!snapshot,
|
!!snapshot,
|
||||||
snapshot?.strategy,
|
snapshot?.strategy,
|
||||||
|
snapshot?.provider,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePreview = () => setSnapshot({ filter: { ...filter }, strategy });
|
const handlePreview = () => setSnapshot({ filter: { ...filter }, strategy, provider });
|
||||||
|
|
||||||
const filterChanged =
|
const filterChanged =
|
||||||
snapshot !== null &&
|
snapshot !== null &&
|
||||||
(JSON.stringify(snapshot.filter) !== JSON.stringify(filter) ||
|
(JSON.stringify(snapshot.filter) !== JSON.stringify(filter) ||
|
||||||
snapshot.strategy !== strategy);
|
snapshot.strategy !== strategy ||
|
||||||
|
snapshot.provider !== provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ export default function DashboardPage() {
|
|||||||
onSubmit={handleEdit}
|
onSubmit={handleEdit}
|
||||||
isPending={updateChannel.isPending}
|
isPending={updateChannel.isPending}
|
||||||
error={updateChannel.error?.message}
|
error={updateChannel.error?.message}
|
||||||
capabilities={capabilities}
|
providers={config?.providers ?? []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScheduleSheet
|
<ScheduleSheet
|
||||||
|
|||||||
@@ -7,38 +7,38 @@ import type { MediaFilter } from "@/lib/types";
|
|||||||
|
|
||||||
const STALE = 10 * 60 * 1000; // 10 min — library metadata rarely changes in a session
|
const STALE = 10 * 60 * 1000; // 10 min — library metadata rarely changes in a session
|
||||||
|
|
||||||
/** List top-level collections (Jellyfin libraries, Plex sections, etc.) */
|
/** List top-level collections for a provider (empty = primary). */
|
||||||
export function useCollections() {
|
export function useCollections(provider?: string) {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["library", "collections"],
|
queryKey: ["library", "collections", provider ?? null],
|
||||||
queryFn: () => api.library.collections(token!),
|
queryFn: () => api.library.collections(token!, provider),
|
||||||
enabled: !!token,
|
enabled: !!token,
|
||||||
staleTime: STALE,
|
staleTime: STALE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List TV series, optionally scoped to a collection.
|
* List TV series, optionally scoped to a collection and provider.
|
||||||
* All series are loaded upfront so the series picker can filter client-side
|
* All series are loaded upfront so the series picker can filter client-side
|
||||||
* without a request per keystroke.
|
* without a request per keystroke.
|
||||||
*/
|
*/
|
||||||
export function useSeries(collectionId?: string, opts?: { enabled?: boolean }) {
|
export function useSeries(collectionId?: string, opts?: { enabled?: boolean; provider?: string }) {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["library", "series", collectionId ?? null],
|
queryKey: ["library", "series", collectionId ?? null, opts?.provider ?? null],
|
||||||
queryFn: () => api.library.series(token!, collectionId),
|
queryFn: () => api.library.series(token!, collectionId, opts?.provider),
|
||||||
enabled: !!token && (opts?.enabled ?? true),
|
enabled: !!token && (opts?.enabled ?? true),
|
||||||
staleTime: STALE,
|
staleTime: STALE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List available genres, optionally scoped to a content type. */
|
/** List available genres, optionally scoped to a content type and provider. */
|
||||||
export function useGenres(contentType?: string, opts?: { enabled?: boolean }) {
|
export function useGenres(contentType?: string, opts?: { enabled?: boolean; provider?: string }) {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["library", "genres", contentType ?? null],
|
queryKey: ["library", "genres", contentType ?? null, opts?.provider ?? null],
|
||||||
queryFn: () => api.library.genres(token!, contentType),
|
queryFn: () => api.library.genres(token!, contentType, opts?.provider),
|
||||||
enabled: !!token && (opts?.enabled ?? true),
|
enabled: !!token && (opts?.enabled ?? true),
|
||||||
staleTime: STALE,
|
staleTime: STALE,
|
||||||
});
|
});
|
||||||
@@ -64,11 +64,12 @@ export function useLibraryItems(
|
|||||||
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres"> | null,
|
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres"> | null,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
strategy?: string,
|
strategy?: string,
|
||||||
|
provider?: string,
|
||||||
) {
|
) {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["library", "items", filter, strategy ?? null],
|
queryKey: ["library", "items", filter, strategy ?? null, provider ?? null],
|
||||||
queryFn: () => api.library.items(token!, filter!, 30, strategy),
|
queryFn: () => api.library.items(token!, filter!, 30, strategy, provider),
|
||||||
enabled: !!token && enabled && !!filter,
|
enabled: !!token && enabled && !!filter,
|
||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,19 +108,25 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
library: {
|
library: {
|
||||||
collections: (token: string) =>
|
collections: (token: string, provider?: string) => {
|
||||||
request<CollectionResponse[]>("/library/collections", { token }),
|
const params = new URLSearchParams();
|
||||||
|
if (provider) params.set("provider", provider);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<CollectionResponse[]>(`/library/collections${qs ? `?${qs}` : ""}`, { token });
|
||||||
|
},
|
||||||
|
|
||||||
series: (token: string, collectionId?: string) => {
|
series: (token: string, collectionId?: string, provider?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (collectionId) params.set("collection", collectionId);
|
if (collectionId) params.set("collection", collectionId);
|
||||||
|
if (provider) params.set("provider", provider);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return request<SeriesResponse[]>(`/library/series${qs ? `?${qs}` : ""}`, { token });
|
return request<SeriesResponse[]>(`/library/series${qs ? `?${qs}` : ""}`, { token });
|
||||||
},
|
},
|
||||||
|
|
||||||
genres: (token: string, contentType?: string) => {
|
genres: (token: string, contentType?: string, provider?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (contentType) params.set("type", contentType);
|
if (contentType) params.set("type", contentType);
|
||||||
|
if (provider) params.set("provider", provider);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return request<string[]>(`/library/genres${qs ? `?${qs}` : ""}`, { token });
|
return request<string[]>(`/library/genres${qs ? `?${qs}` : ""}`, { token });
|
||||||
},
|
},
|
||||||
@@ -130,6 +136,7 @@ export const api = {
|
|||||||
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">,
|
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
strategy?: string,
|
strategy?: string,
|
||||||
|
provider?: string,
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filter.search_term) params.set("q", filter.search_term);
|
if (filter.search_term) params.set("q", filter.search_term);
|
||||||
@@ -138,6 +145,7 @@ export const api = {
|
|||||||
if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
|
if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
|
||||||
params.set("limit", String(limit));
|
params.set("limit", String(limit));
|
||||||
if (strategy) params.set("strategy", strategy);
|
if (strategy) params.set("strategy", strategy);
|
||||||
|
if (provider) params.set("provider", provider);
|
||||||
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
|
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export interface RecyclePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type BlockContent =
|
export type BlockContent =
|
||||||
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy }
|
| { type: "algorithmic"; filter: MediaFilter; strategy: FillStrategy; provider_id?: string }
|
||||||
| { type: "manual"; items: string[] };
|
| { type: "manual"; items: string[]; provider_id?: string };
|
||||||
|
|
||||||
export interface ProgrammingBlock {
|
export interface ProgrammingBlock {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -95,8 +95,16 @@ export interface ProviderCapabilities {
|
|||||||
rescan: boolean;
|
rescan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderInfo {
|
||||||
|
id: string;
|
||||||
|
capabilities: ProviderCapabilities;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigResponse {
|
export interface ConfigResponse {
|
||||||
allow_registration: boolean;
|
allow_registration: boolean;
|
||||||
|
/** All registered providers. Added in multi-provider update. */
|
||||||
|
providers: ProviderInfo[];
|
||||||
|
/** Primary provider capabilities — kept for backward compat. */
|
||||||
provider_capabilities: ProviderCapabilities;
|
provider_capabilities: ProviderCapabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user