diff --git a/k-tv-backend/api/src/poller.rs b/k-tv-backend/api/src/poller.rs index 17ae9f5..7b5ca02 100644 --- a/k-tv-backend/api/src/poller.rs +++ b/k-tv-backend/api/src/poller.rs @@ -300,6 +300,8 @@ mod tests { series_name: None, season_number: None, episode_number: None, + thumbnail_url: None, + collection_id: None, }, source_block_id: Uuid::new_v4(), } diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index 1a49b8d..aa5a7cb 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -346,6 +346,10 @@ pub struct MediaItem { pub season_number: Option, /// For episodes: episode number within the season (1-based). pub episode_number: Option, + /// Provider-served thumbnail image URL, populated if available. + pub thumbnail_url: Option, + /// Provider-specific collection this item belongs to. + pub collection_id: Option, } /// A fully resolved 7-day broadcast program for one channel. diff --git a/k-tv-backend/domain/src/events.rs b/k-tv-backend/domain/src/events.rs index 02f1686..e7ecc3a 100644 --- a/k-tv-backend/domain/src/events.rs +++ b/k-tv-backend/domain/src/events.rs @@ -58,6 +58,8 @@ mod tests { series_name: None, season_number: None, episode_number: None, + thumbnail_url: None, + collection_id: None, }, source_block_id: Uuid::new_v4(), } diff --git a/k-tv-backend/domain/src/lib.rs b/k-tv-backend/domain/src/lib.rs index aaefa18..4f61e2c 100644 --- a/k-tv-backend/domain/src/lib.rs +++ b/k-tv-backend/domain/src/lib.rs @@ -6,6 +6,7 @@ pub mod entities; pub mod errors; pub mod iptv; +pub mod library; pub mod ports; pub mod repositories; pub mod services; @@ -19,5 +20,10 @@ pub use events::DomainEvent; pub use ports::{Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamingProtocol, StreamQuality}; pub use repositories::*; pub use iptv::{generate_m3u, generate_xmltv}; +pub use library::{ + ILibraryRepository, LibraryCollection, LibraryItem, LibrarySearchFilter, + LibrarySyncAdapter, LibrarySyncLogEntry, LibrarySyncResult, +}; +pub use repositories::IAppSettingsRepository; pub use services::{ChannelService, ScheduleEngineService, UserService}; pub use value_objects::*; diff --git a/k-tv-backend/domain/src/library.rs b/k-tv-backend/domain/src/library.rs new file mode 100644 index 0000000..cc0ce96 --- /dev/null +++ b/k-tv-backend/domain/src/library.rs @@ -0,0 +1,156 @@ +//! Library domain types and ports. + +use async_trait::async_trait; + +use crate::{ContentType, DomainResult, IMediaProvider}; + +/// A media item stored in the local library cache. +#[derive(Debug, Clone)] +pub struct LibraryItem { + pub id: String, + pub provider_id: String, + pub external_id: String, + pub title: String, + pub content_type: ContentType, + pub duration_secs: u32, + pub series_name: Option, + pub season_number: Option, + pub episode_number: Option, + pub year: Option, + pub genres: Vec, + pub tags: Vec, + pub collection_id: Option, + pub collection_name: Option, + pub collection_type: Option, + pub thumbnail_url: Option, + pub synced_at: String, +} + +/// A collection summary derived from synced library items. +#[derive(Debug, Clone)] +pub struct LibraryCollection { + pub id: String, + pub name: String, + pub collection_type: Option, +} + +/// Result of a single provider sync run. +#[derive(Debug, Clone)] +pub struct LibrarySyncResult { + pub provider_id: String, + pub items_found: u32, + pub duration_ms: u64, + pub error: Option, +} + +/// Log entry from library_sync_log table. +#[derive(Debug, Clone)] +pub struct LibrarySyncLogEntry { + pub id: i64, + pub provider_id: String, + pub started_at: String, + pub finished_at: Option, + pub items_found: u32, + pub status: String, + pub error_msg: Option, +} + +/// Filter for searching the local library. +#[derive(Debug, Clone)] +pub struct LibrarySearchFilter { + pub provider_id: Option, + pub content_type: Option, + pub series_names: Vec, + pub collection_id: Option, + pub genres: Vec, + pub decade: Option, + pub min_duration_secs: Option, + pub max_duration_secs: Option, + pub search_term: Option, + pub offset: u32, + pub limit: u32, +} + +impl Default for LibrarySearchFilter { + fn default() -> Self { + Self { + provider_id: None, + content_type: None, + series_names: vec![], + collection_id: None, + genres: vec![], + decade: None, + min_duration_secs: None, + max_duration_secs: None, + search_term: None, + offset: 0, + limit: 50, + } + } +} + +/// Port: sync one provider's items into the library repo. +/// DB writes are handled entirely inside implementations — no pool in the trait. +#[async_trait] +pub trait LibrarySyncAdapter: Send + Sync { + async fn sync_provider( + &self, + provider: &dyn IMediaProvider, + provider_id: &str, + ) -> LibrarySyncResult; +} + +/// Port: read/write access to the persisted library. +#[async_trait] +pub trait ILibraryRepository: Send + Sync { + async fn search(&self, filter: &LibrarySearchFilter) -> DomainResult<(Vec, u32)>; + async fn get_by_id(&self, id: &str) -> DomainResult>; + async fn list_collections(&self, provider_id: Option<&str>) -> DomainResult>; + async fn list_series(&self, provider_id: Option<&str>) -> DomainResult>; + async fn list_genres(&self, content_type: Option<&ContentType>, provider_id: Option<&str>) -> DomainResult>; + async fn upsert_items(&self, provider_id: &str, items: Vec) -> DomainResult<()>; + async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>; + async fn log_sync_start(&self, provider_id: &str) -> DomainResult; + async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>; + async fn latest_sync_status(&self) -> DomainResult>; + async fn is_sync_running(&self, provider_id: &str) -> DomainResult; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn library_item_id_uses_double_colon_separator() { + let item = LibraryItem { + id: "jellyfin::abc123".to_string(), + provider_id: "jellyfin".to_string(), + external_id: "abc123".to_string(), + title: "Test Movie".to_string(), + content_type: crate::ContentType::Movie, + duration_secs: 7200, + series_name: None, + season_number: None, + episode_number: None, + year: Some(2020), + genres: vec!["Action".to_string()], + tags: vec![], + collection_id: None, + collection_name: None, + collection_type: None, + thumbnail_url: None, + synced_at: "2026-03-19T00:00:00Z".to_string(), + }; + assert!(item.id.contains("::")); + assert_eq!(item.provider_id, "jellyfin"); + } + + #[test] + fn library_search_filter_defaults_are_empty() { + let f = LibrarySearchFilter::default(); + assert!(f.genres.is_empty()); + assert!(f.series_names.is_empty()); + assert_eq!(f.offset, 0); + assert_eq!(f.limit, 50); + } +} diff --git a/k-tv-backend/domain/src/repositories.rs b/k-tv-backend/domain/src/repositories.rs index b1a3cbb..50000a9 100644 --- a/k-tv-backend/domain/src/repositories.rs +++ b/k-tv-backend/domain/src/repositories.rs @@ -180,3 +180,14 @@ pub trait TranscodeSettingsRepository: Send + Sync { /// Persist the cleanup TTL (upsert — always row id=1). async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()>; } + +/// Repository port for general admin settings (app_settings table). +#[async_trait] +pub trait IAppSettingsRepository: Send + Sync { + /// Get a setting value by key. Returns None if not set. + async fn get(&self, key: &str) -> DomainResult>; + /// Set a setting value (upsert). + async fn set(&self, key: &str, value: &str) -> DomainResult<()>; + /// Get all settings as (key, value) pairs. + async fn get_all(&self) -> DomainResult>; +} diff --git a/k-tv-backend/infra/src/jellyfin/mapping.rs b/k-tv-backend/infra/src/jellyfin/mapping.rs index 18c353f..ebea93d 100644 --- a/k-tv-backend/infra/src/jellyfin/mapping.rs +++ b/k-tv-backend/infra/src/jellyfin/mapping.rs @@ -31,5 +31,7 @@ pub(super) fn map_jellyfin_item(item: JellyfinItem) -> Option { series_name: item.series_name, season_number: item.parent_index_number, episode_number: item.index_number, + thumbnail_url: None, + collection_id: None, }) } diff --git a/k-tv-backend/infra/src/local_files/provider.rs b/k-tv-backend/infra/src/local_files/provider.rs index bbdc669..d308494 100644 --- a/k-tv-backend/infra/src/local_files/provider.rs +++ b/k-tv-backend/infra/src/local_files/provider.rs @@ -51,6 +51,8 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem { series_name: None, season_number: None, episode_number: None, + thumbnail_url: None, + collection_id: None, } }