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:
@@ -214,7 +214,7 @@ impl ProgrammingBlock {
|
||||
name: name.into(),
|
||||
start_time,
|
||||
duration_mins,
|
||||
content: BlockContent::Algorithmic { filter, strategy },
|
||||
content: BlockContent::Algorithmic { filter, strategy, provider_id: String::new() },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: AccessMode::default(),
|
||||
@@ -233,7 +233,7 @@ impl ProgrammingBlock {
|
||||
name: name.into(),
|
||||
start_time,
|
||||
duration_mins,
|
||||
content: BlockContent::Manual { items },
|
||||
content: BlockContent::Manual { items, provider_id: String::new() },
|
||||
loop_on_finish: true,
|
||||
ignore_recycle_policy: false,
|
||||
access_mode: AccessMode::default(),
|
||||
@@ -247,11 +247,21 @@ impl ProgrammingBlock {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum BlockContent {
|
||||
/// 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.
|
||||
Algorithmic {
|
||||
filter: MediaFilter,
|
||||
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
|
||||
pub use entities::*;
|
||||
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 iptv::{generate_m3u, generate_xmltv};
|
||||
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,
|
||||
};
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::ports::{IMediaProvider, StreamQuality};
|
||||
use crate::ports::{IProviderRegistry, StreamQuality};
|
||||
use crate::repositories::{ChannelRepository, ScheduleRepository};
|
||||
use crate::value_objects::{
|
||||
BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
||||
@@ -26,19 +26,19 @@ mod recycle;
|
||||
/// `ScheduledSlot`s via the `IMediaProvider`, and applying the `RecyclePolicy`
|
||||
/// to avoid replaying recently aired items.
|
||||
pub struct ScheduleEngineService {
|
||||
media_provider: Arc<dyn IMediaProvider>,
|
||||
provider_registry: Arc<dyn IProviderRegistry>,
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
schedule_repo: Arc<dyn ScheduleRepository>,
|
||||
}
|
||||
|
||||
impl ScheduleEngineService {
|
||||
pub fn new(
|
||||
media_provider: Arc<dyn IMediaProvider>,
|
||||
provider_registry: Arc<dyn IProviderRegistry>,
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
schedule_repo: Arc<dyn ScheduleRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
media_provider,
|
||||
provider_registry,
|
||||
channel_repo,
|
||||
schedule_repo,
|
||||
}
|
||||
@@ -223,9 +223,9 @@ impl ScheduleEngineService {
|
||||
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> {
|
||||
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.
|
||||
@@ -256,12 +256,12 @@ impl ScheduleEngineService {
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||
match &block.content {
|
||||
BlockContent::Manual { items } => {
|
||||
BlockContent::Manual { items, .. } => {
|
||||
self.resolve_manual(items, start, end, block.id).await
|
||||
}
|
||||
BlockContent::Algorithmic { filter, strategy } => {
|
||||
BlockContent::Algorithmic { filter, strategy, provider_id } => {
|
||||
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.loop_on_finish,
|
||||
block.ignore_recycle_policy,
|
||||
@@ -287,7 +287,7 @@ impl ScheduleEngineService {
|
||||
if cursor >= end {
|
||||
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 =
|
||||
(cursor + Duration::seconds(item.duration_secs as i64)).min(end);
|
||||
slots.push(ScheduledSlot {
|
||||
@@ -312,6 +312,7 @@ impl ScheduleEngineService {
|
||||
/// previous generation. Used only by `Sequential` for series continuity.
|
||||
async fn resolve_algorithmic(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
filter: &MediaFilter,
|
||||
strategy: &FillStrategy,
|
||||
start: DateTime<Utc>,
|
||||
@@ -327,7 +328,7 @@ impl ScheduleEngineService {
|
||||
// `candidates` — all items matching the filter, in provider order.
|
||||
// Kept separate from `pool` so Sequential can rotate through the full
|
||||
// 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() {
|
||||
return Ok(vec![]);
|
||||
|
||||
Reference in New Issue
Block a user