//! Domain entities //! //! This module contains pure domain types with no I/O dependencies. //! These represent the core business concepts of the application. pub use crate::value_objects::{Email, UserId}; use chrono::{DateTime, NaiveTime, Timelike, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use uuid::Uuid; use crate::value_objects::{ AccessMode, BlockId, ChannelId, ContentType, FillStrategy, LogoPosition, MediaFilter, MediaItemId, RecyclePolicy, SlotId, Weekday, }; /// A user in the system. /// /// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { pub id: UserId, pub subject: String, pub email: Email, pub password_hash: Option, pub is_admin: bool, pub created_at: DateTime, } impl User { pub fn new(subject: impl Into, email: Email) -> Self { Self { id: Uuid::new_v4(), subject: subject.into(), email, password_hash: None, is_admin: false, created_at: Utc::now(), } } pub fn with_id( id: Uuid, subject: impl Into, email: Email, password_hash: Option, is_admin: bool, created_at: DateTime, ) -> Self { Self { id, subject: subject.into(), email, password_hash, is_admin, created_at, } } pub fn new_local(email: Email, password_hash: impl Into) -> Self { Self { id: Uuid::new_v4(), subject: format!("local|{}", Uuid::new_v4()), email, password_hash: Some(password_hash.into()), is_admin: false, created_at: Utc::now(), } } } // ============================================================================ // Channel // ============================================================================ /// A broadcast channel owned by a user. /// /// Holds the user-designed `ScheduleConfig` (the template) and `RecyclePolicy`. /// The engine consumes these to produce a concrete `GeneratedSchedule`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Channel { pub id: ChannelId, pub owner_id: UserId, pub name: String, pub description: Option, /// IANA timezone string, e.g. `"America/New_York"`. All `start_time` fields /// inside `ScheduleConfig` are interpreted in this timezone. pub timezone: String, pub schedule_config: ScheduleConfig, pub recycle_policy: RecyclePolicy, pub auto_schedule: bool, pub access_mode: AccessMode, pub access_password_hash: Option, pub logo: Option, pub logo_position: LogoPosition, pub logo_opacity: f32, pub webhook_url: Option, pub webhook_poll_interval_secs: u32, pub webhook_body_template: Option, pub webhook_headers: Option, pub created_at: DateTime, pub updated_at: DateTime, } impl Channel { pub fn new( owner_id: UserId, name: impl Into, timezone: impl Into, ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), owner_id, name: name.into(), description: None, timezone: timezone.into(), schedule_config: ScheduleConfig::default(), recycle_policy: RecyclePolicy::default(), auto_schedule: false, access_mode: AccessMode::default(), access_password_hash: None, logo: None, logo_position: LogoPosition::default(), logo_opacity: 1.0, webhook_url: None, webhook_poll_interval_secs: 5, webhook_body_template: None, webhook_headers: None, created_at: now, updated_at: now, } } } /// The user-designed programming template (V2: day-keyed weekly grid). /// /// Each day of the week has its own independent list of `ProgrammingBlock`s. /// A day with an empty vec (or absent key) produces no slots — valid, not an error. /// A channel does not need to cover all 24 hours — gaps render as no-signal. /// /// `deny_unknown_fields` is required so the `#[serde(untagged)]` compat enum /// correctly rejects V1 `{"blocks":[...]}` payloads and falls through to `OldScheduleConfig`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScheduleConfig { pub day_blocks: HashMap>, } /// V1 on-disk shape — kept for transparent migration only. /// Never construct directly; use `ScheduleConfigCompat` for deserialization. /// `deny_unknown_fields` ensures V2 payloads don't accidentally match here. #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct OldScheduleConfig { pub blocks: Vec, } /// Deserializes either V2 (`day_blocks`) or V1 (`blocks`) from the DB. /// V1 is automatically promoted: all blocks are copied to all 7 days. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum ScheduleConfigCompat { V2(ScheduleConfig), V1(OldScheduleConfig), } impl From for ScheduleConfig { fn from(c: ScheduleConfigCompat) -> Self { match c { ScheduleConfigCompat::V2(cfg) => cfg, ScheduleConfigCompat::V1(old) => { let day_blocks = Weekday::all() .into_iter() .map(|d| (d, old.blocks.clone())) .collect(); ScheduleConfig { day_blocks } } } } } impl ScheduleConfig { /// Blocks for a given day. Returns empty slice if the day has no blocks. pub fn blocks_for(&self, day: Weekday) -> &[ProgrammingBlock] { self.day_blocks.get(&day).map(|v| v.as_slice()).unwrap_or(&[]) } /// The block whose window contains `time` on `day`, if any. pub fn find_block_at(&self, day: Weekday, time: NaiveTime) -> Option<&ProgrammingBlock> { let secs = time.num_seconds_from_midnight(); self.blocks_for(day).iter().find(|block| { let start = block.start_time.num_seconds_from_midnight(); let end = start + block.duration_mins * 60; if end <= 86_400 { secs >= start && secs < end } else { secs >= start || secs < (end % 86_400) } }) } /// The start time of the next block beginning strictly after `time` on `day`. pub fn next_block_start_after(&self, day: Weekday, time: NaiveTime) -> Option { let secs = time.num_seconds_from_midnight(); self.blocks_for(day) .iter() .map(|b| b.start_time.num_seconds_from_midnight()) .filter(|&s| s > secs) .min() .and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0)) } /// Earliest block start time across ALL days (used by background scheduler). /// Returns `None` if every day is empty. pub fn earliest_block_start(&self) -> Option { self.day_blocks.values().flatten().map(|b| b.start_time).min() } /// Iterator over all blocks across all days (for block-ID lookups that are day-agnostic). pub fn all_blocks(&self) -> impl Iterator { self.day_blocks.values().flatten() } } /// A single programming rule within a `ScheduleConfig`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgrammingBlock { pub id: BlockId, pub name: String, /// Local time of day (in the channel's timezone) when this block starts. pub start_time: NaiveTime, /// Target duration in minutes. The engine fills this window as closely as /// possible; remaining time at the end becomes dead air (no-signal). pub duration_mins: u32, pub content: BlockContent, /// Sequential only: loop back to episode 1 after the last episode. Default: true. #[serde(default = "default_true")] pub loop_on_finish: bool, /// When true, skip the channel-level recycle policy for this block. /// Useful for dedicated sequential blocks that must always play in order /// regardless of what other blocks aired. #[serde(default)] pub ignore_recycle_policy: bool, /// Who can watch the stream during this block. Gates only /stream, not /now. #[serde(default)] pub access_mode: AccessMode, /// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected). #[serde(default, skip_serializing_if = "Option::is_none")] pub access_password_hash: Option, } fn default_true() -> bool { true } impl ProgrammingBlock { pub fn new_algorithmic( name: impl Into, start_time: NaiveTime, duration_mins: u32, filter: MediaFilter, strategy: FillStrategy, ) -> Self { Self { id: Uuid::new_v4(), name: name.into(), start_time, duration_mins, content: BlockContent::Algorithmic { filter, strategy, provider_id: String::new() }, loop_on_finish: true, ignore_recycle_policy: false, access_mode: AccessMode::default(), access_password_hash: None, } } pub fn new_manual( name: impl Into, start_time: NaiveTime, duration_mins: u32, items: Vec, ) -> Self { Self { id: Uuid::new_v4(), name: name.into(), start_time, duration_mins, content: BlockContent::Manual { items, provider_id: String::new() }, loop_on_finish: true, ignore_recycle_policy: false, access_mode: AccessMode::default(), access_password_hash: None, } } } /// How the content of a `ProgrammingBlock` is determined. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BlockContent { /// The user hand-picked specific items in a specific order. /// Item IDs are prefixed with the provider key (e.g. `"jellyfin::abc123"`) /// so the registry can route each fetch to the correct provider. Manual { items: Vec, /// Registry key of the provider these items come from. Empty string = primary. #[serde(default)] provider_id: String, }, /// The engine selects items from the provider using the given filter and strategy. Algorithmic { filter: MediaFilter, strategy: FillStrategy, /// Registry key of the provider to query. Empty string = primary. #[serde(default)] provider_id: String, }, } // ============================================================================ // Media / Schedule resolution types // ============================================================================ /// A snapshot of a media item's metadata at schedule-generation time. /// /// Stream URLs are intentionally absent — they are fetched on-demand from the /// provider at tune-in time so they stay fresh and provider-agnostic. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaItem { pub id: MediaItemId, pub title: String, pub content_type: ContentType, pub duration_secs: u32, pub description: Option, pub genres: Vec, pub year: Option, pub tags: Vec, /// For episodes: the parent TV show name. pub series_name: Option, /// For episodes: season number (1-based). pub season_number: Option, /// For episodes: episode number within the season (1-based). pub episode_number: Option, } /// A fully resolved 7-day broadcast program for one channel. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeneratedSchedule { pub id: Uuid, pub channel_id: ChannelId, pub valid_from: DateTime, pub valid_until: DateTime, /// Monotonically increasing counter per channel, used by `RecyclePolicy`. pub generation: u32, /// Resolved slots, sorted ascending by `start_at`. pub slots: Vec, } impl GeneratedSchedule { pub fn is_active_at(&self, time: DateTime) -> bool { time >= self.valid_from && time < self.valid_until } } /// A single resolved broadcast moment within a `GeneratedSchedule`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledSlot { pub id: SlotId, pub start_at: DateTime, pub end_at: DateTime, /// Metadata snapshot captured at schedule-generation time. pub item: MediaItem, /// Which `ProgrammingBlock` rule produced this slot. pub source_block_id: BlockId, } /// What is currently broadcasting on a channel — derived from `GeneratedSchedule` /// and the wall clock. Never stored. `None` means no block is scheduled right now /// (dead air / no-signal). #[derive(Debug, Clone)] pub struct CurrentBroadcast { pub slot: ScheduledSlot, /// Seconds elapsed since the start of the current item. pub offset_secs: u32, } /// Records that an item was aired on a channel. Persisted to drive `RecyclePolicy`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlaybackRecord { pub id: Uuid, pub channel_id: ChannelId, pub item_id: MediaItemId, pub played_at: DateTime, /// The generation of the schedule that scheduled this play. pub generation: u32, } /// A point-in-time snapshot of a channel's `ScheduleConfig`. /// Auto-created on every config save; users can pin with a label. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelConfigSnapshot { pub id: Uuid, pub channel_id: ChannelId, pub config: ScheduleConfig, pub version_num: i64, pub label: Option, pub created_at: DateTime, } impl PlaybackRecord { pub fn new(channel_id: ChannelId, item_id: MediaItemId, generation: u32) -> Self { Self { id: Uuid::new_v4(), channel_id, item_id, played_at: Utc::now(), generation, } } } #[cfg(test)] mod schedule_config_tests { use super::*; use chrono::NaiveTime; fn t(h: u32, m: u32) -> NaiveTime { NaiveTime::from_hms_opt(h, m, 0).unwrap() } fn make_block(start: NaiveTime, duration_mins: u32) -> ProgrammingBlock { ProgrammingBlock::new_algorithmic( "test", start, duration_mins, Default::default(), FillStrategy::Random, ) } fn cfg_with_monday_block(start: NaiveTime, dur: u32) -> ScheduleConfig { let mut cfg = ScheduleConfig::default(); cfg.day_blocks.insert(Weekday::Monday, vec![make_block(start, dur)]); cfg } #[test] fn find_block_at_finds_active_block() { let cfg = cfg_with_monday_block(t(8, 0), 60); assert!(cfg.find_block_at(Weekday::Monday, t(8, 30)).is_some()); assert!(cfg.find_block_at(Weekday::Monday, t(9, 0)).is_none()); } #[test] fn find_block_at_wrong_day_returns_none() { let cfg = cfg_with_monday_block(t(8, 0), 60); assert!(cfg.find_block_at(Weekday::Tuesday, t(8, 30)).is_none()); } #[test] fn v1_compat_copies_blocks_to_all_days() { let json = r#"{"blocks": []}"#; let compat: ScheduleConfigCompat = serde_json::from_str(json).unwrap(); let cfg: ScheduleConfig = compat.into(); assert_eq!(cfg.day_blocks.len(), 7); } #[test] fn v2_payload_with_unknown_blocks_key_fails() { let json = r#"{"blocks": [], "day_blocks": {}}"#; let result: Result = serde_json::from_str(json); match result { Ok(ScheduleConfigCompat::V2(cfg)) => { let _ = cfg; } Ok(ScheduleConfigCompat::V1(_)) => { /* acceptable */ } Err(_) => { /* acceptable — ambiguous payload rejected */ } } } #[test] fn earliest_block_start_across_days() { let mut cfg = ScheduleConfig::default(); cfg.day_blocks.insert(Weekday::Monday, vec![make_block(t(10, 0), 60)]); cfg.day_blocks.insert(Weekday::Friday, vec![make_block(t(7, 0), 60)]); assert_eq!(cfg.earliest_block_start(), Some(t(7, 0))); } #[test] fn empty_config_earliest_block_start_is_none() { let cfg = ScheduleConfig::default(); assert!(cfg.earliest_block_start().is_none()); } }