//! 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 uuid::Uuid; use crate::value_objects::{ BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, SlotId, }; /// 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 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, created_at: Utc::now(), } } pub fn with_id( id: Uuid, subject: impl Into, email: Email, password_hash: Option, created_at: DateTime, ) -> Self { Self { id, subject: subject.into(), email, password_hash, 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()), 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 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(), created_at: now, updated_at: now, } } } /// The user-designed programming template. /// /// This is the shareable/exportable part of a channel. It contains an ordered /// list of `ProgrammingBlock`s but makes no assumptions about the media source. /// A channel does not need to cover all 24 hours — gaps are valid and render /// as a no-signal state on the client. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ScheduleConfig { pub blocks: Vec, } impl ScheduleConfig { /// Return the block whose time window contains `time`, if any. /// /// Handles blocks that span midnight (e.g. start 23:00, duration 180 min). pub fn find_block_at(&self, time: NaiveTime) -> Option<&ProgrammingBlock> { let secs = time.num_seconds_from_midnight(); self.blocks.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 { // Block crosses midnight: active from `start` to `end % 86400` next day secs >= start || secs < (end % 86_400) } }) } /// Return the start time of the next block that begins strictly after `time`, /// within the same calendar day. pub fn next_block_start_after(&self, time: NaiveTime) -> Option { let secs = time.num_seconds_from_midnight(); self.blocks .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)) } /// The earliest block start time across all blocks (used for next-day rollover). pub fn earliest_block_start(&self) -> Option { self.blocks.iter().map(|b| b.start_time).min() } } /// 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, } 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 }, } } 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 }, } } } /// 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. Manual { items: Vec }, /// The engine selects items from the provider using the given filter and strategy. Algorithmic { filter: MediaFilter, strategy: FillStrategy, }, } // ============================================================================ // 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 48-hour 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, } 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, } } }