domain models: schedule, library, config_snapshot, activity, provider_config
This commit is contained in:
624
crates/domain/src/models/library.rs
Normal file
624
crates/domain/src/models/library.rs
Normal file
@@ -0,0 +1,624 @@
|
||||
use crate::value_objects::ContentType;
|
||||
|
||||
// ============================================================================
|
||||
// LibraryItem
|
||||
// ============================================================================
|
||||
|
||||
/// A media item stored in the local library cache, synced from a provider.
|
||||
///
|
||||
/// The `id` format is `"{provider_id}::{external_id}"` -- this composite key
|
||||
/// allows items from multiple providers to coexist in the same table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryItem {
|
||||
id: String,
|
||||
provider_id: String,
|
||||
external_id: String,
|
||||
title: String,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
series_name: Option<String>,
|
||||
season_number: Option<u32>,
|
||||
episode_number: Option<u32>,
|
||||
year: Option<u16>,
|
||||
genres: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
collection_id: Option<String>,
|
||||
collection_name: Option<String>,
|
||||
collection_type: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
synced_at: String,
|
||||
}
|
||||
|
||||
impl LibraryItem {
|
||||
/// Create a new library item with required fields; optional fields default to None/empty.
|
||||
pub fn new(
|
||||
provider_id: impl Into<String>,
|
||||
external_id: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
synced_at: impl Into<String>,
|
||||
) -> Self {
|
||||
let provider_id = provider_id.into();
|
||||
let external_id = external_id.into();
|
||||
let id = format!("{}::{}", provider_id, external_id);
|
||||
Self {
|
||||
id,
|
||||
provider_id,
|
||||
external_id,
|
||||
title: title.into(),
|
||||
content_type,
|
||||
duration_secs,
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
year: None,
|
||||
genres: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
collection_id: None,
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
thumbnail_url: None,
|
||||
synced_at: synced_at.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_persistence(
|
||||
id: String,
|
||||
provider_id: String,
|
||||
external_id: String,
|
||||
title: String,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
series_name: Option<String>,
|
||||
season_number: Option<u32>,
|
||||
episode_number: Option<u32>,
|
||||
year: Option<u16>,
|
||||
genres: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
collection_id: Option<String>,
|
||||
collection_name: Option<String>,
|
||||
collection_type: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
synced_at: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
provider_id,
|
||||
external_id,
|
||||
title,
|
||||
content_type,
|
||||
duration_secs,
|
||||
series_name,
|
||||
season_number,
|
||||
episode_number,
|
||||
year,
|
||||
genres,
|
||||
tags,
|
||||
collection_id,
|
||||
collection_name,
|
||||
collection_type,
|
||||
thumbnail_url,
|
||||
synced_at,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn provider_id(&self) -> &str {
|
||||
&self.provider_id
|
||||
}
|
||||
|
||||
pub fn external_id(&self) -> &str {
|
||||
&self.external_id
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn content_type(&self) -> &ContentType {
|
||||
&self.content_type
|
||||
}
|
||||
|
||||
pub fn duration_secs(&self) -> u32 {
|
||||
self.duration_secs
|
||||
}
|
||||
|
||||
pub fn series_name(&self) -> Option<&str> {
|
||||
self.series_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn season_number(&self) -> Option<u32> {
|
||||
self.season_number
|
||||
}
|
||||
|
||||
pub fn episode_number(&self) -> Option<u32> {
|
||||
self.episode_number
|
||||
}
|
||||
|
||||
pub fn year(&self) -> Option<u16> {
|
||||
self.year
|
||||
}
|
||||
|
||||
pub fn genres(&self) -> &[String] {
|
||||
&self.genres
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> &[String] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
pub fn collection_id(&self) -> Option<&str> {
|
||||
self.collection_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn collection_name(&self) -> Option<&str> {
|
||||
self.collection_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn collection_type(&self) -> Option<&str> {
|
||||
self.collection_type.as_deref()
|
||||
}
|
||||
|
||||
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||
self.thumbnail_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn synced_at(&self) -> &str {
|
||||
&self.synced_at
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LibraryCollection
|
||||
// ============================================================================
|
||||
|
||||
/// A collection summary derived from synced library items.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryCollection {
|
||||
id: String,
|
||||
name: String,
|
||||
collection_type: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryCollection {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
collection_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: String,
|
||||
name: String,
|
||||
collection_type: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
collection_type,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn collection_type(&self) -> Option<&str> {
|
||||
self.collection_type.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LibrarySyncResult
|
||||
// ============================================================================
|
||||
|
||||
/// Result of a single provider sync run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibrarySyncResult {
|
||||
provider_id: String,
|
||||
items_found: u32,
|
||||
duration_ms: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl LibrarySyncResult {
|
||||
pub fn new(
|
||||
provider_id: impl Into<String>,
|
||||
items_found: u32,
|
||||
duration_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider_id: provider_id.into(),
|
||||
items_found,
|
||||
duration_ms,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_error(
|
||||
provider_id: impl Into<String>,
|
||||
duration_ms: u64,
|
||||
error: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider_id: provider_id.into(),
|
||||
items_found: 0,
|
||||
duration_ms,
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
provider_id: String,
|
||||
items_found: u32,
|
||||
duration_ms: u64,
|
||||
error: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider_id,
|
||||
items_found,
|
||||
duration_ms,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn provider_id(&self) -> &str {
|
||||
&self.provider_id
|
||||
}
|
||||
|
||||
pub fn items_found(&self) -> u32 {
|
||||
self.items_found
|
||||
}
|
||||
|
||||
pub fn duration_ms(&self) -> u64 {
|
||||
self.duration_ms
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
self.error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LibrarySyncLogEntry
|
||||
// ============================================================================
|
||||
|
||||
/// Log entry from the library_sync_log table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibrarySyncLogEntry {
|
||||
id: i64,
|
||||
provider_id: String,
|
||||
started_at: String,
|
||||
finished_at: Option<String>,
|
||||
items_found: u32,
|
||||
status: String,
|
||||
error_msg: Option<String>,
|
||||
}
|
||||
|
||||
impl LibrarySyncLogEntry {
|
||||
pub fn new(id: i64, provider_id: impl Into<String>, started_at: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
provider_id: provider_id.into(),
|
||||
started_at: started_at.into(),
|
||||
finished_at: None,
|
||||
items_found: 0,
|
||||
status: "running".to_string(),
|
||||
error_msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
id: i64,
|
||||
provider_id: String,
|
||||
started_at: String,
|
||||
finished_at: Option<String>,
|
||||
items_found: u32,
|
||||
status: String,
|
||||
error_msg: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
provider_id,
|
||||
started_at,
|
||||
finished_at,
|
||||
items_found,
|
||||
status,
|
||||
error_msg,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> i64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn provider_id(&self) -> &str {
|
||||
&self.provider_id
|
||||
}
|
||||
|
||||
pub fn started_at(&self) -> &str {
|
||||
&self.started_at
|
||||
}
|
||||
|
||||
pub fn finished_at(&self) -> Option<&str> {
|
||||
self.finished_at.as_deref()
|
||||
}
|
||||
|
||||
pub fn items_found(&self) -> u32 {
|
||||
self.items_found
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &str {
|
||||
&self.status
|
||||
}
|
||||
|
||||
pub fn error_msg(&self) -> Option<&str> {
|
||||
self.error_msg.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ShowSummary
|
||||
// ============================================================================
|
||||
|
||||
/// Aggregated summary of a TV show derived from synced episodes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowSummary {
|
||||
series_name: String,
|
||||
episode_count: u32,
|
||||
season_count: u32,
|
||||
thumbnail_url: Option<String>,
|
||||
genres: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShowSummary {
|
||||
pub fn new(
|
||||
series_name: impl Into<String>,
|
||||
episode_count: u32,
|
||||
season_count: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
series_name: series_name.into(),
|
||||
episode_count,
|
||||
season_count,
|
||||
thumbnail_url: None,
|
||||
genres: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
series_name: String,
|
||||
episode_count: u32,
|
||||
season_count: u32,
|
||||
thumbnail_url: Option<String>,
|
||||
genres: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
series_name,
|
||||
episode_count,
|
||||
season_count,
|
||||
thumbnail_url,
|
||||
genres,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn series_name(&self) -> &str {
|
||||
&self.series_name
|
||||
}
|
||||
|
||||
pub fn episode_count(&self) -> u32 {
|
||||
self.episode_count
|
||||
}
|
||||
|
||||
pub fn season_count(&self) -> u32 {
|
||||
self.season_count
|
||||
}
|
||||
|
||||
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||
self.thumbnail_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn genres(&self) -> &[String] {
|
||||
&self.genres
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SeasonSummary
|
||||
// ============================================================================
|
||||
|
||||
/// Aggregated summary of one season of a TV show.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeasonSummary {
|
||||
season_number: u32,
|
||||
episode_count: u32,
|
||||
thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
impl SeasonSummary {
|
||||
pub fn new(season_number: u32, episode_count: u32) -> Self {
|
||||
Self {
|
||||
season_number,
|
||||
episode_count,
|
||||
thumbnail_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persistence(
|
||||
season_number: u32,
|
||||
episode_count: u32,
|
||||
thumbnail_url: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
season_number,
|
||||
episode_count,
|
||||
thumbnail_url,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn season_number(&self) -> u32 {
|
||||
self.season_number
|
||||
}
|
||||
|
||||
pub fn episode_count(&self) -> u32 {
|
||||
self.episode_count
|
||||
}
|
||||
|
||||
pub fn thumbnail_url(&self) -> Option<&str> {
|
||||
self.thumbnail_url.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_item_new_generates_composite_id() {
|
||||
let item = LibraryItem::new("jellyfin", "abc123", "Test Movie", ContentType::Movie, 7200, "2026-03-19T00:00:00Z");
|
||||
assert_eq!(item.id(), "jellyfin::abc123");
|
||||
assert_eq!(item.provider_id(), "jellyfin");
|
||||
assert_eq!(item.external_id(), "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_item_new_defaults_optional_fields() {
|
||||
let item = LibraryItem::new("jf", "1", "Movie", ContentType::Movie, 3600, "2026-01-01");
|
||||
assert!(item.series_name().is_none());
|
||||
assert!(item.season_number().is_none());
|
||||
assert!(item.genres().is_empty());
|
||||
assert!(item.tags().is_empty());
|
||||
assert!(item.collection_id().is_none());
|
||||
assert!(item.thumbnail_url().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_item_from_persistence_all_fields() {
|
||||
let item = LibraryItem::from_persistence(
|
||||
"jf::abc".into(),
|
||||
"jf".into(),
|
||||
"abc".into(),
|
||||
"Breaking Bad S01E01".into(),
|
||||
ContentType::Episode,
|
||||
2700,
|
||||
Some("Breaking Bad".into()),
|
||||
Some(1),
|
||||
Some(1),
|
||||
Some(2008),
|
||||
vec!["Drama".into()],
|
||||
vec!["tv".into()],
|
||||
Some("col-1".into()),
|
||||
Some("TV Shows".into()),
|
||||
Some("tvshows".into()),
|
||||
Some("http://thumb.jpg".into()),
|
||||
"2026-03-19T00:00:00Z".into(),
|
||||
);
|
||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||
assert_eq!(item.season_number(), Some(1));
|
||||
assert_eq!(item.year(), Some(2008));
|
||||
assert_eq!(item.collection_name(), Some("TV Shows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_collection_new_and_getters() {
|
||||
let col = LibraryCollection::new("col-1", "Movies");
|
||||
assert_eq!(col.id(), "col-1");
|
||||
assert_eq!(col.name(), "Movies");
|
||||
assert!(col.collection_type().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_collection_from_persistence() {
|
||||
let col = LibraryCollection::from_persistence(
|
||||
"col-2".into(),
|
||||
"TV Shows".into(),
|
||||
Some("tvshows".into()),
|
||||
);
|
||||
assert_eq!(col.collection_type(), Some("tvshows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_result_success() {
|
||||
let r = LibrarySyncResult::new("jellyfin", 150, 1200);
|
||||
assert_eq!(r.provider_id(), "jellyfin");
|
||||
assert_eq!(r.items_found(), 150);
|
||||
assert_eq!(r.duration_ms(), 1200);
|
||||
assert!(r.error().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_result_with_error() {
|
||||
let r = LibrarySyncResult::with_error("jellyfin", 500, "connection refused");
|
||||
assert_eq!(r.items_found(), 0);
|
||||
assert_eq!(r.error(), Some("connection refused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_log_entry_new_defaults() {
|
||||
let entry = LibrarySyncLogEntry::new(1, "jellyfin", "2026-03-19T00:00:00Z");
|
||||
assert_eq!(entry.id(), 1);
|
||||
assert_eq!(entry.status(), "running");
|
||||
assert_eq!(entry.items_found(), 0);
|
||||
assert!(entry.finished_at().is_none());
|
||||
assert!(entry.error_msg().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_summary_getters() {
|
||||
let show = ShowSummary::from_persistence(
|
||||
"Breaking Bad".into(),
|
||||
62,
|
||||
5,
|
||||
Some("http://thumb.jpg".into()),
|
||||
vec!["Drama".into(), "Crime".into()],
|
||||
);
|
||||
assert_eq!(show.series_name(), "Breaking Bad");
|
||||
assert_eq!(show.episode_count(), 62);
|
||||
assert_eq!(show.season_count(), 5);
|
||||
assert_eq!(show.genres().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn season_summary_getters() {
|
||||
let season = SeasonSummary::from_persistence(1, 7, Some("http://s1.jpg".into()));
|
||||
assert_eq!(season.season_number(), 1);
|
||||
assert_eq!(season.episode_count(), 7);
|
||||
assert_eq!(season.thumbnail_url(), Some("http://s1.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn season_summary_new_defaults() {
|
||||
let season = SeasonSummary::new(3, 13);
|
||||
assert_eq!(season.season_number(), 3);
|
||||
assert_eq!(season.episode_count(), 13);
|
||||
assert!(season.thumbnail_url().is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user