domain models: User, Channel, ScheduleConfig, MediaItem, PlaybackRecord
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use errors::{DomainError, DomainResult};
|
||||
pub use models::*;
|
||||
pub use value_objects::*;
|
||||
|
||||
593
crates/domain/src/models/channel.rs
Normal file
593
crates/domain/src/models/channel.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
use chrono::{DateTime, NaiveTime, Timelike, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::value_objects::{
|
||||
AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId,
|
||||
RecyclePolicy, UserId, Weekday,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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 {
|
||||
id: ChannelId,
|
||||
owner_id: UserId,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
/// IANA timezone string, e.g. `"America/New_York"`. All `start_time` fields
|
||||
/// inside `ScheduleConfig` are interpreted in this timezone.
|
||||
timezone: String,
|
||||
schedule_config: ScheduleConfig,
|
||||
recycle_policy: RecyclePolicy,
|
||||
auto_schedule: bool,
|
||||
access_mode: AccessMode,
|
||||
access_password_hash: Option<String>,
|
||||
logo: Option<String>,
|
||||
logo_position: LogoPosition,
|
||||
logo_opacity: f32,
|
||||
webhook_url: Option<String>,
|
||||
webhook_poll_interval_secs: u32,
|
||||
webhook_body_template: Option<String>,
|
||||
webhook_headers: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
/// Create a new channel with sensible defaults.
|
||||
pub fn new(
|
||||
owner_id: UserId,
|
||||
name: impl Into<String>,
|
||||
timezone: impl Into<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ChannelId::generate(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence — no validation, accepts all fields.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_persistence(
|
||||
id: ChannelId,
|
||||
owner_id: UserId,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
timezone: String,
|
||||
schedule_config: ScheduleConfig,
|
||||
recycle_policy: RecyclePolicy,
|
||||
auto_schedule: bool,
|
||||
access_mode: AccessMode,
|
||||
access_password_hash: Option<String>,
|
||||
logo: Option<String>,
|
||||
logo_position: LogoPosition,
|
||||
logo_opacity: f32,
|
||||
webhook_url: Option<String>,
|
||||
webhook_poll_interval_secs: u32,
|
||||
webhook_body_template: Option<String>,
|
||||
webhook_headers: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
owner_id,
|
||||
name,
|
||||
description,
|
||||
timezone,
|
||||
schedule_config,
|
||||
recycle_policy,
|
||||
auto_schedule,
|
||||
access_mode,
|
||||
access_password_hash,
|
||||
logo,
|
||||
logo_position,
|
||||
logo_opacity,
|
||||
webhook_url,
|
||||
webhook_poll_interval_secs,
|
||||
webhook_body_template,
|
||||
webhook_headers,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> ChannelId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn owner_id(&self) -> UserId {
|
||||
self.owner_id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
|
||||
pub fn timezone(&self) -> &str {
|
||||
&self.timezone
|
||||
}
|
||||
|
||||
pub fn schedule_config(&self) -> &ScheduleConfig {
|
||||
&self.schedule_config
|
||||
}
|
||||
|
||||
pub fn recycle_policy(&self) -> &RecyclePolicy {
|
||||
&self.recycle_policy
|
||||
}
|
||||
|
||||
pub fn auto_schedule(&self) -> bool {
|
||||
self.auto_schedule
|
||||
}
|
||||
|
||||
pub fn access_mode(&self) -> &AccessMode {
|
||||
&self.access_mode
|
||||
}
|
||||
|
||||
pub fn access_password_hash(&self) -> Option<&str> {
|
||||
self.access_password_hash.as_deref()
|
||||
}
|
||||
|
||||
pub fn logo(&self) -> Option<&str> {
|
||||
self.logo.as_deref()
|
||||
}
|
||||
|
||||
pub fn logo_position(&self) -> &LogoPosition {
|
||||
&self.logo_position
|
||||
}
|
||||
|
||||
pub fn logo_opacity(&self) -> f32 {
|
||||
self.logo_opacity
|
||||
}
|
||||
|
||||
pub fn webhook_url(&self) -> Option<&str> {
|
||||
self.webhook_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn webhook_poll_interval_secs(&self) -> u32 {
|
||||
self.webhook_poll_interval_secs
|
||||
}
|
||||
|
||||
pub fn webhook_body_template(&self) -> Option<&str> {
|
||||
self.webhook_body_template.as_deref()
|
||||
}
|
||||
|
||||
pub fn webhook_headers(&self) -> Option<&str> {
|
||||
self.webhook_headers.as_deref()
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ScheduleConfig
|
||||
// ============================================================================
|
||||
|
||||
/// 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 {
|
||||
day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>>,
|
||||
}
|
||||
|
||||
impl ScheduleConfig {
|
||||
/// Create a new empty schedule config.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create from a pre-built day_blocks map (e.g. from compat migration).
|
||||
pub fn from_day_blocks(day_blocks: HashMap<Weekday, Vec<ProgrammingBlock>>) -> Self {
|
||||
Self { day_blocks }
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Get the underlying day_blocks map.
|
||||
pub fn day_blocks(&self) -> &HashMap<Weekday, Vec<ProgrammingBlock>> {
|
||||
&self.day_blocks
|
||||
}
|
||||
|
||||
/// Insert blocks for a specific day (used in tests and config building).
|
||||
pub fn insert_day(&mut self, day: Weekday, blocks: Vec<ProgrammingBlock>) {
|
||||
self.day_blocks.insert(day, blocks);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OldScheduleConfig + ScheduleConfigCompat
|
||||
// ============================================================================
|
||||
|
||||
/// 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 {
|
||||
blocks: Vec<ProgrammingBlock>,
|
||||
}
|
||||
|
||||
impl OldScheduleConfig {
|
||||
pub fn blocks(&self) -> &[ProgrammingBlock] {
|
||||
&self.blocks
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ProgrammingBlock
|
||||
// ============================================================================
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A single programming rule within a `ScheduleConfig`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProgrammingBlock {
|
||||
id: BlockId,
|
||||
name: String,
|
||||
/// Local time of day (in the channel's timezone) when this block starts.
|
||||
start_time: NaiveTime,
|
||||
/// Target duration in minutes.
|
||||
duration_mins: u32,
|
||||
content: BlockContent,
|
||||
|
||||
/// Sequential only: loop back to episode 1 after the last episode. Default: true.
|
||||
#[serde(default = "default_true")]
|
||||
loop_on_finish: bool,
|
||||
|
||||
/// When true, skip the channel-level recycle policy for this block.
|
||||
#[serde(default)]
|
||||
ignore_recycle_policy: bool,
|
||||
|
||||
/// Who can watch the stream during this block.
|
||||
#[serde(default)]
|
||||
access_mode: AccessMode,
|
||||
|
||||
/// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
access_password_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl ProgrammingBlock {
|
||||
/// Create a new algorithmic programming block.
|
||||
pub fn new_algorithmic(
|
||||
name: impl Into<String>,
|
||||
start_time: NaiveTime,
|
||||
duration_mins: u32,
|
||||
filter: MediaFilter,
|
||||
strategy: FillStrategy,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: BlockId::generate(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new manual programming block with hand-picked items.
|
||||
pub fn new_manual(
|
||||
name: impl Into<String>,
|
||||
start_time: NaiveTime,
|
||||
duration_mins: u32,
|
||||
items: Vec<MediaItemId>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: BlockId::generate(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> BlockId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn start_time(&self) -> NaiveTime {
|
||||
self.start_time
|
||||
}
|
||||
|
||||
pub fn duration_mins(&self) -> u32 {
|
||||
self.duration_mins
|
||||
}
|
||||
|
||||
pub fn content(&self) -> &BlockContent {
|
||||
&self.content
|
||||
}
|
||||
|
||||
pub fn loop_on_finish(&self) -> bool {
|
||||
self.loop_on_finish
|
||||
}
|
||||
|
||||
pub fn ignore_recycle_policy(&self) -> bool {
|
||||
self.ignore_recycle_policy
|
||||
}
|
||||
|
||||
pub fn access_mode(&self) -> &AccessMode {
|
||||
&self.access_mode
|
||||
}
|
||||
|
||||
pub fn access_password_hash(&self) -> Option<&str> {
|
||||
self.access_password_hash.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BlockContent
|
||||
// ============================================================================
|
||||
|
||||
/// 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<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,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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.insert_day(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.insert_day(Weekday::Monday, vec![make_block(t(10, 0), 60)]);
|
||||
cfg.insert_day(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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_new_defaults() {
|
||||
let owner = UserId::generate();
|
||||
let ch = Channel::new(owner, "Test Channel", "America/New_York");
|
||||
assert_eq!(ch.name(), "Test Channel");
|
||||
assert_eq!(ch.timezone(), "America/New_York");
|
||||
assert_eq!(ch.owner_id(), owner);
|
||||
assert!(!ch.auto_schedule());
|
||||
assert!(ch.description().is_none());
|
||||
assert_eq!(ch.logo_opacity(), 1.0);
|
||||
assert_eq!(ch.webhook_poll_interval_secs(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programming_block_getters() {
|
||||
let block = ProgrammingBlock::new_algorithmic(
|
||||
"Morning",
|
||||
t(8, 0),
|
||||
120,
|
||||
Default::default(),
|
||||
FillStrategy::BestFit,
|
||||
);
|
||||
assert_eq!(block.name(), "Morning");
|
||||
assert_eq!(block.start_time(), t(8, 0));
|
||||
assert_eq!(block.duration_mins(), 120);
|
||||
assert!(block.loop_on_finish());
|
||||
assert!(!block.ignore_recycle_policy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_block_creation() {
|
||||
let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")];
|
||||
let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items);
|
||||
match block.content() {
|
||||
BlockContent::Manual { items, provider_id } => {
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(provider_id.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Manual content"),
|
||||
}
|
||||
}
|
||||
}
|
||||
87
crates/domain/src/models/collections.rs
Normal file
87
crates/domain/src/models/collections.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
/// Pagination parameters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PageParams {
|
||||
offset: u32,
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
impl PageParams {
|
||||
pub fn new(offset: u32, limit: u32) -> Self {
|
||||
Self { offset, limit }
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> u32 {
|
||||
self.offset
|
||||
}
|
||||
|
||||
pub fn limit(&self) -> u32 {
|
||||
self.limit
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PageParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paginated response wrapper.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Paginated<T> {
|
||||
items: Vec<T>,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl<T> Paginated<T> {
|
||||
pub fn new(items: Vec<T>, total: u64) -> Self {
|
||||
Self { items, total }
|
||||
}
|
||||
|
||||
pub fn items(&self) -> &[T] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
pub fn into_items(self) -> Vec<T> {
|
||||
self.items
|
||||
}
|
||||
|
||||
pub fn total(&self) -> u64 {
|
||||
self.total
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_params_default() {
|
||||
let p = PageParams::default();
|
||||
assert_eq!(p.offset(), 0);
|
||||
assert_eq!(p.limit(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_params_custom() {
|
||||
let p = PageParams::new(10, 25);
|
||||
assert_eq!(p.offset(), 10);
|
||||
assert_eq!(p.limit(), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginated_getters() {
|
||||
let page = Paginated::new(vec![1, 2, 3], 100);
|
||||
assert_eq!(page.items(), &[1, 2, 3]);
|
||||
assert_eq!(page.total(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginated_into_items() {
|
||||
let page = Paginated::new(vec!["a", "b"], 2);
|
||||
let items = page.into_items();
|
||||
assert_eq!(items, vec!["a", "b"]);
|
||||
}
|
||||
}
|
||||
280
crates/domain/src/models/media.rs
Normal file
280
crates/domain/src/models/media.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::{ChannelId, ContentType, MediaItemId};
|
||||
|
||||
/// 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 {
|
||||
id: MediaItemId,
|
||||
title: String,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
description: Option<String>,
|
||||
genres: Vec<String>,
|
||||
year: Option<u16>,
|
||||
tags: Vec<String>,
|
||||
/// For episodes: the parent TV show name.
|
||||
series_name: Option<String>,
|
||||
/// For episodes: season number (1-based).
|
||||
season_number: Option<u32>,
|
||||
/// For episodes: episode number within the season (1-based).
|
||||
episode_number: Option<u32>,
|
||||
/// Provider-served thumbnail image URL, populated if available.
|
||||
thumbnail_url: Option<String>,
|
||||
/// Provider-specific collection this item belongs to.
|
||||
collection_id: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
/// Create a new media item with required fields; optional fields default to None/empty.
|
||||
pub fn new(
|
||||
id: MediaItemId,
|
||||
title: impl Into<String>,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title: title.into(),
|
||||
content_type,
|
||||
duration_secs,
|
||||
description: None,
|
||||
genres: Vec::new(),
|
||||
year: None,
|
||||
tags: Vec::new(),
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence — accepts all fields.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_persistence(
|
||||
id: MediaItemId,
|
||||
title: String,
|
||||
content_type: ContentType,
|
||||
duration_secs: u32,
|
||||
description: Option<String>,
|
||||
genres: Vec<String>,
|
||||
year: Option<u16>,
|
||||
tags: Vec<String>,
|
||||
series_name: Option<String>,
|
||||
season_number: Option<u32>,
|
||||
episode_number: Option<u32>,
|
||||
thumbnail_url: Option<String>,
|
||||
collection_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
content_type,
|
||||
duration_secs,
|
||||
description,
|
||||
genres,
|
||||
year,
|
||||
tags,
|
||||
series_name,
|
||||
season_number,
|
||||
episode_number,
|
||||
thumbnail_url,
|
||||
collection_id,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> &MediaItemId {
|
||||
&self.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 description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
|
||||
pub fn genres(&self) -> &[String] {
|
||||
&self.genres
|
||||
}
|
||||
|
||||
pub fn year(&self) -> Option<u16> {
|
||||
self.year
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> &[String] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
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 thumbnail_url(&self) -> Option<&str> {
|
||||
self.thumbnail_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn collection_id(&self) -> Option<&str> {
|
||||
self.collection_id.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PlaybackRecord
|
||||
// ============================================================================
|
||||
|
||||
/// Records that an item was aired on a channel. Persisted to drive `RecyclePolicy`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaybackRecord {
|
||||
id: Uuid,
|
||||
channel_id: ChannelId,
|
||||
item_id: MediaItemId,
|
||||
played_at: DateTime<Utc>,
|
||||
/// The generation of the schedule that scheduled this play.
|
||||
generation: u32,
|
||||
}
|
||||
|
||||
impl PlaybackRecord {
|
||||
/// Create a new playback record (generates ID and timestamp).
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence — accepts all fields.
|
||||
pub fn from_persistence(
|
||||
id: Uuid,
|
||||
channel_id: ChannelId,
|
||||
item_id: MediaItemId,
|
||||
played_at: DateTime<Utc>,
|
||||
generation: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
channel_id,
|
||||
item_id,
|
||||
played_at,
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn channel_id(&self) -> ChannelId {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
pub fn item_id(&self) -> &MediaItemId {
|
||||
&self.item_id
|
||||
}
|
||||
|
||||
pub fn played_at(&self) -> DateTime<Utc> {
|
||||
self.played_at
|
||||
}
|
||||
|
||||
pub fn generation(&self) -> u32 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn media_item_new_defaults() {
|
||||
let item = MediaItem::new(
|
||||
MediaItemId::new("test::123"),
|
||||
"Test Movie",
|
||||
ContentType::Movie,
|
||||
7200,
|
||||
);
|
||||
assert_eq!(item.title(), "Test Movie");
|
||||
assert_eq!(item.duration_secs(), 7200);
|
||||
assert!(item.description().is_none());
|
||||
assert!(item.genres().is_empty());
|
||||
assert!(item.year().is_none());
|
||||
assert!(item.series_name().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_item_from_persistence_round_trip() {
|
||||
let item = MediaItem::from_persistence(
|
||||
MediaItemId::new("jf::abc"),
|
||||
"Breaking Bad S01E01".into(),
|
||||
ContentType::Episode,
|
||||
2700,
|
||||
Some("Pilot episode".into()),
|
||||
vec!["Drama".into()],
|
||||
Some(2008),
|
||||
vec!["tv".into()],
|
||||
Some("Breaking Bad".into()),
|
||||
Some(1),
|
||||
Some(1),
|
||||
Some("http://thumb.jpg".into()),
|
||||
Some("col-1".into()),
|
||||
);
|
||||
assert_eq!(item.title(), "Breaking Bad S01E01");
|
||||
assert_eq!(item.series_name(), Some("Breaking Bad"));
|
||||
assert_eq!(item.season_number(), Some(1));
|
||||
assert_eq!(item.episode_number(), Some(1));
|
||||
assert_eq!(item.year(), Some(2008));
|
||||
assert_eq!(item.collection_id(), Some("col-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_record_new() {
|
||||
let ch_id = ChannelId::generate();
|
||||
let item_id = MediaItemId::new("test::1");
|
||||
let record = PlaybackRecord::new(ch_id, item_id, 5);
|
||||
assert_eq!(record.channel_id(), ch_id);
|
||||
assert_eq!(record.generation(), 5);
|
||||
assert_eq!(record.item_id().value(), "test::1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_record_from_persistence() {
|
||||
let id = Uuid::new_v4();
|
||||
let ch_id = ChannelId::generate();
|
||||
let item_id = MediaItemId::new("test::2");
|
||||
let now = Utc::now();
|
||||
let record = PlaybackRecord::from_persistence(id, ch_id, item_id, now, 3);
|
||||
assert_eq!(record.id(), id);
|
||||
assert_eq!(record.played_at(), now);
|
||||
assert_eq!(record.generation(), 3);
|
||||
}
|
||||
}
|
||||
12
crates/domain/src/models/mod.rs
Normal file
12
crates/domain/src/models/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
mod channel;
|
||||
mod collections;
|
||||
mod media;
|
||||
mod user;
|
||||
|
||||
pub use channel::{
|
||||
BlockContent, Channel, OldScheduleConfig, ProgrammingBlock, ScheduleConfig,
|
||||
ScheduleConfigCompat,
|
||||
};
|
||||
pub use collections::{PageParams, Paginated};
|
||||
pub use media::{MediaItem, PlaybackRecord};
|
||||
pub use user::User;
|
||||
130
crates/domain/src/models/user.rs
Normal file
130
crates/domain/src/models/user.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::value_objects::{Email, UserId};
|
||||
|
||||
/// 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 {
|
||||
id: UserId,
|
||||
subject: String,
|
||||
email: Email,
|
||||
password_hash: Option<String>,
|
||||
is_admin: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new OIDC user (no local password).
|
||||
pub fn new(subject: impl Into<String>, email: Email) -> Self {
|
||||
Self {
|
||||
id: UserId::generate(),
|
||||
subject: subject.into(),
|
||||
email,
|
||||
password_hash: None,
|
||||
is_admin: false,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new local user with a password hash.
|
||||
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: UserId::generate(),
|
||||
subject: format!("local|{}", uuid::Uuid::new_v4()),
|
||||
email,
|
||||
password_hash: Some(password_hash.into()),
|
||||
is_admin: false,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence — no validation, accepts all fields.
|
||||
pub fn from_persistence(
|
||||
id: UserId,
|
||||
subject: String,
|
||||
email: Email,
|
||||
password_hash: Option<String>,
|
||||
is_admin: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
subject,
|
||||
email,
|
||||
password_hash,
|
||||
is_admin,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Getters --
|
||||
|
||||
pub fn id(&self) -> UserId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> &str {
|
||||
&self.subject
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &Email {
|
||||
&self.email
|
||||
}
|
||||
|
||||
pub fn password_hash(&self) -> Option<&str> {
|
||||
self.password_hash.as_deref()
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.is_admin
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_generates_id_and_timestamp() {
|
||||
let email = Email::new("test@example.com").unwrap();
|
||||
let user = User::new("oidc|123", email);
|
||||
assert!(!user.is_admin());
|
||||
assert!(user.password_hash().is_none());
|
||||
assert_eq!(user.subject(), "oidc|123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_local_sets_password_and_subject() {
|
||||
let email = Email::new("local@example.com").unwrap();
|
||||
let user = User::new_local(email, "hashed_pw");
|
||||
assert!(user.password_hash().is_some());
|
||||
assert!(user.subject().starts_with("local|"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_persistence_round_trip() {
|
||||
let email = Email::new("stored@example.com").unwrap();
|
||||
let id = UserId::generate();
|
||||
let now = Utc::now();
|
||||
let user = User::from_persistence(
|
||||
id,
|
||||
"sub".into(),
|
||||
email.clone(),
|
||||
Some("hash".into()),
|
||||
true,
|
||||
now,
|
||||
);
|
||||
assert_eq!(user.id(), id);
|
||||
assert_eq!(user.subject(), "sub");
|
||||
assert!(user.is_admin());
|
||||
assert_eq!(user.password_hash(), Some("hash"));
|
||||
assert_eq!(user.created_at(), now);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user