188 lines
5.8 KiB
Rust
188 lines
5.8 KiB
Rust
//! 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<String>,
|
|
pub season_number: Option<u32>,
|
|
pub episode_number: Option<u32>,
|
|
pub year: Option<u16>,
|
|
pub genres: Vec<String>,
|
|
pub tags: Vec<String>,
|
|
pub collection_id: Option<String>,
|
|
pub collection_name: Option<String>,
|
|
pub collection_type: Option<String>,
|
|
pub thumbnail_url: Option<String>,
|
|
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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub items_found: u32,
|
|
pub status: String,
|
|
pub error_msg: Option<String>,
|
|
}
|
|
|
|
/// Filter for searching the local library.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LibrarySearchFilter {
|
|
pub provider_id: Option<String>,
|
|
pub content_type: Option<ContentType>,
|
|
pub series_names: Vec<String>,
|
|
pub collection_id: Option<String>,
|
|
pub genres: Vec<String>,
|
|
pub decade: Option<u16>,
|
|
pub min_duration_secs: Option<u32>,
|
|
pub max_duration_secs: Option<u32>,
|
|
pub search_term: Option<String>,
|
|
pub season_number: Option<u32>,
|
|
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<String>,
|
|
pub genres: Vec<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<LibraryItem>, u32)>;
|
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>;
|
|
async fn list_collections(&self, provider_id: Option<&str>) -> DomainResult<Vec<LibraryCollection>>;
|
|
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>>;
|
|
async fn list_genres(&self, content_type: Option<&ContentType>, provider_id: Option<&str>) -> DomainResult<Vec<String>>;
|
|
async fn upsert_items(&self, provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()>;
|
|
async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>;
|
|
async fn log_sync_start(&self, provider_id: &str) -> DomainResult<i64>;
|
|
async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>;
|
|
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>>;
|
|
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool>;
|
|
async fn list_shows(
|
|
&self,
|
|
provider_id: Option<&str>,
|
|
search_term: Option<&str>,
|
|
genres: &[String],
|
|
) -> DomainResult<Vec<ShowSummary>>;
|
|
async fn list_seasons(
|
|
&self,
|
|
series_name: &str,
|
|
provider_id: Option<&str>,
|
|
) -> DomainResult<Vec<SeasonSummary>>;
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|