497 lines
16 KiB
Rust
497 lines
16 KiB
Rust
//! 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<String>,
|
|
pub is_admin: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl User {
|
|
pub fn new(subject: impl Into<String>, 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<String>,
|
|
email: Email,
|
|
password_hash: Option<String>,
|
|
is_admin: bool,
|
|
created_at: DateTime<Utc>,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
subject: subject.into(),
|
|
email,
|
|
password_hash,
|
|
is_admin,
|
|
created_at,
|
|
}
|
|
}
|
|
|
|
pub fn new_local(email: Email, password_hash: impl Into<String>) -> 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<String>,
|
|
/// 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<String>,
|
|
pub logo: Option<String>,
|
|
pub logo_position: LogoPosition,
|
|
pub logo_opacity: f32,
|
|
pub webhook_url: Option<String>,
|
|
pub webhook_poll_interval_secs: u32,
|
|
pub webhook_body_template: Option<String>,
|
|
pub webhook_headers: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl Channel {
|
|
pub fn new(
|
|
owner_id: UserId,
|
|
name: impl Into<String>,
|
|
timezone: impl Into<String>,
|
|
) -> 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<Weekday, Vec<ProgrammingBlock>>,
|
|
}
|
|
|
|
/// 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<ProgrammingBlock>,
|
|
}
|
|
|
|
/// 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<ScheduleConfigCompat> 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<NaiveTime> {
|
|
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<NaiveTime> {
|
|
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<Item = &ProgrammingBlock> {
|
|
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<String>,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
impl ProgrammingBlock {
|
|
pub fn new_algorithmic(
|
|
name: impl Into<String>,
|
|
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<String>,
|
|
start_time: NaiveTime,
|
|
duration_mins: u32,
|
|
items: Vec<MediaItemId>,
|
|
) -> 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<MediaItemId>,
|
|
/// 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<String>,
|
|
pub genres: Vec<String>,
|
|
pub year: Option<u16>,
|
|
pub tags: Vec<String>,
|
|
/// For episodes: the parent TV show name.
|
|
pub series_name: Option<String>,
|
|
/// For episodes: season number (1-based).
|
|
pub season_number: Option<u32>,
|
|
/// For episodes: episode number within the season (1-based).
|
|
pub episode_number: Option<u32>,
|
|
}
|
|
|
|
/// 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<Utc>,
|
|
pub valid_until: DateTime<Utc>,
|
|
/// Monotonically increasing counter per channel, used by `RecyclePolicy`.
|
|
pub generation: u32,
|
|
/// Resolved slots, sorted ascending by `start_at`.
|
|
pub slots: Vec<ScheduledSlot>,
|
|
}
|
|
|
|
impl GeneratedSchedule {
|
|
pub fn is_active_at(&self, time: DateTime<Utc>) -> 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<Utc>,
|
|
pub end_at: DateTime<Utc>,
|
|
/// 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<Utc>,
|
|
/// 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<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
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<ScheduleConfigCompat, _> = 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());
|
|
}
|
|
}
|