//! 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 season_number: 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, season_number: None, offset: 0, limit: 50, } } } /// Aggregated summary of a TV show derived from synced episodes. #[derive(Debug, Clone)] pub struct ShowSummary { pub series_name: String, pub episode_count: u32, pub season_count: u32, pub thumbnail_url: Option, pub genres: Vec, } /// Aggregated summary of one season of a TV show. #[derive(Debug, Clone)] pub struct SeasonSummary { pub season_number: u32, pub episode_count: u32, pub thumbnail_url: Option, } /// 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; async fn list_shows( &self, provider_id: Option<&str>, search_term: Option<&str>, genres: &[String], ) -> DomainResult>; async fn list_seasons( &self, series_name: &str, provider_id: Option<&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); } }