diff --git a/crates/domain/src/models/activity.rs b/crates/domain/src/models/activity.rs new file mode 100644 index 0000000..90f9131 --- /dev/null +++ b/crates/domain/src/models/activity.rs @@ -0,0 +1,117 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +// ============================================================================ +// ActivityEvent +// ============================================================================ + +/// An in-app activity event stored in the database for the admin log view. +/// +/// Captures user and system actions (channel created, schedule generated, etc.) +/// for the admin dashboard's activity feed. +#[derive(Debug, Clone)] +pub struct ActivityEvent { + id: Uuid, + timestamp: DateTime, + event_type: String, + detail: String, + channel_id: Option, +} + +impl ActivityEvent { + /// Create a new activity event (generates ID and timestamp). + pub fn new( + event_type: impl Into, + detail: impl Into, + channel_id: Option, + ) -> Self { + Self { + id: Uuid::new_v4(), + timestamp: Utc::now(), + event_type: event_type.into(), + detail: detail.into(), + channel_id, + } + } + + /// Hydrate from persistence -- no validation, accepts all fields. + pub fn from_persistence( + id: Uuid, + timestamp: DateTime, + event_type: String, + detail: String, + channel_id: Option, + ) -> Self { + Self { + id, + timestamp, + event_type, + detail, + channel_id, + } + } + + // -- Getters -- + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn timestamp(&self) -> DateTime { + self.timestamp + } + + pub fn event_type(&self) -> &str { + &self.event_type + } + + pub fn detail(&self) -> &str { + &self.detail + } + + pub fn channel_id(&self) -> Option { + self.channel_id + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_generates_id_and_timestamp() { + let event = ActivityEvent::new("channel_created", "Channel 'CNN' created", None); + assert_eq!(event.event_type(), "channel_created"); + assert_eq!(event.detail(), "Channel 'CNN' created"); + assert!(event.channel_id().is_none()); + } + + #[test] + fn new_with_channel_id() { + let ch_id = Uuid::new_v4(); + let event = ActivityEvent::new("schedule_generated", "Gen #5", Some(ch_id)); + assert_eq!(event.channel_id(), Some(ch_id)); + } + + #[test] + fn from_persistence_round_trip() { + let id = Uuid::new_v4(); + let ch_id = Uuid::new_v4(); + let now = Utc::now(); + let event = ActivityEvent::from_persistence( + id, + now, + "sync_complete".into(), + "150 items synced".into(), + Some(ch_id), + ); + assert_eq!(event.id(), id); + assert_eq!(event.timestamp(), now); + assert_eq!(event.event_type(), "sync_complete"); + assert_eq!(event.channel_id(), Some(ch_id)); + } +} diff --git a/crates/domain/src/models/config_snapshot.rs b/crates/domain/src/models/config_snapshot.rs new file mode 100644 index 0000000..21e5b26 --- /dev/null +++ b/crates/domain/src/models/config_snapshot.rs @@ -0,0 +1,136 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::value_objects::ChannelId; + +use super::ScheduleConfig; + +// ============================================================================ +// ChannelConfigSnapshot +// ============================================================================ + +/// A point-in-time snapshot of a channel's `ScheduleConfig`. +/// +/// Auto-created on every config save; users can pin with a label. +/// Enables config history browsing and rollback. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelConfigSnapshot { + id: Uuid, + channel_id: ChannelId, + config: ScheduleConfig, + version_num: i64, + label: Option, + created_at: DateTime, +} + +impl ChannelConfigSnapshot { + /// Create a new snapshot (generates ID and timestamp). + pub fn new( + channel_id: ChannelId, + config: ScheduleConfig, + version_num: i64, + ) -> Self { + Self { + id: Uuid::new_v4(), + channel_id, + config, + version_num, + label: None, + created_at: Utc::now(), + } + } + + /// Hydrate from persistence -- no validation, accepts all fields. + pub fn from_persistence( + id: Uuid, + channel_id: ChannelId, + config: ScheduleConfig, + version_num: i64, + label: Option, + created_at: DateTime, + ) -> Self { + Self { + id, + channel_id, + config, + version_num, + label, + created_at, + } + } + + // -- Getters -- + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn channel_id(&self) -> ChannelId { + self.channel_id + } + + pub fn config(&self) -> &ScheduleConfig { + &self.config + } + + pub fn version_num(&self) -> i64 { + self.version_num + } + + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } + + pub fn created_at(&self) -> DateTime { + self.created_at + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_generates_id_and_timestamp() { + let ch_id = ChannelId::generate(); + let snap = ChannelConfigSnapshot::new(ch_id, ScheduleConfig::default(), 1); + assert_eq!(snap.channel_id(), ch_id); + assert_eq!(snap.version_num(), 1); + assert!(snap.label().is_none()); + } + + #[test] + fn from_persistence_round_trip() { + let id = Uuid::new_v4(); + let ch_id = ChannelId::generate(); + let now = Utc::now(); + let snap = ChannelConfigSnapshot::from_persistence( + id, + ch_id, + ScheduleConfig::default(), + 42, + Some("release-v1".into()), + now, + ); + assert_eq!(snap.id(), id); + assert_eq!(snap.version_num(), 42); + assert_eq!(snap.label(), Some("release-v1")); + assert_eq!(snap.created_at(), now); + } + + #[test] + fn config_accessor() { + let snap = ChannelConfigSnapshot::new( + ChannelId::generate(), + ScheduleConfig::default(), + 1, + ); + // Default config has empty day_blocks + assert!(snap.config().day_blocks().is_empty()); + } +} diff --git a/crates/domain/src/models/library.rs b/crates/domain/src/models/library.rs new file mode 100644 index 0000000..977b925 --- /dev/null +++ b/crates/domain/src/models/library.rs @@ -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, + season_number: Option, + episode_number: Option, + year: Option, + genres: Vec, + tags: Vec, + collection_id: Option, + collection_name: Option, + collection_type: Option, + thumbnail_url: Option, + 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, + external_id: impl Into, + title: impl Into, + content_type: ContentType, + duration_secs: u32, + synced_at: impl Into, + ) -> 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, + season_number: Option, + episode_number: Option, + year: Option, + genres: Vec, + tags: Vec, + collection_id: Option, + collection_name: Option, + collection_type: Option, + thumbnail_url: Option, + 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 { + self.season_number + } + + pub fn episode_number(&self) -> Option { + self.episode_number + } + + pub fn year(&self) -> Option { + 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, +} + +impl LibraryCollection { + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + collection_type: None, + } + } + + pub fn from_persistence( + id: String, + name: String, + collection_type: Option, + ) -> 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, +} + +impl LibrarySyncResult { + pub fn new( + provider_id: impl Into, + 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, + duration_ms: u64, + error: impl Into, + ) -> 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, + ) -> 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, + items_found: u32, + status: String, + error_msg: Option, +} + +impl LibrarySyncLogEntry { + pub fn new(id: i64, provider_id: impl Into, started_at: impl Into) -> 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, + items_found: u32, + status: String, + error_msg: Option, + ) -> 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, + genres: Vec, +} + +impl ShowSummary { + pub fn new( + series_name: impl Into, + 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, + genres: Vec, + ) -> 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, +} + +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, + ) -> 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()); + } +} diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index c6e0b83..ac6fd0d 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -1,12 +1,25 @@ +mod activity; mod channel; mod collections; +mod config_snapshot; +mod library; mod media; +mod provider_config; +mod schedule; mod user; +pub use activity::ActivityEvent; pub use channel::{ BlockContent, Channel, OldScheduleConfig, ProgrammingBlock, ScheduleConfig, ScheduleConfigCompat, }; pub use collections::{PageParams, Paginated}; +pub use config_snapshot::ChannelConfigSnapshot; +pub use library::{ + LibraryCollection, LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, SeasonSummary, + ShowSummary, +}; pub use media::{MediaItem, PlaybackRecord}; +pub use provider_config::ProviderConfigRow; +pub use schedule::{CurrentBroadcast, GeneratedSchedule, ScheduledSlot}; pub use user::User; diff --git a/crates/domain/src/models/provider_config.rs b/crates/domain/src/models/provider_config.rs new file mode 100644 index 0000000..b3e854a --- /dev/null +++ b/crates/domain/src/models/provider_config.rs @@ -0,0 +1,112 @@ +// ============================================================================ +// ProviderConfigRow +// ============================================================================ + +/// A row from the provider_configs table. +/// +/// Stores the JSON configuration blob for a registered media provider +/// (e.g. Jellyfin URL + API key). The `provider_type` discriminates which +/// adapter to instantiate at runtime. +#[derive(Debug, Clone)] +pub struct ProviderConfigRow { + id: String, + provider_type: String, + config_json: String, + enabled: bool, + updated_at: String, +} + +impl ProviderConfigRow { + /// Create a new provider config row. + pub fn new( + id: impl Into, + provider_type: impl Into, + config_json: impl Into, + ) -> Self { + Self { + id: id.into(), + provider_type: provider_type.into(), + config_json: config_json.into(), + enabled: true, + updated_at: String::new(), + } + } + + /// Hydrate from persistence -- no validation, accepts all fields. + pub fn from_persistence( + id: String, + provider_type: String, + config_json: String, + enabled: bool, + updated_at: String, + ) -> Self { + Self { + id, + provider_type, + config_json, + enabled, + updated_at, + } + } + + // -- Getters -- + + pub fn id(&self) -> &str { + &self.id + } + + pub fn provider_type(&self) -> &str { + &self.provider_type + } + + pub fn config_json(&self) -> &str { + &self.config_json + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn updated_at(&self) -> &str { + &self.updated_at + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_defaults_enabled() { + let row = ProviderConfigRow::new("jf-1", "jellyfin", r#"{"url":"http://localhost:8096"}"#); + assert_eq!(row.id(), "jf-1"); + assert_eq!(row.provider_type(), "jellyfin"); + assert!(row.enabled()); + assert!(row.updated_at().is_empty()); + } + + #[test] + fn from_persistence_round_trip() { + let row = ProviderConfigRow::from_persistence( + "local-1".into(), + "local_files".into(), + r#"{"path":"/media"}"#.into(), + false, + "2026-03-19T00:00:00Z".into(), + ); + assert_eq!(row.id(), "local-1"); + assert_eq!(row.provider_type(), "local_files"); + assert!(!row.enabled()); + assert_eq!(row.updated_at(), "2026-03-19T00:00:00Z"); + } + + #[test] + fn config_json_accessor() { + let row = ProviderConfigRow::new("test", "jellyfin", r#"{"api_key":"secret"}"#); + assert!(row.config_json().contains("api_key")); + } +} diff --git a/crates/domain/src/models/schedule.rs b/crates/domain/src/models/schedule.rs new file mode 100644 index 0000000..259f750 --- /dev/null +++ b/crates/domain/src/models/schedule.rs @@ -0,0 +1,316 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::value_objects::{BlockId, ChannelId, ScheduleId, SlotId}; + +use super::MediaItem; + +// ============================================================================ +// GeneratedSchedule +// ============================================================================ + +/// A fully resolved broadcast program for one channel. +/// +/// Contains concrete time slots derived from the channel's `ScheduleConfig`. +/// The `generation` counter is monotonically increasing per channel and drives +/// `RecyclePolicy` cooldown calculations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneratedSchedule { + id: ScheduleId, + channel_id: ChannelId, + valid_from: DateTime, + valid_until: DateTime, + /// Monotonically increasing counter per channel, used by `RecyclePolicy`. + generation: u32, + /// Resolved slots, sorted ascending by `start_at`. + slots: Vec, +} + +impl GeneratedSchedule { + /// Create a new generated schedule. + pub fn new( + channel_id: ChannelId, + valid_from: DateTime, + valid_until: DateTime, + generation: u32, + slots: Vec, + ) -> Self { + Self { + id: ScheduleId::generate(), + channel_id, + valid_from, + valid_until, + generation, + slots, + } + } + + /// Hydrate from persistence -- no validation, accepts all fields. + pub fn from_persistence( + id: ScheduleId, + channel_id: ChannelId, + valid_from: DateTime, + valid_until: DateTime, + generation: u32, + slots: Vec, + ) -> Self { + Self { + id, + channel_id, + valid_from, + valid_until, + generation, + slots, + } + } + + /// Whether `time` falls within this schedule's validity window `[valid_from, valid_until)`. + pub fn is_active_at(&self, time: DateTime) -> bool { + time >= self.valid_from && time < self.valid_until + } + + // -- Getters -- + + pub fn id(&self) -> ScheduleId { + self.id + } + + pub fn channel_id(&self) -> ChannelId { + self.channel_id + } + + pub fn valid_from(&self) -> DateTime { + self.valid_from + } + + pub fn valid_until(&self) -> DateTime { + self.valid_until + } + + pub fn generation(&self) -> u32 { + self.generation + } + + pub fn slots(&self) -> &[ScheduledSlot] { + &self.slots + } + + pub fn into_slots(self) -> Vec { + self.slots + } +} + +// ============================================================================ +// ScheduledSlot +// ============================================================================ + +/// A single resolved broadcast moment within a `GeneratedSchedule`. +/// +/// Contains a snapshot of the media item's metadata captured at schedule-generation +/// time. Stream URLs are fetched on-demand at tune-in, not stored here. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledSlot { + id: SlotId, + start_at: DateTime, + end_at: DateTime, + /// Metadata snapshot captured at schedule-generation time. + item: MediaItem, + /// Which `ProgrammingBlock` rule produced this slot. + source_block_id: BlockId, +} + +impl ScheduledSlot { + /// Create a new scheduled slot. + pub fn new( + start_at: DateTime, + end_at: DateTime, + item: MediaItem, + source_block_id: BlockId, + ) -> Self { + Self { + id: SlotId::generate(), + start_at, + end_at, + item, + source_block_id, + } + } + + /// Hydrate from persistence -- no validation, accepts all fields. + pub fn from_persistence( + id: SlotId, + start_at: DateTime, + end_at: DateTime, + item: MediaItem, + source_block_id: BlockId, + ) -> Self { + Self { + id, + start_at, + end_at, + item, + source_block_id, + } + } + + // -- Getters -- + + pub fn id(&self) -> SlotId { + self.id + } + + pub fn start_at(&self) -> DateTime { + self.start_at + } + + pub fn end_at(&self) -> DateTime { + self.end_at + } + + pub fn item(&self) -> &MediaItem { + &self.item + } + + pub fn source_block_id(&self) -> BlockId { + self.source_block_id + } +} + +// ============================================================================ +// CurrentBroadcast +// ============================================================================ + +/// What is currently broadcasting on a channel -- derived from `GeneratedSchedule` +/// and the wall clock. Never stored, never serialized. `None` means no block is +/// scheduled right now (dead air / no-signal). +#[derive(Debug, Clone)] +pub struct CurrentBroadcast { + slot: ScheduledSlot, + /// Seconds elapsed since the start of the current item. + offset_secs: u32, +} + +impl CurrentBroadcast { + /// Create a new current broadcast snapshot. + pub fn new(slot: ScheduledSlot, offset_secs: u32) -> Self { + Self { slot, offset_secs } + } + + // -- Getters -- + + pub fn slot(&self) -> &ScheduledSlot { + &self.slot + } + + pub fn into_slot(self) -> ScheduledSlot { + self.slot + } + + pub fn offset_secs(&self) -> u32 { + self.offset_secs + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::value_objects::{ContentType, MediaItemId}; + use chrono::Duration; + + fn make_item() -> MediaItem { + MediaItem::new( + MediaItemId::new("test::1"), + "Test Movie", + ContentType::Movie, + 7200, + ) + } + + fn make_slot(start: DateTime, dur_secs: i64) -> ScheduledSlot { + ScheduledSlot::new( + start, + start + Duration::seconds(dur_secs), + make_item(), + BlockId::generate(), + ) + } + + #[test] + fn generated_schedule_is_active_at() { + let now = Utc::now(); + let from = now - Duration::hours(1); + let until = now + Duration::hours(1); + let sched = GeneratedSchedule::new( + ChannelId::generate(), + from, + until, + 1, + vec![], + ); + assert!(sched.is_active_at(now)); + assert!(sched.is_active_at(from)); + assert!(!sched.is_active_at(until)); // exclusive upper bound + assert!(!sched.is_active_at(from - Duration::seconds(1))); + } + + #[test] + fn generated_schedule_getters() { + let ch_id = ChannelId::generate(); + let now = Utc::now(); + let slot = make_slot(now, 3600); + let sched = GeneratedSchedule::new(ch_id, now, now + Duration::hours(24), 5, vec![slot]); + assert_eq!(sched.channel_id(), ch_id); + assert_eq!(sched.generation(), 5); + assert_eq!(sched.slots().len(), 1); + } + + #[test] + fn scheduled_slot_getters() { + let now = Utc::now(); + let block_id = BlockId::generate(); + let slot = ScheduledSlot::new(now, now + Duration::hours(2), make_item(), block_id); + assert_eq!(slot.start_at(), now); + assert_eq!(slot.source_block_id(), block_id); + assert_eq!(slot.item().title(), "Test Movie"); + } + + #[test] + fn current_broadcast_getters() { + let now = Utc::now(); + let slot = make_slot(now, 3600); + let bc = CurrentBroadcast::new(slot, 120); + assert_eq!(bc.offset_secs(), 120); + assert_eq!(bc.slot().item().title(), "Test Movie"); + } + + #[test] + fn from_persistence_round_trip() { + let id = ScheduleId::generate(); + let ch_id = ChannelId::generate(); + let now = Utc::now(); + let until = now + Duration::hours(48); + let sched = GeneratedSchedule::from_persistence(id, ch_id, now, until, 3, vec![]); + assert_eq!(sched.id(), id); + assert_eq!(sched.valid_from(), now); + assert_eq!(sched.valid_until(), until); + } + + #[test] + fn into_slots_consumes() { + let now = Utc::now(); + let slot = make_slot(now, 3600); + let sched = GeneratedSchedule::new( + ChannelId::generate(), + now, + now + Duration::hours(24), + 1, + vec![slot], + ); + let slots = sched.into_slots(); + assert_eq!(slots.len(), 1); + } +}