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:
@@ -17,6 +17,7 @@ pub mod auth;
|
||||
pub mod db;
|
||||
pub mod factory;
|
||||
pub mod jellyfin;
|
||||
pub mod provider_registry;
|
||||
mod channel_repository;
|
||||
mod schedule_repository;
|
||||
mod user_repository;
|
||||
@@ -26,6 +27,7 @@ pub mod local_files;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use db::run_migrations;
|
||||
pub use provider_registry::ProviderRegistry;
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
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()) {
|
||||
continue;
|
||||
}
|
||||
// Must start with 19 or 20.
|
||||
let prefix = chars[i] as u8 * 10 + chars[i + 1] as u8 - b'0' * 11;
|
||||
// Simpler: just parse and range-check.
|
||||
// Parse and range-check.
|
||||
let s4: String = chars[i..i + 4].iter().collect();
|
||||
let num: u16 = s4.parse().ok()?;
|
||||
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.
|
||||
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
||||
let after_ok = i + 4 >= n || !chars[i + 4].is_ascii_digit();
|
||||
let _ = prefix;
|
||||
if before_ok && after_ok {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user