diff --git a/crates/domain/clippy.toml b/crates/domain/clippy.toml new file mode 100644 index 0000000..0d4e02f --- /dev/null +++ b/crates/domain/clippy.toml @@ -0,0 +1 @@ +too-many-arguments-threshold = 20 diff --git a/crates/domain/src/events/mod.rs b/crates/domain/src/events/mod.rs index 6224a24..ca38e07 100644 --- a/crates/domain/src/events/mod.rs +++ b/crates/domain/src/events/mod.rs @@ -1,125 +1,23 @@ -//! Domain events emitted when important state transitions occur. -//! -//! Events carry only IDs — consumers fetch the data they need from -//! repositories. This keeps `Clone` cheap and avoids coupling events -//! to model internals. - use crate::value_objects::{ChannelId, ScheduleId, SlotId}; -/// Events emitted by domain aggregates on state transitions. -/// -/// Must be `Clone + Send + 'static` so publishers can fan out cheaply. -/// Marked `#[non_exhaustive]` — downstream consumers must handle unknown -/// variants gracefully (wildcard arm). #[derive(Clone, Debug)] #[non_exhaustive] pub enum DomainEvent { - /// The active broadcast switched to a new slot. BroadcastTransition { channel_id: ChannelId, slot_id: SlotId, }, - /// No content is currently airing on the channel. NoSignal { channel_id: ChannelId }, - /// A new schedule was generated for a channel. ScheduleGenerated { channel_id: ChannelId, schedule_id: ScheduleId, }, - /// A channel was created. ChannelCreated { channel_id: ChannelId }, - /// A channel configuration was updated. ChannelUpdated { channel_id: ChannelId }, - /// A channel was deleted. ChannelDeleted { channel_id: ChannelId }, - /// A new user was registered. UserRegistered { user_id: crate::value_objects::UserId }, } #[cfg(test)] -mod tests { - use super::*; - use uuid::Uuid; - - #[test] - fn broadcast_transition_carries_ids() { - let ch = ChannelId::from(Uuid::new_v4()); - let sl = SlotId::from(Uuid::new_v4()); - let event = DomainEvent::BroadcastTransition { - channel_id: ch, - slot_id: sl, - }; - match event { - DomainEvent::BroadcastTransition { - channel_id, - slot_id, - } => { - assert_eq!(channel_id, ch); - assert_eq!(slot_id, sl); - } - _ => panic!("wrong variant"), - } - } - - #[test] - fn no_signal_carries_channel_id() { - let ch = ChannelId::from(Uuid::new_v4()); - let event = DomainEvent::NoSignal { channel_id: ch }; - match event { - DomainEvent::NoSignal { channel_id } => assert_eq!(channel_id, ch), - _ => panic!("wrong variant"), - } - } - - #[test] - fn schedule_generated_carries_ids() { - let ch = ChannelId::from(Uuid::new_v4()); - let sc = ScheduleId::from(Uuid::new_v4()); - let event = DomainEvent::ScheduleGenerated { - channel_id: ch, - schedule_id: sc, - }; - match event { - DomainEvent::ScheduleGenerated { - channel_id, - schedule_id, - } => { - assert_eq!(channel_id, ch); - assert_eq!(schedule_id, sc); - } - _ => panic!("wrong variant"), - } - } - - #[test] - fn channel_lifecycle_events() { - let ch = ChannelId::from(Uuid::new_v4()); - - let created = DomainEvent::ChannelCreated { channel_id: ch }; - let updated = DomainEvent::ChannelUpdated { channel_id: ch }; - let deleted = DomainEvent::ChannelDeleted { channel_id: ch }; - - // All three carry the same channel_id. - for event in [created, updated, deleted] { - match event { - DomainEvent::ChannelCreated { channel_id } - | DomainEvent::ChannelUpdated { channel_id } - | DomainEvent::ChannelDeleted { channel_id } => { - assert_eq!(channel_id, ch); - } - _ => panic!("wrong variant"), - } - } - } - - #[test] - fn event_is_clone() { - let ch = ChannelId::from(Uuid::new_v4()); - let event = DomainEvent::NoSignal { channel_id: ch }; - let cloned = event.clone(); - match cloned { - DomainEvent::NoSignal { channel_id } => assert_eq!(channel_id, ch), - _ => panic!("wrong variant"), - } - } -} +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/domain/src/events/tests/mod.rs b/crates/domain/src/events/tests/mod.rs new file mode 100644 index 0000000..20b8b87 --- /dev/null +++ b/crates/domain/src/events/tests/mod.rs @@ -0,0 +1,83 @@ +use super::*; +use uuid::Uuid; + +#[test] +fn broadcast_transition_carries_ids() { + let ch = ChannelId::from(Uuid::new_v4()); + let sl = SlotId::from(Uuid::new_v4()); + let event = DomainEvent::BroadcastTransition { + channel_id: ch, + slot_id: sl, + }; + match event { + DomainEvent::BroadcastTransition { + channel_id, + slot_id, + } => { + assert_eq!(channel_id, ch); + assert_eq!(slot_id, sl); + } + _ => panic!("wrong variant"), + } +} + +#[test] +fn no_signal_carries_channel_id() { + let ch = ChannelId::from(Uuid::new_v4()); + let event = DomainEvent::NoSignal { channel_id: ch }; + match event { + DomainEvent::NoSignal { channel_id } => assert_eq!(channel_id, ch), + _ => panic!("wrong variant"), + } +} + +#[test] +fn schedule_generated_carries_ids() { + let ch = ChannelId::from(Uuid::new_v4()); + let sc = ScheduleId::from(Uuid::new_v4()); + let event = DomainEvent::ScheduleGenerated { + channel_id: ch, + schedule_id: sc, + }; + match event { + DomainEvent::ScheduleGenerated { + channel_id, + schedule_id, + } => { + assert_eq!(channel_id, ch); + assert_eq!(schedule_id, sc); + } + _ => panic!("wrong variant"), + } +} + +#[test] +fn channel_lifecycle_events() { + let ch = ChannelId::from(Uuid::new_v4()); + + let created = DomainEvent::ChannelCreated { channel_id: ch }; + let updated = DomainEvent::ChannelUpdated { channel_id: ch }; + let deleted = DomainEvent::ChannelDeleted { channel_id: ch }; + + for event in [created, updated, deleted] { + match event { + DomainEvent::ChannelCreated { channel_id } + | DomainEvent::ChannelUpdated { channel_id } + | DomainEvent::ChannelDeleted { channel_id } => { + assert_eq!(channel_id, ch); + } + _ => panic!("wrong variant"), + } + } +} + +#[test] +fn event_is_clone() { + let ch = ChannelId::from(Uuid::new_v4()); + let event = DomainEvent::NoSignal { channel_id: ch }; + let cloned = event.clone(); + match cloned { + DomainEvent::NoSignal { channel_id } => assert_eq!(channel_id, ch), + _ => panic!("wrong variant"), + } +} diff --git a/crates/domain/src/models/activity.rs b/crates/domain/src/models/activity.rs index 90f9131..934e6df 100644 --- a/crates/domain/src/models/activity.rs +++ b/crates/domain/src/models/activity.rs @@ -1,14 +1,6 @@ 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, @@ -19,7 +11,6 @@ pub struct ActivityEvent { } impl ActivityEvent { - /// Create a new activity event (generates ID and timestamp). pub fn new( event_type: impl Into, detail: impl Into, @@ -34,7 +25,6 @@ impl ActivityEvent { } } - /// Hydrate from persistence -- no validation, accepts all fields. pub fn from_persistence( id: Uuid, timestamp: DateTime, @@ -51,8 +41,6 @@ impl ActivityEvent { } } - // -- Getters -- - pub fn id(&self) -> Uuid { self.id } @@ -74,44 +62,6 @@ impl ActivityEvent { } } -// ============================================================================ -// 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)); - } -} +#[path = "tests/activity.rs"] +mod tests; diff --git a/crates/domain/src/models/channel.rs b/crates/domain/src/models/channel.rs index 04341e2..1e9357c 100644 --- a/crates/domain/src/models/channel.rs +++ b/crates/domain/src/models/channel.rs @@ -7,22 +7,16 @@ use crate::value_objects::{ RecyclePolicy, UserId, Weekday, }; -// ============================================================================ -// Channel -// ============================================================================ +const SECONDS_IN_DAY: u32 = 86_400; +const DEFAULT_LOGO_OPACITY: f32 = 1.0; +const DEFAULT_WEBHOOK_POLL_INTERVAL_SECS: u32 = 5; -/// 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, - /// 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, @@ -41,7 +35,6 @@ pub struct Channel { } impl Channel { - /// Create a new channel with sensible defaults. pub fn new( owner_id: UserId, name: impl Into, @@ -61,9 +54,9 @@ impl Channel { access_password_hash: None, logo: None, logo_position: LogoPosition::default(), - logo_opacity: 1.0, + logo_opacity: DEFAULT_LOGO_OPACITY, webhook_url: None, - webhook_poll_interval_secs: 5, + webhook_poll_interval_secs: DEFAULT_WEBHOOK_POLL_INTERVAL_SECS, webhook_body_template: None, webhook_headers: None, created_at: now, @@ -71,8 +64,6 @@ impl Channel { } } - /// Hydrate from persistence — no validation, accepts all fields. - #[allow(clippy::too_many_arguments)] pub fn from_persistence( id: ChannelId, owner_id: UserId, @@ -117,8 +108,6 @@ impl Channel { } } - // -- Getters -- - pub fn id(&self) -> ChannelId { self.id } @@ -195,57 +184,38 @@ impl Channel { self.updated_at } - // -- Setters -- - - /// Update the channel name and touch `updated_at`. pub fn set_name(&mut self, name: impl Into) { self.name = name.into(); self.updated_at = Utc::now(); } - /// Update the description and touch `updated_at`. pub fn set_description(&mut self, description: Option) { self.description = description; self.updated_at = Utc::now(); } - /// Update the timezone and touch `updated_at`. pub fn set_timezone(&mut self, timezone: impl Into) { self.timezone = timezone.into(); self.updated_at = Utc::now(); } - /// Replace the schedule config and touch `updated_at`. pub fn set_schedule_config(&mut self, config: ScheduleConfig) { self.schedule_config = config; self.updated_at = Utc::now(); } - /// Replace the recycle policy and touch `updated_at`. pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) { self.recycle_policy = policy; self.updated_at = Utc::now(); } - /// Toggle auto-schedule and touch `updated_at`. pub fn set_auto_schedule(&mut self, enabled: bool) { self.auto_schedule = enabled; self.updated_at = Utc::now(); } } -// ============================================================================ -// 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`. +// deny_unknown_fields required so #[serde(untagged)] compat enum correctly rejects V1 payloads #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScheduleConfig { @@ -253,36 +223,31 @@ pub struct ScheduleConfig { } 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>) -> 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 { + if end <= SECONDS_IN_DAY { secs >= start && secs < end } else { - secs >= start || secs < (end % 86_400) + secs >= start || secs < (end % SECONDS_IN_DAY) } }) } - /// 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) @@ -293,8 +258,6 @@ impl ScheduleConfig { .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() @@ -303,29 +266,19 @@ impl ScheduleConfig { .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() } - /// Get the underlying day_blocks map. pub fn day_blocks(&self) -> &HashMap> { &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) { 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 { @@ -338,8 +291,6 @@ impl OldScheduleConfig { } } -/// 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 { @@ -362,44 +313,32 @@ impl From for ScheduleConfig { } } -// ============================================================================ -// 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, } impl ProgrammingBlock { - /// Create a new algorithmic programming block. pub fn new_algorithmic( name: impl Into, start_time: NaiveTime, @@ -424,7 +363,6 @@ impl ProgrammingBlock { } } - /// Create a new manual programming block with hand-picked items. pub fn new_manual( name: impl Into, start_time: NaiveTime, @@ -447,8 +385,6 @@ impl ProgrammingBlock { } } - // -- Getters -- - pub fn id(&self) -> BlockId { self.id } @@ -486,146 +422,22 @@ impl ProgrammingBlock { } } -// ============================================================================ -// 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, - /// 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 = 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"), - } - } -} +#[path = "tests/channel.rs"] +mod tests; diff --git a/crates/domain/src/models/collections.rs b/crates/domain/src/models/collections.rs index 0e48f8f..623280b 100644 --- a/crates/domain/src/models/collections.rs +++ b/crates/domain/src/models/collections.rs @@ -1,4 +1,5 @@ -/// Pagination parameters. +const DEFAULT_PAGE_LIMIT: u32 = 50; + #[derive(Debug, Clone)] pub struct PageParams { offset: u32, @@ -23,12 +24,11 @@ impl Default for PageParams { fn default() -> Self { Self { offset: 0, - limit: 50, + limit: DEFAULT_PAGE_LIMIT, } } } -/// A paginated response wrapper. #[derive(Debug, Clone)] pub struct Paginated { items: Vec, @@ -54,34 +54,5 @@ impl Paginated { } #[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"]); - } -} +#[path = "tests/collections.rs"] +mod tests; diff --git a/crates/domain/src/models/config_snapshot.rs b/crates/domain/src/models/config_snapshot.rs index 21e5b26..ac29348 100644 --- a/crates/domain/src/models/config_snapshot.rs +++ b/crates/domain/src/models/config_snapshot.rs @@ -6,14 +6,6 @@ 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, @@ -25,7 +17,6 @@ pub struct ChannelConfigSnapshot { } impl ChannelConfigSnapshot { - /// Create a new snapshot (generates ID and timestamp). pub fn new( channel_id: ChannelId, config: ScheduleConfig, @@ -41,7 +32,6 @@ impl ChannelConfigSnapshot { } } - /// Hydrate from persistence -- no validation, accepts all fields. pub fn from_persistence( id: Uuid, channel_id: ChannelId, @@ -60,8 +50,6 @@ impl ChannelConfigSnapshot { } } - // -- Getters -- - pub fn id(&self) -> Uuid { self.id } @@ -87,50 +75,6 @@ impl ChannelConfigSnapshot { } } -// ============================================================================ -// 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()); - } -} +#[path = "tests/config_snapshot.rs"] +mod tests; diff --git a/crates/domain/src/models/library.rs b/crates/domain/src/models/library.rs index 977b925..2cd7b30 100644 --- a/crates/domain/src/models/library.rs +++ b/crates/domain/src/models/library.rs @@ -1,13 +1,7 @@ use crate::value_objects::ContentType; -// ============================================================================ -// LibraryItem -// ============================================================================ +const SYNC_STATUS_RUNNING: &str = "running"; -/// 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, @@ -30,7 +24,6 @@ pub struct LibraryItem { } 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, @@ -63,8 +56,6 @@ impl LibraryItem { } } - /// Hydrate from persistence -- no validation, accepts all fields. - #[allow(clippy::too_many_arguments)] pub fn from_persistence( id: String, provider_id: String, @@ -105,8 +96,6 @@ impl LibraryItem { } } - // -- Getters -- - pub fn id(&self) -> &str { &self.id } @@ -176,11 +165,6 @@ impl LibraryItem { } } -// ============================================================================ -// LibraryCollection -// ============================================================================ - -/// A collection summary derived from synced library items. #[derive(Debug, Clone)] pub struct LibraryCollection { id: String, @@ -209,8 +193,6 @@ impl LibraryCollection { } } - // -- Getters -- - pub fn id(&self) -> &str { &self.id } @@ -224,11 +206,6 @@ impl LibraryCollection { } } -// ============================================================================ -// LibrarySyncResult -// ============================================================================ - -/// Result of a single provider sync run. #[derive(Debug, Clone)] pub struct LibrarySyncResult { provider_id: String, @@ -278,8 +255,6 @@ impl LibrarySyncResult { } } - // -- Getters -- - pub fn provider_id(&self) -> &str { &self.provider_id } @@ -297,11 +272,6 @@ impl LibrarySyncResult { } } -// ============================================================================ -// LibrarySyncLogEntry -// ============================================================================ - -/// Log entry from the library_sync_log table. #[derive(Debug, Clone)] pub struct LibrarySyncLogEntry { id: i64, @@ -321,7 +291,7 @@ impl LibrarySyncLogEntry { started_at: started_at.into(), finished_at: None, items_found: 0, - status: "running".to_string(), + status: SYNC_STATUS_RUNNING.to_string(), error_msg: None, } } @@ -346,8 +316,6 @@ impl LibrarySyncLogEntry { } } - // -- Getters -- - pub fn id(&self) -> i64 { self.id } @@ -377,11 +345,6 @@ impl LibrarySyncLogEntry { } } -// ============================================================================ -// ShowSummary -// ============================================================================ - -/// Aggregated summary of a TV show derived from synced episodes. #[derive(Debug, Clone)] pub struct ShowSummary { series_name: String, @@ -422,8 +385,6 @@ impl ShowSummary { } } - // -- Getters -- - pub fn series_name(&self) -> &str { &self.series_name } @@ -445,11 +406,6 @@ impl ShowSummary { } } -// ============================================================================ -// SeasonSummary -// ============================================================================ - -/// Aggregated summary of one season of a TV show. #[derive(Debug, Clone)] pub struct SeasonSummary { season_number: u32, @@ -478,8 +434,6 @@ impl SeasonSummary { } } - // -- Getters -- - pub fn season_number(&self) -> u32 { self.season_number } @@ -493,132 +447,6 @@ impl SeasonSummary { } } -// ============================================================================ -// 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()); - } -} +#[path = "tests/library.rs"] +mod tests; diff --git a/crates/domain/src/models/media.rs b/crates/domain/src/models/media.rs index 7bd135a..91dba1f 100644 --- a/crates/domain/src/models/media.rs +++ b/crates/domain/src/models/media.rs @@ -4,10 +4,6 @@ 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, @@ -18,20 +14,14 @@ pub struct MediaItem { genres: Vec, year: Option, tags: Vec, - /// For episodes: the parent TV show name. series_name: Option, - /// For episodes: season number (1-based). season_number: Option, - /// For episodes: episode number within the season (1-based). episode_number: Option, - /// Provider-served thumbnail image URL, populated if available. thumbnail_url: Option, - /// Provider-specific collection this item belongs to. collection_id: Option, } impl MediaItem { - /// Create a new media item with required fields; optional fields default to None/empty. pub fn new( id: MediaItemId, title: impl Into, @@ -55,8 +45,6 @@ impl MediaItem { } } - /// Hydrate from persistence — accepts all fields. - #[allow(clippy::too_many_arguments)] pub fn from_persistence( id: MediaItemId, title: String, @@ -89,8 +77,6 @@ impl MediaItem { } } - // -- Getters -- - pub fn id(&self) -> &MediaItemId { &self.id } @@ -144,23 +130,16 @@ impl MediaItem { } } -// ============================================================================ -// 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, - /// 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(), @@ -171,7 +150,6 @@ impl PlaybackRecord { } } - /// Hydrate from persistence — accepts all fields. pub fn from_persistence( id: Uuid, channel_id: ChannelId, @@ -188,8 +166,6 @@ impl PlaybackRecord { } } - // -- Getters -- - pub fn id(&self) -> Uuid { self.id } @@ -212,69 +188,5 @@ impl PlaybackRecord { } #[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); - } -} +#[path = "tests/media.rs"] +mod tests; diff --git a/crates/domain/src/models/provider_config.rs b/crates/domain/src/models/provider_config.rs index b3e854a..9df8914 100644 --- a/crates/domain/src/models/provider_config.rs +++ b/crates/domain/src/models/provider_config.rs @@ -1,12 +1,3 @@ -// ============================================================================ -// 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, @@ -17,7 +8,6 @@ pub struct ProviderConfigRow { } impl ProviderConfigRow { - /// Create a new provider config row. pub fn new( id: impl Into, provider_type: impl Into, @@ -32,7 +22,6 @@ impl ProviderConfigRow { } } - /// Hydrate from persistence -- no validation, accepts all fields. pub fn from_persistence( id: String, provider_type: String, @@ -49,8 +38,6 @@ impl ProviderConfigRow { } } - // -- Getters -- - pub fn id(&self) -> &str { &self.id } @@ -72,41 +59,6 @@ impl ProviderConfigRow { } } -// ============================================================================ -// 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")); - } -} +#[path = "tests/provider_config.rs"] +mod tests; diff --git a/crates/domain/src/models/schedule.rs b/crates/domain/src/models/schedule.rs index 259f750..2de8392 100644 --- a/crates/domain/src/models/schedule.rs +++ b/crates/domain/src/models/schedule.rs @@ -5,29 +5,17 @@ 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, @@ -45,7 +33,6 @@ impl GeneratedSchedule { } } - /// Hydrate from persistence -- no validation, accepts all fields. pub fn from_persistence( id: ScheduleId, channel_id: ChannelId, @@ -64,13 +51,10 @@ impl GeneratedSchedule { } } - /// 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 } @@ -100,27 +84,16 @@ impl GeneratedSchedule { } } -// ============================================================================ -// 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, @@ -136,7 +109,6 @@ impl ScheduledSlot { } } - /// Hydrate from persistence -- no validation, accepts all fields. pub fn from_persistence( id: SlotId, start_at: DateTime, @@ -153,8 +125,6 @@ impl ScheduledSlot { } } - // -- Getters -- - pub fn id(&self) -> SlotId { self.id } @@ -176,28 +146,17 @@ impl ScheduledSlot { } } -// ============================================================================ -// 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 } @@ -211,106 +170,6 @@ impl CurrentBroadcast { } } -// ============================================================================ -// 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); - } -} +#[path = "tests/schedule.rs"] +mod tests; diff --git a/crates/domain/src/models/tests/activity.rs b/crates/domain/src/models/tests/activity.rs new file mode 100644 index 0000000..290d458 --- /dev/null +++ b/crates/domain/src/models/tests/activity.rs @@ -0,0 +1,34 @@ +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/tests/channel.rs b/crates/domain/src/models/tests/channel.rs new file mode 100644 index 0000000..cb2182d --- /dev/null +++ b/crates/domain/src/models/tests/channel.rs @@ -0,0 +1,111 @@ +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 = serde_json::from_str(json); + match result { + Ok(ScheduleConfigCompat::V2(cfg)) => { + let _ = cfg; + } + Ok(ScheduleConfigCompat::V1(_)) => {} + Err(_) => {} + } +} + +#[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(), DEFAULT_LOGO_OPACITY); + assert_eq!(ch.webhook_poll_interval_secs(), DEFAULT_WEBHOOK_POLL_INTERVAL_SECS); +} + +#[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"), + } +} diff --git a/crates/domain/src/models/tests/collections.rs b/crates/domain/src/models/tests/collections.rs new file mode 100644 index 0000000..4583479 --- /dev/null +++ b/crates/domain/src/models/tests/collections.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn page_params_default() { + let p = PageParams::default(); + assert_eq!(p.offset(), 0); + assert_eq!(p.limit(), DEFAULT_PAGE_LIMIT); +} + +#[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"]); +} diff --git a/crates/domain/src/models/tests/config_snapshot.rs b/crates/domain/src/models/tests/config_snapshot.rs new file mode 100644 index 0000000..1fd825c --- /dev/null +++ b/crates/domain/src/models/tests/config_snapshot.rs @@ -0,0 +1,39 @@ +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, + ); + assert!(snap.config().day_blocks().is_empty()); +} diff --git a/crates/domain/src/models/tests/library.rs b/crates/domain/src/models/tests/library.rs new file mode 100644 index 0000000..2624763 --- /dev/null +++ b/crates/domain/src/models/tests/library.rs @@ -0,0 +1,122 @@ +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(), SYNC_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/tests/media.rs b/crates/domain/src/models/tests/media.rs new file mode 100644 index 0000000..cb3d7eb --- /dev/null +++ b/crates/domain/src/models/tests/media.rs @@ -0,0 +1,64 @@ +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); +} diff --git a/crates/domain/src/models/tests/provider_config.rs b/crates/domain/src/models/tests/provider_config.rs new file mode 100644 index 0000000..18a3e09 --- /dev/null +++ b/crates/domain/src/models/tests/provider_config.rs @@ -0,0 +1,31 @@ +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/tests/schedule.rs b/crates/domain/src/models/tests/schedule.rs new file mode 100644 index 0000000..8701e29 --- /dev/null +++ b/crates/domain/src/models/tests/schedule.rs @@ -0,0 +1,96 @@ +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)); + 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); +} diff --git a/crates/domain/src/models/tests/user.rs b/crates/domain/src/models/tests/user.rs new file mode 100644 index 0000000..14971f4 --- /dev/null +++ b/crates/domain/src/models/tests/user.rs @@ -0,0 +1,38 @@ +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); +} diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs index 6df58e1..e365ac5 100644 --- a/crates/domain/src/models/user.rs +++ b/crates/domain/src/models/user.rs @@ -3,9 +3,6 @@ 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, @@ -17,7 +14,6 @@ pub struct User { } impl User { - /// Create a new OIDC user (no local password). pub fn new(subject: impl Into, email: Email) -> Self { Self { id: UserId::generate(), @@ -29,7 +25,6 @@ impl User { } } - /// Create a new local user with a password hash. pub fn new_local(email: Email, password_hash: impl Into) -> Self { Self { id: UserId::generate(), @@ -41,7 +36,6 @@ impl User { } } - /// Hydrate from persistence — no validation, accepts all fields. pub fn from_persistence( id: UserId, subject: String, @@ -60,8 +54,6 @@ impl User { } } - // -- Getters -- - pub fn id(&self) -> UserId { self.id } @@ -86,52 +78,11 @@ impl User { self.created_at } - // -- Mutations -- - - /// Promote this user to admin. pub fn promote_to_admin(&mut self) { self.is_admin = true; } } #[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); - } -} +#[path = "tests/user.rs"] +mod tests; diff --git a/crates/domain/src/ports/activity.rs b/crates/domain/src/ports/activity.rs index 82f4e97..dc11d8f 100644 --- a/crates/domain/src/ports/activity.rs +++ b/crates/domain/src/ports/activity.rs @@ -1,17 +1,11 @@ -//! Activity log port. -//! -//! Records user and system actions for the admin dashboard's activity feed. - use async_trait::async_trait; use crate::errors::DomainResult; use crate::models::ActivityEvent; use crate::value_objects::ChannelId; -/// Port for activity log persistence. #[async_trait] pub trait ActivityLogCommand: Send + Sync { - /// Log a new activity event. async fn log( &self, event_type: &str, @@ -20,9 +14,7 @@ pub trait ActivityLogCommand: Send + Sync { ) -> DomainResult<()>; } -/// Port for reading activity log entries. #[async_trait] pub trait ActivityLogQuery: Send + Sync { - /// Retrieve the most recent activity events. async fn recent(&self, limit: u32) -> DomainResult>; } diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs index 87fedc3..8534c74 100644 --- a/crates/domain/src/ports/auth.rs +++ b/crates/domain/src/ports/auth.rs @@ -1,19 +1,8 @@ -//! Authentication port. -//! -//! Abstracts password hashing and verification so the domain layer -//! never depends on a specific hashing algorithm (bcrypt, argon2, etc.). - use crate::errors::DomainResult; -/// Port for password hashing and verification. -/// -/// Implementations live in the infra layer (e.g. `BcryptAuthService`). -/// These methods are intentionally synchronous — hashing libraries are CPU-bound -/// and should be spawned on a blocking thread pool by the caller if needed. +// Intentionally sync: CPU-bound hashing should run on a blocking thread pool pub trait AuthService: Send + Sync { - /// Hash a plaintext password and return the encoded hash string. fn hash_password(&self, password: &str) -> DomainResult; - /// Verify a plaintext password against an encoded hash. fn verify_password(&self, password: &str, hash: &str) -> DomainResult; } diff --git a/crates/domain/src/ports/channel.rs b/crates/domain/src/ports/channel.rs index 72a11cf..0ba3e0a 100644 --- a/crates/domain/src/ports/channel.rs +++ b/crates/domain/src/ports/channel.rs @@ -1,5 +1,3 @@ -//! Channel persistence ports (CQRS split). - use async_trait::async_trait; use uuid::Uuid; @@ -7,19 +5,12 @@ use crate::errors::DomainResult; use crate::models::{Channel, ChannelConfigSnapshot, ScheduleConfig}; use crate::value_objects::{ChannelId, UserId}; -/// Write-side port for channel persistence. #[async_trait] pub trait ChannelCommand: Send + Sync { - /// Insert or update a channel. async fn save(&self, channel: &Channel) -> DomainResult<()>; - /// Delete a channel by ID. async fn delete(&self, id: ChannelId) -> DomainResult<()>; - /// Snapshot the current config before saving a new one. - /// - /// `version_num` is computed by the infra layer as `MAX(version_num)+1` - /// inside a transaction. async fn save_config_snapshot( &self, channel_id: ChannelId, @@ -27,7 +18,6 @@ pub trait ChannelCommand: Send + Sync { label: Option, ) -> DomainResult; - /// Update the label on an existing config snapshot. async fn patch_config_snapshot_label( &self, channel_id: ChannelId, @@ -36,28 +26,21 @@ pub trait ChannelCommand: Send + Sync { ) -> DomainResult>; } -/// Read-side port for channel persistence. #[async_trait] pub trait ChannelQuery: Send + Sync { - /// Find a channel by its ID. async fn find_by_id(&self, id: ChannelId) -> DomainResult>; - /// Find all channels owned by a user. async fn find_by_owner(&self, owner_id: UserId) -> DomainResult>; - /// List all channels. async fn find_all(&self) -> DomainResult>; - /// Find channels with auto-schedule enabled. async fn find_auto_schedule_enabled(&self) -> DomainResult>; - /// List all config snapshots for a channel, newest first. async fn list_config_snapshots( &self, channel_id: ChannelId, ) -> DomainResult>; - /// Get a specific config snapshot by channel and snapshot ID. async fn get_config_snapshot( &self, channel_id: ChannelId, diff --git a/crates/domain/src/ports/events.rs b/crates/domain/src/ports/events.rs index 06f5074..c87220d 100644 --- a/crates/domain/src/ports/events.rs +++ b/crates/domain/src/ports/events.rs @@ -1,38 +1,19 @@ -//! Domain event ports. -//! -//! Minimal event infrastructure for publishing domain events. -//! The consumer/handler side is intentionally simple — no subscription -//! mechanism yet; handlers are registered at startup and dispatched -//! synchronously by the publisher. - use async_trait::async_trait; use crate::errors::DomainResult; pub use crate::events::DomainEvent; -/// Port for publishing domain events. -/// -/// Implementations may log, fan out to handlers, or push to a message bus. #[async_trait] pub trait EventPublisher: Send + Sync { - /// Publish a single domain event. async fn publish(&self, event: DomainEvent) -> DomainResult<()>; } -/// Port for consuming domain events from a queue or channel. #[async_trait] pub trait EventConsumer: Send + Sync { - /// Block until the next event is available and return it. async fn recv(&self) -> DomainResult; } -/// Port for handling domain events. -/// -/// Each handler is responsible for one side-effect (e.g. logging, webhook -/// dispatch, cache invalidation). Handlers are registered at startup. #[async_trait] pub trait EventHandler: Send + Sync { - /// Handle a domain event. Errors are logged but do not abort the - /// originating operation. async fn handle(&self, event: &DomainEvent) -> DomainResult<()>; } diff --git a/crates/domain/src/ports/library.rs b/crates/domain/src/ports/library.rs index 676ff3d..5fc0e40 100644 --- a/crates/domain/src/ports/library.rs +++ b/crates/domain/src/ports/library.rs @@ -1,5 +1,3 @@ -//! Library persistence ports (CQRS split) and sync adapter. - use async_trait::async_trait; use crate::errors::DomainResult; @@ -11,58 +9,43 @@ use crate::value_objects::{ContentType, LibrarySearchFilter}; use super::media::IMediaProvider; -/// Write-side port for library persistence. #[async_trait] pub trait LibraryCommand: Send + Sync { - /// Upsert a batch of library items for a given provider. async fn upsert_items(&self, provider_id: &str, items: Vec) -> DomainResult<()>; - /// Remove all items belonging to a provider (used before full re-sync). async fn clear_provider(&self, provider_id: &str) -> DomainResult<()>; - /// Create a sync log entry marking the start of a sync run. - /// Returns the log entry ID for later completion. async fn log_sync_start(&self, provider_id: &str) -> DomainResult; - /// Mark a sync log entry as finished with the given result. async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()>; } -/// Read-side port for library persistence. #[async_trait] pub trait LibraryQuery: Send + Sync { - /// Search the library with the given filter. Returns (items, total_count). async fn search( &self, filter: &LibrarySearchFilter, ) -> DomainResult<(Vec, u32)>; - /// Get a single library item by its composite ID. async fn get_by_id(&self, id: &str) -> DomainResult>; - /// List all collections, optionally filtered by provider. async fn list_collections( &self, provider_id: Option<&str>, ) -> DomainResult>; - /// List all unique series names, optionally filtered by provider. async fn list_series(&self, provider_id: Option<&str>) -> DomainResult>; - /// List all genres, optionally filtered by content type and provider. async fn list_genres( &self, content_type: Option<&ContentType>, provider_id: Option<&str>, ) -> DomainResult>; - /// Get the latest sync log entries (one per provider). async fn latest_sync_status(&self) -> DomainResult>; - /// Check whether a sync is currently running for a provider. async fn is_sync_running(&self, provider_id: &str) -> DomainResult; - /// List TV show summaries, optionally filtered by provider, search term, and genres. async fn list_shows( &self, provider_id: Option<&str>, @@ -70,7 +53,6 @@ pub trait LibraryQuery: Send + Sync { genres: &[String], ) -> DomainResult>; - /// List season summaries for a specific series. async fn list_seasons( &self, series_name: &str, @@ -78,9 +60,6 @@ pub trait LibraryQuery: Send + Sync { ) -> DomainResult>; } -/// Port: sync one provider's items into the library. -/// -/// DB writes are handled entirely inside implementations — no pool in the trait. #[async_trait] pub trait LibrarySyncAdapter: Send + Sync { async fn sync_provider( diff --git a/crates/domain/src/ports/media.rs b/crates/domain/src/ports/media.rs index 3f50efb..c39c375 100644 --- a/crates/domain/src/ports/media.rs +++ b/crates/domain/src/ports/media.rs @@ -1,10 +1,3 @@ -//! Media provider ports and associated types. -//! -//! Abstract interfaces for fetching media from any source. -//! The domain never knows whether the backing provider is Jellyfin, Plex, -//! a local filesystem, or anything else — adapters in the infra crate implement -//! these traits for each concrete source. - use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -12,37 +5,19 @@ use crate::errors::{DomainError, DomainResult}; use crate::models::MediaItem; use crate::value_objects::{ContentType, MediaFilter, MediaItemId}; -// ============================================================================ -// Stream quality -// ============================================================================ - -/// Requested stream quality for `get_stream_url`. #[derive(Debug, Clone)] pub enum StreamQuality { - /// Try direct stream via PlaybackInfo; fall back to HLS at 8 Mbps. Direct, - /// Force HLS transcode at this bitrate (bits per second). Transcode(u32), } -// ============================================================================ -// Provider capabilities -// ============================================================================ - -/// How a provider delivers video to the client. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StreamingProtocol { - /// HLS playlist (`.m3u8`). Requires hls.js on non-Safari browsers. Hls, - /// Direct file URL with Range-header support. Native `