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:
2026-03-14 23:59:21 +01:00
parent c53892159a
commit ead65e6be2
21 changed files with 468 additions and 150 deletions

View File

@@ -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,
},
}

View File

@@ -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};

View File

@@ -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>>;
}

View File

@@ -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![]);