domain crate code quality cleanup
strip all comments, extract tests to tests/ dirs, remove #[allow(clippy::...)], extract magic numbers to constants, refactor schedule engine private methods to use param structs, add Default impls, clippy.toml for persistence constructors
This commit is contained in:
1
crates/domain/clippy.toml
Normal file
1
crates/domain/clippy.toml
Normal file
@@ -0,0 +1 @@
|
||||
too-many-arguments-threshold = 20
|
||||
@@ -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;
|
||||
|
||||
83
crates/domain/src/events/tests/mod.rs
Normal file
83
crates/domain/src/events/tests/mod.rs
Normal file
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
detail: impl Into<String>,
|
||||
@@ -34,7 +25,6 @@ impl ActivityEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||
pub fn from_persistence(
|
||||
id: Uuid,
|
||||
timestamp: DateTime<Utc>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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<String>,
|
||||
@@ -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<String>) {
|
||||
self.name = name.into();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Update the description and touch `updated_at`.
|
||||
pub fn set_description(&mut self, description: Option<String>) {
|
||||
self.description = description;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Update the timezone and touch `updated_at`.
|
||||
pub fn set_timezone(&mut self, timezone: impl Into<String>) {
|
||||
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<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 {
|
||||
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<NaiveTime> {
|
||||
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<NaiveTime> {
|
||||
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<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 {
|
||||
@@ -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<ScheduleConfigCompat> 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<String>,
|
||||
}
|
||||
|
||||
impl ProgrammingBlock {
|
||||
/// Create a new algorithmic programming block.
|
||||
pub fn new_algorithmic(
|
||||
name: impl Into<String>,
|
||||
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<String>,
|
||||
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<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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "tests/channel.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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<T> {
|
||||
items: Vec<T>,
|
||||
@@ -54,34 +54,5 @@ impl<T> Paginated<T> {
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
external_id: impl Into<String>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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>,
|
||||
@@ -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<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(),
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
provider_type: impl Into<String>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Utc>,
|
||||
valid_until: DateTime<Utc>,
|
||||
/// Monotonically increasing counter per channel, used by `RecyclePolicy`.
|
||||
generation: u32,
|
||||
/// Resolved slots, sorted ascending by `start_at`.
|
||||
slots: Vec<ScheduledSlot>,
|
||||
}
|
||||
|
||||
impl GeneratedSchedule {
|
||||
/// Create a new generated schedule.
|
||||
pub fn new(
|
||||
channel_id: ChannelId,
|
||||
valid_from: DateTime<Utc>,
|
||||
@@ -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<Utc>) -> 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<Utc>,
|
||||
end_at: DateTime<Utc>,
|
||||
/// 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<Utc>,
|
||||
end_at: DateTime<Utc>,
|
||||
@@ -136,7 +109,6 @@ impl ScheduledSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate from persistence -- no validation, accepts all fields.
|
||||
pub fn from_persistence(
|
||||
id: SlotId,
|
||||
start_at: DateTime<Utc>,
|
||||
@@ -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<Utc>, 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;
|
||||
|
||||
34
crates/domain/src/models/tests/activity.rs
Normal file
34
crates/domain/src/models/tests/activity.rs
Normal file
@@ -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));
|
||||
}
|
||||
111
crates/domain/src/models/tests/channel.rs
Normal file
111
crates/domain/src/models/tests/channel.rs
Normal file
@@ -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<ScheduleConfigCompat, _> = 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"),
|
||||
}
|
||||
}
|
||||
29
crates/domain/src/models/tests/collections.rs
Normal file
29
crates/domain/src/models/tests/collections.rs
Normal file
@@ -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"]);
|
||||
}
|
||||
39
crates/domain/src/models/tests/config_snapshot.rs
Normal file
39
crates/domain/src/models/tests/config_snapshot.rs
Normal file
@@ -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());
|
||||
}
|
||||
122
crates/domain/src/models/tests/library.rs
Normal file
122
crates/domain/src/models/tests/library.rs
Normal file
@@ -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());
|
||||
}
|
||||
64
crates/domain/src/models/tests/media.rs
Normal file
64
crates/domain/src/models/tests/media.rs
Normal file
@@ -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);
|
||||
}
|
||||
31
crates/domain/src/models/tests/provider_config.rs
Normal file
31
crates/domain/src/models/tests/provider_config.rs
Normal file
@@ -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"));
|
||||
}
|
||||
96
crates/domain/src/models/tests/schedule.rs
Normal file
96
crates/domain/src/models/tests/schedule.rs
Normal file
@@ -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<Utc>, 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);
|
||||
}
|
||||
38
crates/domain/src/models/tests/user.rs
Normal file
38
crates/domain/src/models/tests/user.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<String>, 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<String>) -> 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;
|
||||
|
||||
@@ -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<Vec<ActivityEvent>>;
|
||||
}
|
||||
|
||||
@@ -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<String>;
|
||||
|
||||
/// Verify a plaintext password against an encoded hash.
|
||||
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool>;
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
) -> DomainResult<ChannelConfigSnapshot>;
|
||||
|
||||
/// 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<Option<ChannelConfigSnapshot>>;
|
||||
}
|
||||
|
||||
/// 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<Option<Channel>>;
|
||||
|
||||
/// Find all channels owned by a user.
|
||||
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>>;
|
||||
|
||||
/// List all channels.
|
||||
async fn find_all(&self) -> DomainResult<Vec<Channel>>;
|
||||
|
||||
/// Find channels with auto-schedule enabled.
|
||||
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>>;
|
||||
|
||||
/// List all config snapshots for a channel, newest first.
|
||||
async fn list_config_snapshots(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> DomainResult<Vec<ChannelConfigSnapshot>>;
|
||||
|
||||
/// Get a specific config snapshot by channel and snapshot ID.
|
||||
async fn get_config_snapshot(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
|
||||
@@ -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<DomainEvent>;
|
||||
}
|
||||
|
||||
/// 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<()>;
|
||||
}
|
||||
|
||||
@@ -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<LibraryItem>) -> 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<i64>;
|
||||
|
||||
/// 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<LibraryItem>, u32)>;
|
||||
|
||||
/// Get a single library item by its composite ID.
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<LibraryItem>>;
|
||||
|
||||
/// List all collections, optionally filtered by provider.
|
||||
async fn list_collections(
|
||||
&self,
|
||||
provider_id: Option<&str>,
|
||||
) -> DomainResult<Vec<LibraryCollection>>;
|
||||
|
||||
/// List all unique series names, optionally filtered by provider.
|
||||
async fn list_series(&self, provider_id: Option<&str>) -> DomainResult<Vec<String>>;
|
||||
|
||||
/// List all genres, optionally filtered by content type and provider.
|
||||
async fn list_genres(
|
||||
&self,
|
||||
content_type: Option<&ContentType>,
|
||||
provider_id: Option<&str>,
|
||||
) -> DomainResult<Vec<String>>;
|
||||
|
||||
/// Get the latest sync log entries (one per provider).
|
||||
async fn latest_sync_status(&self) -> DomainResult<Vec<LibrarySyncLogEntry>>;
|
||||
|
||||
/// Check whether a sync is currently running for a provider.
|
||||
async fn is_sync_running(&self, provider_id: &str) -> DomainResult<bool>;
|
||||
|
||||
/// 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<Vec<ShowSummary>>;
|
||||
|
||||
/// 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<Vec<SeasonSummary>>;
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -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 `<video>` element.
|
||||
DirectFile,
|
||||
}
|
||||
|
||||
/// Feature matrix for a media provider.
|
||||
///
|
||||
/// The API and frontend use this to gate calls and hide UI controls that
|
||||
/// the active provider does not support.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
pub collections: bool,
|
||||
@@ -52,96 +27,46 @@ pub struct ProviderCapabilities {
|
||||
pub decade: bool,
|
||||
pub search: bool,
|
||||
pub streaming_protocol: StreamingProtocol,
|
||||
/// Whether `POST /files/rescan` is available.
|
||||
pub rescan: bool,
|
||||
/// Whether on-demand FFmpeg transcoding to HLS is available.
|
||||
pub transcode: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Library browsing types
|
||||
// ============================================================================
|
||||
|
||||
/// A top-level media collection / library exposed by a provider.
|
||||
///
|
||||
/// In Jellyfin this maps to a virtual library (Movies, TV Shows, ...).
|
||||
/// In Plex it maps to a section. The `id` is provider-specific and is used
|
||||
/// as the value for `MediaFilter::collections`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Provider-specific type hint, e.g. "movies", "tvshows". `None` when the
|
||||
/// provider does not expose this information.
|
||||
pub collection_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightweight summary of a TV series available in the provider's library.
|
||||
/// Returned by `IMediaProvider::list_series` for the dashboard browser.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SeriesSummary {
|
||||
/// Provider-specific series ID (opaque -- used for ParentId filtering).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Total number of episodes across all seasons, if the provider exposes it.
|
||||
pub episode_count: u32,
|
||||
pub genres: Vec<String>,
|
||||
pub year: Option<u16>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IMediaProvider
|
||||
// ============================================================================
|
||||
|
||||
/// Port for reading media content from an external provider.
|
||||
///
|
||||
/// Implementations live in the infra layer. One adapter per provider type
|
||||
/// (e.g. `JellyfinMediaProvider`, `PlexMediaProvider`, `LocalFileProvider`).
|
||||
///
|
||||
/// The three browsing methods (`list_collections`, `list_series`, `list_genres`)
|
||||
/// have default implementations that return an `InfrastructureError`. Adapters
|
||||
/// that support library browsing override them; those that don't (e.g. the
|
||||
/// `NoopMediaProvider`) inherit the default and return a clear error.
|
||||
#[async_trait]
|
||||
pub trait IMediaProvider: Send + Sync {
|
||||
/// Declare what features this provider supports.
|
||||
///
|
||||
/// Called at request time (not cached) so the response always reflects the
|
||||
/// active provider. Implementations return a plain struct -- no I/O needed.
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
|
||||
/// Fetch metadata for all items matching `filter` from this provider.
|
||||
///
|
||||
/// The provider interprets each field of `MediaFilter` in terms of its own
|
||||
/// API (e.g. Jellyfin libraries, Plex sections, filesystem paths).
|
||||
/// Returns an empty vec -- not an error -- when nothing matches.
|
||||
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>>;
|
||||
|
||||
/// Fetch metadata for a single item by its opaque ID.
|
||||
///
|
||||
/// Used by the scheduler when resolving `BlockContent::Manual` blocks, where
|
||||
/// the user has hand-picked specific items. Returns `None` if the item no
|
||||
/// longer exists in the provider (deleted, unavailable, etc.).
|
||||
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
|
||||
|
||||
/// Get a playback URL for an item, called on-demand at tune-in time.
|
||||
///
|
||||
/// URLs are intentionally *not* stored in the schedule because they may be
|
||||
/// short-lived (signed URLs, session tokens) or depend on client context.
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String>;
|
||||
|
||||
/// List top-level collections (libraries/sections) available in this provider.
|
||||
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"list_collections is not supported by this provider".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// List TV series available in an optional collection.
|
||||
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
|
||||
let _ = collection_id;
|
||||
Err(DomainError::InfrastructureError(
|
||||
@@ -149,7 +74,6 @@ pub trait IMediaProvider: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// List all genres available for a given content type.
|
||||
async fn list_genres(
|
||||
&self,
|
||||
content_type: Option<&ContentType>,
|
||||
@@ -161,56 +85,36 @@ pub trait IMediaProvider: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IProviderRegistry
|
||||
// ============================================================================
|
||||
|
||||
/// Port for routing media operations across multiple named providers.
|
||||
///
|
||||
/// The registry holds all configured providers (Jellyfin, local files, ...)
|
||||
/// and dispatches each call to the right one. Item IDs are prefixed with the
|
||||
/// provider key (e.g. `"jellyfin::abc123"`, `"local::base64path"`) so every
|
||||
/// fetch and stream call is self-routing.
|
||||
#[async_trait]
|
||||
pub trait IProviderRegistry: Send + Sync {
|
||||
/// Fetch items from a named provider (used by Algorithmic blocks).
|
||||
/// Empty `provider_id` uses the primary provider.
|
||||
async fn fetch_items(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
filter: &MediaFilter,
|
||||
) -> DomainResult<Vec<MediaItem>>;
|
||||
|
||||
/// Fetch a single item by its (possibly prefixed) ID.
|
||||
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>>;
|
||||
|
||||
/// Get a playback URL. Routes via prefix in `item_id`.
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String>;
|
||||
|
||||
/// List all registered provider keys in registration order.
|
||||
fn provider_ids(&self) -> Vec<String>;
|
||||
|
||||
/// Key of the primary (first-registered) provider.
|
||||
fn primary_id(&self) -> &str;
|
||||
|
||||
/// Capability matrix for a specific provider. Returns `None` if the key is unknown.
|
||||
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities>;
|
||||
|
||||
/// List collections for a provider. Empty `provider_id` = primary.
|
||||
async fn list_collections(&self, provider_id: &str) -> DomainResult<Vec<Collection>>;
|
||||
|
||||
/// List series for a provider. Empty `provider_id` = primary.
|
||||
async fn list_series(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
collection_id: Option<&str>,
|
||||
) -> DomainResult<Vec<SeriesSummary>>;
|
||||
|
||||
/// List genres for a provider. Empty `provider_id` = primary.
|
||||
async fn list_genres(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
//! Domain ports (trait definitions).
|
||||
//!
|
||||
//! These traits define the abstract interfaces that infrastructure adapters
|
||||
//! implement. The domain layer depends only on these traits, never on concrete
|
||||
//! implementations.
|
||||
//!
|
||||
//! Repository traits follow a CQRS split: separate Command (write) and Query
|
||||
//! (read) traits for each aggregate. Small or rarely-split repositories keep
|
||||
//! a single trait when the split adds no value.
|
||||
|
||||
pub mod activity;
|
||||
pub mod auth;
|
||||
pub mod channel;
|
||||
@@ -20,8 +10,6 @@ pub mod settings;
|
||||
pub mod transcode;
|
||||
pub mod user;
|
||||
|
||||
// -- Re-exports for convenience --
|
||||
|
||||
pub use activity::{ActivityLogCommand, ActivityLogQuery};
|
||||
pub use auth::AuthService;
|
||||
pub use channel::{ChannelCommand, ChannelQuery};
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
//! Provider configuration port (CQRS split).
|
||||
//!
|
||||
//! Stores the JSON configuration blob for registered media providers
|
||||
//! (e.g. Jellyfin URL + API key).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
use crate::models::ProviderConfigRow;
|
||||
|
||||
/// Write-side port for provider configuration persistence.
|
||||
#[async_trait]
|
||||
pub trait ProviderConfigCommand: Send + Sync {
|
||||
/// Insert or update a provider configuration.
|
||||
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()>;
|
||||
|
||||
/// Delete a provider configuration by ID.
|
||||
async fn delete(&self, id: &str) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
/// Read-side port for provider configuration persistence.
|
||||
#[async_trait]
|
||||
pub trait ProviderConfigQuery: Send + Sync {
|
||||
/// Get all provider configurations.
|
||||
async fn get_all(&self) -> DomainResult<Vec<ProviderConfigRow>>;
|
||||
|
||||
/// Get a provider configuration by ID.
|
||||
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Schedule persistence ports (CQRS split).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -8,19 +6,12 @@ use crate::errors::DomainResult;
|
||||
use crate::models::{GeneratedSchedule, PlaybackRecord};
|
||||
use crate::value_objects::{BlockId, ChannelId, MediaItemId, ScheduleId};
|
||||
|
||||
/// Write-side port for schedule and playback persistence.
|
||||
#[async_trait]
|
||||
pub trait ScheduleCommand: Send + Sync {
|
||||
/// Insert or replace a generated schedule.
|
||||
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()>;
|
||||
|
||||
/// Persist a playback record (item was aired on a channel).
|
||||
async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()>;
|
||||
|
||||
/// Delete all schedules with generation > `target_generation` for this channel.
|
||||
///
|
||||
/// Also deletes matching playback_records (no DB cascade between those tables).
|
||||
/// `scheduled_slots` cascade via FK from `generated_schedules`.
|
||||
async fn delete_schedules_after(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -28,44 +19,34 @@ pub trait ScheduleCommand: Send + Sync {
|
||||
) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
/// Read-side port for schedule and playback persistence.
|
||||
#[async_trait]
|
||||
pub trait ScheduleQuery: Send + Sync {
|
||||
/// Find the schedule whose `[valid_from, valid_until)` window contains `at`.
|
||||
async fn find_active(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
at: DateTime<Utc>,
|
||||
) -> DomainResult<Option<GeneratedSchedule>>;
|
||||
|
||||
/// Find the most recently generated schedule for a channel.
|
||||
/// Used to derive the next generation number.
|
||||
async fn find_latest(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> DomainResult<Option<GeneratedSchedule>>;
|
||||
|
||||
/// All playback records for a channel, used by the recycle policy engine.
|
||||
async fn find_playback_history(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> DomainResult<Vec<PlaybackRecord>>;
|
||||
|
||||
/// Return the most recent slot per block_id across ALL schedules for a channel.
|
||||
///
|
||||
/// Resilient to any single generation having empty slots for a block.
|
||||
async fn find_last_slot_per_block(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> DomainResult<HashMap<BlockId, MediaItemId>>;
|
||||
|
||||
/// List all generated schedule headers for a channel, newest first.
|
||||
async fn list_schedule_history(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> DomainResult<Vec<GeneratedSchedule>>;
|
||||
|
||||
/// Fetch a specific schedule with its slots, verifying channel ownership.
|
||||
async fn get_schedule_by_id(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
//! Application settings port.
|
||||
//!
|
||||
//! Key-value admin configuration (e.g. `library_sync_interval_hours`).
|
||||
//! Small enough to keep as a single trait rather than CQRS split.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
|
||||
/// Port for general admin settings persistence (`app_settings` table).
|
||||
#[async_trait]
|
||||
pub trait AppSettingsRepository: Send + Sync {
|
||||
/// Get a setting value by key. Returns `None` if not set.
|
||||
async fn get(&self, key: &str) -> DomainResult<Option<String>>;
|
||||
|
||||
/// Set a setting value (upsert).
|
||||
async fn set(&self, key: &str, value: &str) -> DomainResult<()>;
|
||||
|
||||
/// Get all settings as (key, value) pairs.
|
||||
async fn get_all(&self) -> DomainResult<Vec<(String, String)>>;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
//! Transcode settings port.
|
||||
//!
|
||||
//! Persists FFmpeg transcoding configuration (cleanup TTL, etc.).
|
||||
//! Small enough to keep as a single trait.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
|
||||
/// Port for transcode settings persistence.
|
||||
#[async_trait]
|
||||
pub trait TranscodeSettingsRepository: Send + Sync {
|
||||
/// Load the persisted cleanup TTL. Returns `None` if no row exists yet.
|
||||
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>>;
|
||||
|
||||
/// Persist the cleanup TTL (upsert -- always row id=1).
|
||||
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
//! User persistence ports (CQRS split).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
use crate::models::User;
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
/// Write-side port for user persistence.
|
||||
#[async_trait]
|
||||
pub trait UserCommand: Send + Sync {
|
||||
/// Insert or update a user.
|
||||
async fn save(&self, user: &User) -> DomainResult<()>;
|
||||
|
||||
/// Delete a user by their internal ID.
|
||||
async fn delete(&self, id: UserId) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
/// Read-side port for user persistence.
|
||||
#[async_trait]
|
||||
pub trait UserQuery: Send + Sync {
|
||||
/// Find a user by their internal ID.
|
||||
async fn find_by_id(&self, id: UserId) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Find a user by their OIDC subject (used for authentication).
|
||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Find a user by their email address.
|
||||
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Count total number of users (used for first-user admin promotion).
|
||||
async fn count_users(&self) -> DomainResult<u64>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
//! IPTV export: M3U playlist and XMLTV guide generation.
|
||||
//!
|
||||
//! Pure functions — no I/O, no dependencies beyond domain types.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::models::{Channel, ScheduledSlot};
|
||||
use crate::value_objects::ChannelId;
|
||||
|
||||
/// Generate an M3U playlist for the given channels.
|
||||
///
|
||||
/// Each entry points to the channel's `/stream` endpoint authenticated with the
|
||||
/// provided JWT token so IPTV clients can load it directly.
|
||||
pub fn generate_m3u(channels: &[Channel], base_url: &str, token: &str) -> String {
|
||||
let mut out = String::from("#EXTM3U\n");
|
||||
for ch in channels {
|
||||
@@ -30,7 +22,6 @@ pub fn generate_m3u(channels: &[Channel], base_url: &str, token: &str) -> String
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate an XMLTV EPG document for the given channels and their scheduled slots.
|
||||
pub fn generate_xmltv(
|
||||
channels: &[Channel],
|
||||
slots_by_channel: &HashMap<ChannelId, Vec<ScheduledSlot>>,
|
||||
@@ -99,71 +90,6 @@ fn escape_xml(s: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::MediaItem;
|
||||
use crate::value_objects::{ContentType, MediaItemId, UserId};
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
fn make_channel(name: &str) -> Channel {
|
||||
Channel::new(UserId::generate(), name, "UTC")
|
||||
}
|
||||
|
||||
fn make_slot(title: &str, start_offset_hours: i64) -> ScheduledSlot {
|
||||
let start = Utc::now() + Duration::hours(start_offset_hours);
|
||||
let item = MediaItem::new(
|
||||
MediaItemId::new(format!("test::{title}")),
|
||||
title,
|
||||
ContentType::Movie,
|
||||
3600,
|
||||
);
|
||||
ScheduledSlot::new(
|
||||
start,
|
||||
start + Duration::hours(1),
|
||||
item,
|
||||
crate::value_objects::BlockId::generate(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3u_contains_all_channels() {
|
||||
let channels = vec![make_channel("Channel 1"), make_channel("Channel 2")];
|
||||
let m3u = generate_m3u(&channels, "http://localhost:3000", "tok123");
|
||||
assert!(m3u.starts_with("#EXTM3U\n"));
|
||||
assert!(m3u.contains("Channel 1"));
|
||||
assert!(m3u.contains("Channel 2"));
|
||||
assert!(m3u.contains("token=tok123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmltv_structure() {
|
||||
let channels = vec![make_channel("Test TV")];
|
||||
let ch_id = channels[0].id();
|
||||
let mut slots_map = HashMap::new();
|
||||
slots_map.insert(ch_id, vec![make_slot("Movie Night", 0)]);
|
||||
|
||||
let xml = generate_xmltv(&channels, &slots_map);
|
||||
assert!(xml.contains("<tv generator-info-name=\"k-tv\">"));
|
||||
assert!(xml.contains("Test TV"));
|
||||
assert!(xml.contains("Movie Night"));
|
||||
assert!(xml.contains("</tv>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmltv_escapes_special_chars() {
|
||||
let channels = vec![make_channel("A&B <Channel>")];
|
||||
let xml = generate_xmltv(&channels, &HashMap::new());
|
||||
assert!(xml.contains("A&B <Channel>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3u_empty_channels() {
|
||||
let m3u = generate_m3u(&[], "http://localhost", "tok");
|
||||
assert_eq!(m3u, "#EXTM3U\n");
|
||||
}
|
||||
}
|
||||
#[path = "tests/iptv.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//! Domain services — pure business logic with no I/O side effects.
|
||||
//!
|
||||
//! The scheduling engine and IPTV export functions live here. Application-level
|
||||
//! orchestration (user/channel CRUD, auth flows) belongs in the `application`
|
||||
//! crate's use cases, not here.
|
||||
|
||||
pub mod iptv;
|
||||
pub mod schedule;
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! Fill strategies for scheduling engine block resolution.
|
||||
//!
|
||||
//! Pure functions — no I/O, no async, no side effects.
|
||||
//! Each strategy selects items from a pool to fill a target time budget.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
@@ -12,9 +7,6 @@ use rand::SeedableRng;
|
||||
use crate::models::MediaItem;
|
||||
use crate::value_objects::{FillStrategy, MediaItemId};
|
||||
|
||||
/// Select items from `pool` (recycled-filtered) to fill `target_secs`, using
|
||||
/// `strategy`. `candidates` (unfiltered) is only needed by `Sequential` for
|
||||
/// ordering; `last_item_id` drives series continuity.
|
||||
pub(super) fn fill_block<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
@@ -45,8 +37,6 @@ pub(super) fn fill_block<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy bin-packing: at each step pick the longest item that still fits
|
||||
/// in the remaining budget, without repeating items within the same block.
|
||||
pub(super) fn fill_best_fit(pool: &[MediaItem], target_secs: u32) -> Vec<&MediaItem> {
|
||||
let mut remaining = target_secs;
|
||||
let mut selected: Vec<&MediaItem> = Vec::new();
|
||||
@@ -72,24 +62,6 @@ pub(super) fn fill_best_fit(pool: &[MediaItem], target_secs: u32) -> Vec<&MediaI
|
||||
selected
|
||||
}
|
||||
|
||||
/// Sequential fill with cross-generation series continuity.
|
||||
///
|
||||
/// `candidates` — all items matching the filter, in Jellyfin's natural order
|
||||
/// (typically by season + episode number for TV shows).
|
||||
/// `pool` — candidates filtered by the recycle policy (eligible to air).
|
||||
/// `last_item_id` — the last item scheduled in this block in the previous
|
||||
/// generation or in an earlier occurrence of this block within
|
||||
/// the current generation. Used to resume the series from the
|
||||
/// next episode rather than restarting from episode 1.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Find `last_item_id`'s position in `candidates` and start from the next index.
|
||||
/// 2. Walk the full `candidates` list in order (wrapping around at the end),
|
||||
/// but only pick items that are in `pool` (i.e. not on cooldown).
|
||||
/// 3. Greedily fill the time budget with items in that order.
|
||||
///
|
||||
/// This ensures episodes always air in series order, the series wraps correctly
|
||||
/// when the last episode has been reached, and cooldowns are still respected.
|
||||
pub(super) fn fill_sequential<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
@@ -101,12 +73,9 @@ pub(super) fn fill_sequential<'a>(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Set of item IDs currently eligible to air.
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| i.id()).collect();
|
||||
|
||||
let ordered: Vec<&MediaItem> = if loop_on_finish {
|
||||
// Find where in the full ordered list to resume, wrapping around.
|
||||
// Falls back to index 0 if last_item_id is absent or was removed from the library.
|
||||
let start_idx = last_item_id
|
||||
.and_then(|id| candidates.iter().position(|c| c.id() == id))
|
||||
.map(|pos| (pos + 1) % candidates.len())
|
||||
@@ -117,15 +86,13 @@ pub(super) fn fill_sequential<'a>(
|
||||
.filter(|item| available.contains(item.id()))
|
||||
.collect()
|
||||
} else {
|
||||
// No wrap: compute raw next position without modulo.
|
||||
// If the series has finished (next_pos >= len), return dead air.
|
||||
let next_pos = last_item_id
|
||||
.and_then(|id| candidates.iter().position(|c| c.id() == id))
|
||||
.map(|pos| pos + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
if next_pos >= candidates.len() {
|
||||
return vec![]; // series finished — dead air
|
||||
return vec![];
|
||||
}
|
||||
|
||||
candidates[next_pos..]
|
||||
@@ -134,8 +101,6 @@ pub(super) fn fill_sequential<'a>(
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Greedily fill the block's time budget in episode order.
|
||||
// Stop at the first episode that doesn't fit — skipping would break ordering.
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
for item in &ordered {
|
||||
@@ -146,89 +111,13 @@ pub(super) fn fill_sequential<'a>(
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Edge case: if the very first episode is longer than the entire block,
|
||||
// still include it — the slot builder clips it to block end via .min(end).
|
||||
if result.is_empty() {
|
||||
if let Some(&first) = ordered.first() {
|
||||
result.push(first);
|
||||
}
|
||||
// Include oversize first episode — slot builder clips to block end
|
||||
if result.is_empty() && let Some(&first) = ordered.first() {
|
||||
result.push(first);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value_objects::ContentType;
|
||||
|
||||
fn item(id: &str, secs: u32) -> MediaItem {
|
||||
MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_fit_picks_longest_first() {
|
||||
let pool = vec![item("a", 100), item("b", 200), item("c", 150)];
|
||||
let result = fill_best_fit(&pool, 350);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].id().value(), "b"); // 200 first
|
||||
assert_eq!(result[1].id().value(), "c"); // 150 next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_fit_no_repeats() {
|
||||
let pool = vec![item("a", 100)];
|
||||
let result = fill_best_fit(&pool, 300);
|
||||
assert_eq!(result.len(), 1); // only one item, can't repeat
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_resumes_from_last() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
|
||||
let pool = candidates.clone();
|
||||
let last = MediaItemId::new("ep1");
|
||||
let result = fill_sequential(&candidates, &pool, 120, Some(&last), true);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].id().value(), "ep2");
|
||||
assert_eq!(result[1].id().value(), "ep3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_wraps_when_loop_on_finish() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
|
||||
let pool = candidates.clone();
|
||||
let last = MediaItemId::new("ep3");
|
||||
let result = fill_sequential(&candidates, &pool, 180, Some(&last), true);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].id().value(), "ep1"); // wrapped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_dead_air_when_no_loop() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60)];
|
||||
let pool = candidates.clone();
|
||||
let last = MediaItemId::new("ep2");
|
||||
let result = fill_sequential(&candidates, &pool, 120, Some(&last), false);
|
||||
assert!(result.is_empty()); // series finished
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_includes_oversize_first_episode() {
|
||||
let candidates = vec![item("ep1", 9999)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_sequential(&candidates, &pool, 60, None, true);
|
||||
assert_eq!(result.len(), 1); // included despite being too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_fill_respects_budget() {
|
||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||
let candidates = pool.clone();
|
||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Random, None, true);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
}
|
||||
#[path = "tests/fill.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
//! Core scheduling engine.
|
||||
//!
|
||||
//! Generates 7-day broadcast schedules by walking through a channel's
|
||||
//! `ScheduleConfig` day by day, resolving each `ProgrammingBlock` into concrete
|
||||
//! `ScheduledSlot`s via the `IProviderRegistry`, and applying the `RecyclePolicy`
|
||||
//! to avoid replaying recently aired items.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, TimeZone, Utc};
|
||||
@@ -21,6 +14,20 @@ use crate::value_objects::{BlockId, ChannelId, FillStrategy, MediaFilter, MediaI
|
||||
mod fill;
|
||||
mod recycle;
|
||||
|
||||
const SCHEDULE_DURATION_DAYS: i64 = 7;
|
||||
|
||||
struct BlockTimeWindow {
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
}
|
||||
|
||||
struct RecycleContext<'a> {
|
||||
history: &'a [PlaybackRecord],
|
||||
policy: &'a RecyclePolicy,
|
||||
generation: u32,
|
||||
last_item_id: Option<&'a MediaItemId>,
|
||||
}
|
||||
|
||||
pub struct ScheduleEngineService {
|
||||
provider_registry: Arc<dyn IProviderRegistry>,
|
||||
channel_query: Arc<dyn ChannelQuery>,
|
||||
@@ -43,22 +50,6 @@ impl ScheduleEngineService {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Generate and persist a 7-day schedule for `channel_id` starting at `from`.
|
||||
///
|
||||
/// The algorithm:
|
||||
/// 1. Walk each calendar day in the 7-day window.
|
||||
/// 2. For each `ProgrammingBlock`, compute its UTC wall-clock interval for that day.
|
||||
/// 3. Clip the interval to `[from, from + 7d)`.
|
||||
/// 4. Resolve the block content via the media provider, applying the recycle policy.
|
||||
/// 5. For `Sequential` blocks, resume from where the previous generation left off
|
||||
/// (series continuity — see `fill::fill_sequential`).
|
||||
/// 6. Record every played item in the playback history.
|
||||
///
|
||||
/// Gaps between blocks are left empty — clients render them as a no-signal state.
|
||||
pub async fn generate_schedule(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -80,9 +71,6 @@ impl ScheduleEngineService {
|
||||
.find_playback_history(channel_id)
|
||||
.await?;
|
||||
|
||||
// Load the most recent schedule for two purposes:
|
||||
// 1. Derive the next generation number.
|
||||
// 2. Know where each Sequential block left off (series continuity).
|
||||
let latest_schedule = self.schedule_query.find_latest(channel_id).await?;
|
||||
|
||||
let generation = latest_schedule
|
||||
@@ -90,18 +78,13 @@ impl ScheduleEngineService {
|
||||
.map(|s| s.generation() + 1)
|
||||
.unwrap_or(1);
|
||||
|
||||
// Build the initial per-block continuity map from the most recent slot per
|
||||
// block across ALL schedules. This is resilient to any single generation
|
||||
// having empty slots for a block (e.g. provider returned nothing transiently).
|
||||
// The map is updated as each block occurrence is resolved within this
|
||||
// generation so the second day of a 48h schedule continues from here.
|
||||
let mut block_continuity = self
|
||||
.schedule_query
|
||||
.find_last_slot_per_block(channel_id)
|
||||
.await?;
|
||||
|
||||
let valid_from = from;
|
||||
let valid_until = from + Duration::days(7);
|
||||
let valid_until = from + Duration::days(SCHEDULE_DURATION_DAYS);
|
||||
|
||||
let start_date = from.with_timezone(&tz).date_naive();
|
||||
let end_date = valid_until.with_timezone(&tz).date_naive();
|
||||
@@ -114,8 +97,7 @@ impl ScheduleEngineService {
|
||||
for block in channel.schedule_config().blocks_for(weekday) {
|
||||
let naive_start = current_date.and_time(block.start_time());
|
||||
|
||||
// `earliest()` handles DST gaps — if the local time doesn't exist
|
||||
// (e.g. clocks spring forward) we skip this block occurrence.
|
||||
// earliest() picks first valid mapping, skipping DST gaps
|
||||
let block_start_utc = match tz.from_local_datetime(&naive_start).earliest() {
|
||||
Some(dt) => dt.with_timezone(&Utc),
|
||||
None => continue,
|
||||
@@ -124,7 +106,6 @@ impl ScheduleEngineService {
|
||||
let block_end_utc =
|
||||
block_start_utc + Duration::minutes(block.duration_mins() as i64);
|
||||
|
||||
// Clip to the 7-day window.
|
||||
let slot_start = block_start_utc.max(valid_from);
|
||||
let slot_end = block_end_utc.min(valid_until);
|
||||
|
||||
@@ -132,23 +113,24 @@ impl ScheduleEngineService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For Sequential blocks: resume from the last item aired in this block.
|
||||
let last_item_id = block_continuity.get(&block.id());
|
||||
|
||||
let mut block_slots = self
|
||||
.resolve_block(
|
||||
block,
|
||||
slot_start,
|
||||
slot_end,
|
||||
&history,
|
||||
channel.recycle_policy(),
|
||||
generation,
|
||||
last_item_id,
|
||||
BlockTimeWindow {
|
||||
start: slot_start,
|
||||
end: slot_end,
|
||||
},
|
||||
RecycleContext {
|
||||
history: &history,
|
||||
policy: channel.recycle_policy(),
|
||||
generation,
|
||||
last_item_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update continuity so the next occurrence of this block (same
|
||||
// generation, next calendar day) continues from here.
|
||||
if let Some(last_slot) = block_slots.last() {
|
||||
block_continuity.insert(block.id(), last_slot.item().id().clone());
|
||||
}
|
||||
@@ -161,7 +143,6 @@ impl ScheduleEngineService {
|
||||
})?;
|
||||
}
|
||||
|
||||
// Blocks in ScheduleConfig are not required to be sorted; sort resolved slots.
|
||||
slots.sort_by_key(|s| s.start_at());
|
||||
|
||||
let schedule = GeneratedSchedule::new(
|
||||
@@ -174,7 +155,6 @@ impl ScheduleEngineService {
|
||||
|
||||
self.schedule_command.save(&schedule).await?;
|
||||
|
||||
// Persist playback history so the recycle policy has data for next generation.
|
||||
for slot in schedule.slots() {
|
||||
let record =
|
||||
PlaybackRecord::new(channel_id, slot.item().id().clone(), generation);
|
||||
@@ -184,10 +164,6 @@ impl ScheduleEngineService {
|
||||
Ok(schedule)
|
||||
}
|
||||
|
||||
/// Determine what is currently broadcasting on a schedule.
|
||||
///
|
||||
/// Returns `None` when `now` falls in a gap between blocks — the client
|
||||
/// should display a no-signal / static screen in that case.
|
||||
pub fn get_current_broadcast(
|
||||
schedule: &GeneratedSchedule,
|
||||
now: DateTime<Utc>,
|
||||
@@ -204,7 +180,6 @@ impl ScheduleEngineService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the most recently generated schedule for a channel (used by the background scheduler).
|
||||
pub async fn get_latest_schedule(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -212,7 +187,6 @@ impl ScheduleEngineService {
|
||||
self.schedule_query.find_latest(channel_id).await
|
||||
}
|
||||
|
||||
/// Look up the schedule currently active at `at` without generating a new one.
|
||||
pub async fn get_active_schedule(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -221,7 +195,6 @@ impl ScheduleEngineService {
|
||||
self.schedule_query.find_active(channel_id, at).await
|
||||
}
|
||||
|
||||
/// Delegate stream URL resolution to the provider registry (routes via ID prefix).
|
||||
pub async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
@@ -230,7 +203,6 @@ impl ScheduleEngineService {
|
||||
self.provider_registry.get_stream_url(item_id, quality).await
|
||||
}
|
||||
|
||||
/// List all generated schedule headers for a channel, newest first.
|
||||
pub async fn list_schedule_history(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -238,7 +210,6 @@ impl ScheduleEngineService {
|
||||
self.schedule_query.list_schedule_history(channel_id).await
|
||||
}
|
||||
|
||||
/// Fetch a specific schedule with its slots.
|
||||
pub async fn get_schedule_by_id(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -249,7 +220,6 @@ impl ScheduleEngineService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete all schedules with generation > target_generation for this channel.
|
||||
pub async fn delete_schedules_after(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -260,7 +230,6 @@ impl ScheduleEngineService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Return all slots that overlap the given time window — the EPG data.
|
||||
pub fn get_epg(
|
||||
schedule: &GeneratedSchedule,
|
||||
from: DateTime<Utc>,
|
||||
@@ -273,24 +242,16 @@ impl ScheduleEngineService {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Block resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn resolve_block(
|
||||
&self,
|
||||
block: &ProgrammingBlock,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
history: &[PlaybackRecord],
|
||||
policy: &RecyclePolicy,
|
||||
generation: u32,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
window: BlockTimeWindow,
|
||||
recycle: RecycleContext<'_>,
|
||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||
match block.content() {
|
||||
BlockContent::Manual { items, .. } => {
|
||||
self.resolve_manual(items, start, end, block.id()).await
|
||||
self.resolve_manual(items, window.start, window.end, block.id())
|
||||
.await
|
||||
}
|
||||
BlockContent::Algorithmic {
|
||||
filter,
|
||||
@@ -301,13 +262,9 @@ impl ScheduleEngineService {
|
||||
provider_id,
|
||||
filter,
|
||||
strategy,
|
||||
start,
|
||||
end,
|
||||
history,
|
||||
policy,
|
||||
generation,
|
||||
window,
|
||||
recycle,
|
||||
block.id(),
|
||||
last_item_id,
|
||||
block.loop_on_finish(),
|
||||
block.ignore_recycle_policy(),
|
||||
)
|
||||
@@ -316,8 +273,6 @@ impl ScheduleEngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a manual block by fetching each hand-picked item in order.
|
||||
/// Stops when the block's time budget (`end`) is exhausted.
|
||||
async fn resolve_manual(
|
||||
&self,
|
||||
item_ids: &[MediaItemId],
|
||||
@@ -338,36 +293,22 @@ impl ScheduleEngineService {
|
||||
slots.push(ScheduledSlot::new(cursor, item_end, item, block_id));
|
||||
cursor = item_end;
|
||||
}
|
||||
// If item is not found (deleted/unavailable), silently skip it.
|
||||
}
|
||||
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
/// Resolve an algorithmic block: fetch candidates, apply recycle policy,
|
||||
/// run the fill strategy, and build slots.
|
||||
///
|
||||
/// `last_item_id` is the ID of the last item scheduled in this block in the
|
||||
/// previous generation. Used only by `Sequential` for series continuity.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn resolve_algorithmic(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
filter: &MediaFilter,
|
||||
strategy: &FillStrategy,
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
history: &[PlaybackRecord],
|
||||
policy: &RecyclePolicy,
|
||||
generation: u32,
|
||||
window: BlockTimeWindow,
|
||||
recycle: RecycleContext<'_>,
|
||||
block_id: BlockId,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
loop_on_finish: bool,
|
||||
ignore_recycle_policy: bool,
|
||||
) -> DomainResult<Vec<ScheduledSlot>> {
|
||||
// `candidates` — all items matching the filter, in provider order.
|
||||
// Kept separate from `pool` so Sequential can rotate through the full
|
||||
// ordered list while still honouring cooldowns.
|
||||
let candidates = self
|
||||
.provider_registry
|
||||
.fetch_items(provider_id, filter)
|
||||
@@ -380,27 +321,27 @@ impl ScheduleEngineService {
|
||||
let pool = if ignore_recycle_policy {
|
||||
candidates.clone()
|
||||
} else {
|
||||
recycle::apply_recycle_policy(&candidates, history, policy, generation)
|
||||
recycle::apply_recycle_policy(&candidates, recycle.history, recycle.policy, recycle.generation)
|
||||
};
|
||||
let target_secs = (end - start).num_seconds() as u32;
|
||||
let target_secs = (window.end - window.start).num_seconds() as u32;
|
||||
let selected = fill::fill_block(
|
||||
&candidates,
|
||||
&pool,
|
||||
target_secs,
|
||||
strategy,
|
||||
last_item_id,
|
||||
recycle.last_item_id,
|
||||
loop_on_finish,
|
||||
);
|
||||
|
||||
let mut slots = Vec::new();
|
||||
let mut cursor = start;
|
||||
let mut cursor = window.start;
|
||||
|
||||
for item in selected {
|
||||
if cursor >= end {
|
||||
if cursor >= window.end {
|
||||
break;
|
||||
}
|
||||
let item_end =
|
||||
(cursor + Duration::seconds(item.duration_secs() as i64)).min(end);
|
||||
(cursor + Duration::seconds(item.duration_secs() as i64)).min(window.end);
|
||||
slots.push(ScheduledSlot::new(cursor, item_end, item.clone(), block_id));
|
||||
cursor = item_end;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! Recycle policy engine for the scheduling engine.
|
||||
//!
|
||||
//! Pure function — no I/O, no async, no side effects.
|
||||
//! Filters a candidate pool according to the channel's `RecyclePolicy`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -10,12 +5,6 @@ use chrono::Utc;
|
||||
use crate::models::{MediaItem, PlaybackRecord};
|
||||
use crate::value_objects::{MediaItemId, RecyclePolicy};
|
||||
|
||||
/// Filter `candidates` according to `policy`, returning the eligible pool.
|
||||
///
|
||||
/// An item is on cooldown if *either* the day-based or generation-based
|
||||
/// threshold is exceeded. If honouring all cooldowns would leave fewer items
|
||||
/// than `policy.min_available_ratio` of the total, all cooldowns are waived
|
||||
/// and the full pool is returned (prevents small libraries from stalling).
|
||||
pub(super) fn apply_recycle_policy(
|
||||
candidates: &[MediaItem],
|
||||
history: &[PlaybackRecord],
|
||||
@@ -52,67 +41,13 @@ pub(super) fn apply_recycle_policy(
|
||||
(candidates.len() as f32 * policy.min_available_ratio).ceil() as usize;
|
||||
|
||||
if available.len() < min_count {
|
||||
// Pool too small after applying cooldowns — recycle everything.
|
||||
// Pool too small after cooldowns — recycle everything
|
||||
candidates.to_vec()
|
||||
} else {
|
||||
available
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value_objects::{ChannelId, ContentType};
|
||||
|
||||
fn item(id: &str) -> MediaItem {
|
||||
MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, 3600)
|
||||
}
|
||||
|
||||
fn record(item_id: &str, generation: u32) -> PlaybackRecord {
|
||||
PlaybackRecord::new(ChannelId::generate(), MediaItemId::new(item_id), generation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_history_returns_all() {
|
||||
let pool = vec![item("a"), item("b"), item("c")];
|
||||
let policy = RecyclePolicy {
|
||||
cooldown_days: Some(7),
|
||||
cooldown_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &[], &policy, 1);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_cooldown_excludes() {
|
||||
let pool = vec![item("a"), item("b"), item("c")];
|
||||
let history = vec![record("a", 1)];
|
||||
let policy = RecyclePolicy {
|
||||
cooldown_days: None,
|
||||
cooldown_generations: Some(2),
|
||||
min_available_ratio: 0.0,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &history, &policy, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|i| i.id().value() != "a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_available_ratio_waives_cooldown() {
|
||||
let pool = vec![item("a"), item("b")];
|
||||
let history = vec![record("a", 1), record("b", 1)];
|
||||
let policy = RecyclePolicy {
|
||||
cooldown_days: None,
|
||||
cooldown_generations: Some(5),
|
||||
min_available_ratio: 0.5, // needs at least 1 item
|
||||
};
|
||||
// Both on cooldown, but ratio requires >= 1 item => waive all
|
||||
let result = apply_recycle_policy(&pool, &history, &policy, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
}
|
||||
#[path = "tests/recycle.rs"]
|
||||
mod tests;
|
||||
|
||||
70
crates/domain/src/services/schedule/tests/fill.rs
Normal file
70
crates/domain/src/services/schedule/tests/fill.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use super::*;
|
||||
|
||||
use crate::value_objects::ContentType;
|
||||
|
||||
fn item(id: &str, secs: u32) -> MediaItem {
|
||||
MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, secs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_fit_picks_longest_first() {
|
||||
let pool = vec![item("a", 100), item("b", 200), item("c", 150)];
|
||||
let result = fill_best_fit(&pool, 350);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].id().value(), "b");
|
||||
assert_eq!(result[1].id().value(), "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_fit_no_repeats() {
|
||||
let pool = vec![item("a", 100)];
|
||||
let result = fill_best_fit(&pool, 300);
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_resumes_from_last() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
|
||||
let pool = candidates.clone();
|
||||
let last = MediaItemId::new("ep1");
|
||||
let result = fill_sequential(&candidates, &pool, 120, Some(&last), true);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].id().value(), "ep2");
|
||||
assert_eq!(result[1].id().value(), "ep3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_wraps_when_loop_on_finish() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60), item("ep3", 60)];
|
||||
let pool = candidates.clone();
|
||||
let last = MediaItemId::new("ep3");
|
||||
let result = fill_sequential(&candidates, &pool, 180, Some(&last), true);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].id().value(), "ep1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_dead_air_when_no_loop() {
|
||||
let candidates = vec![item("ep1", 60), item("ep2", 60)];
|
||||
let pool = candidates.clone();
|
||||
let last = MediaItemId::new("ep2");
|
||||
let result = fill_sequential(&candidates, &pool, 120, Some(&last), false);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_includes_oversize_first_episode() {
|
||||
let candidates = vec![item("ep1", 9999)];
|
||||
let pool = candidates.clone();
|
||||
let result = fill_sequential(&candidates, &pool, 60, None, true);
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_fill_respects_budget() {
|
||||
let pool = vec![item("a", 100), item("b", 100), item("c", 100)];
|
||||
let candidates = pool.clone();
|
||||
let result = fill_block(&candidates, &pool, 200, &FillStrategy::Random, None, true);
|
||||
let total: u32 = result.iter().map(|i| i.duration_secs()).sum();
|
||||
assert!(total <= 200);
|
||||
}
|
||||
50
crates/domain/src/services/schedule/tests/recycle.rs
Normal file
50
crates/domain/src/services/schedule/tests/recycle.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use super::*;
|
||||
|
||||
use crate::value_objects::{ChannelId, ContentType};
|
||||
|
||||
fn item(id: &str) -> MediaItem {
|
||||
MediaItem::new(MediaItemId::new(id), id, ContentType::Movie, 3600)
|
||||
}
|
||||
|
||||
fn record(item_id: &str, generation: u32) -> PlaybackRecord {
|
||||
PlaybackRecord::new(ChannelId::generate(), MediaItemId::new(item_id), generation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_history_returns_all() {
|
||||
let pool = vec![item("a"), item("b"), item("c")];
|
||||
let policy = RecyclePolicy {
|
||||
cooldown_days: Some(7),
|
||||
cooldown_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &[], &policy, 1);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_cooldown_excludes() {
|
||||
let pool = vec![item("a"), item("b"), item("c")];
|
||||
let history = vec![record("a", 1)];
|
||||
let policy = RecyclePolicy {
|
||||
cooldown_days: None,
|
||||
cooldown_generations: Some(2),
|
||||
min_available_ratio: 0.0,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &history, &policy, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|i| i.id().value() != "a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_available_ratio_waives_cooldown() {
|
||||
let pool = vec![item("a"), item("b")];
|
||||
let history = vec![record("a", 1), record("b", 1)];
|
||||
let policy = RecyclePolicy {
|
||||
cooldown_days: None,
|
||||
cooldown_generations: Some(5),
|
||||
min_available_ratio: 0.5,
|
||||
};
|
||||
let result = apply_recycle_policy(&pool, &history, &policy, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
62
crates/domain/src/services/tests/iptv.rs
Normal file
62
crates/domain/src/services/tests/iptv.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use super::*;
|
||||
|
||||
use crate::models::MediaItem;
|
||||
use crate::value_objects::{ContentType, MediaItemId, UserId};
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
fn make_channel(name: &str) -> Channel {
|
||||
Channel::new(UserId::generate(), name, "UTC")
|
||||
}
|
||||
|
||||
fn make_slot(title: &str, start_offset_hours: i64) -> ScheduledSlot {
|
||||
let start = Utc::now() + Duration::hours(start_offset_hours);
|
||||
let item = MediaItem::new(
|
||||
MediaItemId::new(format!("test::{title}")),
|
||||
title,
|
||||
ContentType::Movie,
|
||||
3600,
|
||||
);
|
||||
ScheduledSlot::new(
|
||||
start,
|
||||
start + Duration::hours(1),
|
||||
item,
|
||||
crate::value_objects::BlockId::generate(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3u_contains_all_channels() {
|
||||
let channels = vec![make_channel("Channel 1"), make_channel("Channel 2")];
|
||||
let m3u = generate_m3u(&channels, "http://localhost:3000", "tok123");
|
||||
assert!(m3u.starts_with("#EXTM3U\n"));
|
||||
assert!(m3u.contains("Channel 1"));
|
||||
assert!(m3u.contains("Channel 2"));
|
||||
assert!(m3u.contains("token=tok123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmltv_structure() {
|
||||
let channels = vec![make_channel("Test TV")];
|
||||
let ch_id = channels[0].id();
|
||||
let mut slots_map = HashMap::new();
|
||||
slots_map.insert(ch_id, vec![make_slot("Movie Night", 0)]);
|
||||
|
||||
let xml = generate_xmltv(&channels, &slots_map);
|
||||
assert!(xml.contains("<tv generator-info-name=\"k-tv\">"));
|
||||
assert!(xml.contains("Test TV"));
|
||||
assert!(xml.contains("Movie Night"));
|
||||
assert!(xml.contains("</tv>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmltv_escapes_special_chars() {
|
||||
let channels = vec![make_channel("A&B <Channel>")];
|
||||
let xml = generate_xmltv(&channels, &HashMap::new());
|
||||
assert!(xml.contains("A&B <Channel>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3u_empty_channels() {
|
||||
let m3u = generate_m3u(&[], "http://localhost", "tok");
|
||||
assert_eq!(m3u, "#EXTM3U\n");
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
//! InMemory implementations of all domain port traits.
|
||||
//!
|
||||
//! Each struct uses `Mutex<HashMap<Id, Entity>>` internally. One struct
|
||||
//! implements both the Command and Query traits for a given aggregate.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -25,10 +21,6 @@ use crate::value_objects::{
|
||||
BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, UserId,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryUserRepository
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryUserRepository {
|
||||
pub store: Mutex<HashMap<Uuid, crate::models::User>>,
|
||||
}
|
||||
@@ -41,6 +33,12 @@ impl InMemoryUserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryUserRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserCommand for InMemoryUserRepository {
|
||||
async fn save(&self, user: &crate::models::User) -> DomainResult<()> {
|
||||
@@ -81,10 +79,6 @@ impl UserQuery for InMemoryUserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryChannelRepository
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryChannelRepository {
|
||||
pub channels: Mutex<HashMap<Uuid, Channel>>,
|
||||
pub snapshots: Mutex<Vec<ChannelConfigSnapshot>>,
|
||||
@@ -99,6 +93,12 @@ impl InMemoryChannelRepository {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryChannelRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelCommand for InMemoryChannelRepository {
|
||||
async fn save(&self, channel: &Channel) -> DomainResult<()> {
|
||||
@@ -211,7 +211,7 @@ impl ChannelQuery for InMemoryChannelRepository {
|
||||
.filter(|s| s.channel_id() == channel_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
result.sort_by(|a, b| b.version_num().cmp(&a.version_num()));
|
||||
result.sort_by_key(|s| Reverse(s.version_num()));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -228,10 +228,6 @@ impl ChannelQuery for InMemoryChannelRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryScheduleRepository
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryScheduleRepository {
|
||||
pub schedules: Mutex<HashMap<Uuid, GeneratedSchedule>>,
|
||||
pub playback_records: Mutex<Vec<PlaybackRecord>>,
|
||||
@@ -246,6 +242,12 @@ impl InMemoryScheduleRepository {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryScheduleRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScheduleCommand for InMemoryScheduleRepository {
|
||||
async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> {
|
||||
@@ -325,7 +327,7 @@ impl ScheduleQuery for InMemoryScheduleRepository {
|
||||
let block_id = slot.source_block_id();
|
||||
let should_insert = result
|
||||
.get(&block_id)
|
||||
.map_or(true, |(prev_time, _)| slot.start_at() > *prev_time);
|
||||
.is_none_or(|(prev_time, _)| slot.start_at() > *prev_time);
|
||||
if should_insert {
|
||||
result.insert(block_id, (slot.start_at(), slot.item().id().clone()));
|
||||
}
|
||||
@@ -347,7 +349,7 @@ impl ScheduleQuery for InMemoryScheduleRepository {
|
||||
.filter(|s| s.channel_id() == channel_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
result.sort_by(|a, b| b.generation().cmp(&a.generation()));
|
||||
result.sort_by_key(|s| Reverse(s.generation()));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -364,10 +366,6 @@ impl ScheduleQuery for InMemoryScheduleRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryLibraryRepository
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryLibraryRepository {
|
||||
pub items: Mutex<HashMap<String, LibraryItem>>,
|
||||
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
|
||||
@@ -384,6 +382,12 @@ impl InMemoryLibraryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryLibraryRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LibraryCommand for InMemoryLibraryRepository {
|
||||
async fn upsert_items(&self, _provider_id: &str, items: Vec<LibraryItem>) -> DomainResult<()> {
|
||||
@@ -444,20 +448,14 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
let mut items: Vec<_> = store
|
||||
.values()
|
||||
.filter(|item| {
|
||||
if let Some(pid) = filter.provider_id() {
|
||||
if item.provider_id() != pid {
|
||||
return false;
|
||||
}
|
||||
if let Some(pid) = filter.provider_id() && item.provider_id() != pid {
|
||||
return false;
|
||||
}
|
||||
if let Some(ct) = filter.content_type() {
|
||||
if item.content_type() != ct {
|
||||
return false;
|
||||
}
|
||||
if let Some(ct) = filter.content_type() && item.content_type() != ct {
|
||||
return false;
|
||||
}
|
||||
if let Some(term) = filter.search_term() {
|
||||
if !item.title().to_lowercase().contains(&term.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(term) = filter.search_term() && !item.title().to_lowercase().contains(&term.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
if !filter.genres().is_empty()
|
||||
&& !filter
|
||||
@@ -489,10 +487,8 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
let store = self.items.lock().unwrap();
|
||||
let mut seen = HashMap::new();
|
||||
for item in store.values() {
|
||||
if let Some(pid) = provider_id {
|
||||
if item.provider_id() != pid {
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = provider_id && item.provider_id() != pid {
|
||||
continue;
|
||||
}
|
||||
if let (Some(cid), Some(cname)) = (item.collection_id(), item.collection_name()) {
|
||||
seen.entry(cid.to_string())
|
||||
@@ -572,16 +568,12 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
let store = self.items.lock().unwrap();
|
||||
let mut shows: HashMap<String, (u32, std::collections::HashSet<u32>)> = HashMap::new();
|
||||
for item in store.values() {
|
||||
if let Some(pid) = provider_id {
|
||||
if item.provider_id() != pid {
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = provider_id && item.provider_id() != pid {
|
||||
continue;
|
||||
}
|
||||
if let Some(series) = item.series_name() {
|
||||
if let Some(term) = search_term {
|
||||
if !series.to_lowercase().contains(&term.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(term) = search_term && !series.to_lowercase().contains(&term.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let entry = shows
|
||||
.entry(series.to_string())
|
||||
@@ -608,15 +600,13 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
let store = self.items.lock().unwrap();
|
||||
let mut seasons: HashMap<u32, u32> = HashMap::new();
|
||||
for item in store.values() {
|
||||
if let Some(pid) = provider_id {
|
||||
if item.provider_id() != pid {
|
||||
continue;
|
||||
}
|
||||
if let Some(pid) = provider_id && item.provider_id() != pid {
|
||||
continue;
|
||||
}
|
||||
if item.series_name() == Some(series_name) {
|
||||
if let Some(sn) = item.season_number() {
|
||||
*seasons.entry(sn).or_insert(0) += 1;
|
||||
}
|
||||
if item.series_name() == Some(series_name)
|
||||
&& let Some(sn) = item.season_number()
|
||||
{
|
||||
*seasons.entry(sn).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut result: Vec<_> = seasons
|
||||
@@ -628,10 +618,6 @@ impl LibraryQuery for InMemoryLibraryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryActivityLog
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryActivityLog {
|
||||
pub events: Mutex<Vec<ActivityEvent>>,
|
||||
}
|
||||
@@ -644,6 +630,12 @@ impl InMemoryActivityLog {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryActivityLog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityLogCommand for InMemoryActivityLog {
|
||||
async fn log(
|
||||
@@ -667,15 +659,11 @@ impl ActivityLogQuery for InMemoryActivityLog {
|
||||
async fn recent(&self, limit: u32) -> DomainResult<Vec<ActivityEvent>> {
|
||||
let events = self.events.lock().unwrap();
|
||||
let mut sorted: Vec<_> = events.clone();
|
||||
sorted.sort_by(|a, b| b.timestamp().cmp(&a.timestamp()));
|
||||
sorted.sort_by_key(|s| Reverse(s.timestamp()));
|
||||
Ok(sorted.into_iter().take(limit as usize).collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryAppSettings
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryAppSettings {
|
||||
pub settings: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
@@ -688,6 +676,12 @@ impl InMemoryAppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryAppSettings {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppSettingsRepository for InMemoryAppSettings {
|
||||
async fn get(&self, key: &str) -> DomainResult<Option<String>> {
|
||||
@@ -713,10 +707,6 @@ impl AppSettingsRepository for InMemoryAppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryProviderConfig
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryProviderConfig {
|
||||
pub configs: Mutex<HashMap<String, ProviderConfigRow>>,
|
||||
}
|
||||
@@ -729,6 +719,12 @@ impl InMemoryProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderConfigCommand for InMemoryProviderConfig {
|
||||
async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> {
|
||||
@@ -756,10 +752,6 @@ impl ProviderConfigQuery for InMemoryProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InMemoryTranscodeSettings
|
||||
// ============================================================================
|
||||
|
||||
pub struct InMemoryTranscodeSettings {
|
||||
pub cleanup_ttl: Mutex<Option<u32>>,
|
||||
}
|
||||
@@ -772,6 +764,12 @@ impl InMemoryTranscodeSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryTranscodeSettings {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscodeSettingsRepository for InMemoryTranscodeSettings {
|
||||
async fn load_cleanup_ttl(&self) -> DomainResult<Option<u32>> {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//! Test doubles for domain ports.
|
||||
//!
|
||||
//! Gated behind `#[cfg(feature = "test-helpers")]`.
|
||||
//! Provides InMemory implementations (for integration tests) and
|
||||
//! Noop implementations (for unit tests that don't care about persistence).
|
||||
|
||||
mod in_memory;
|
||||
mod noops;
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! Noop implementations of domain ports.
|
||||
//!
|
||||
//! Return `Ok(())` for writes, `Ok(None)`/`Ok(vec![])` for reads.
|
||||
//! Useful for unit tests that don't care about persistence/side-effects.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::errors::DomainResult;
|
||||
@@ -16,10 +11,6 @@ use crate::ports::{
|
||||
};
|
||||
use crate::value_objects::{ChannelId, MediaFilter, MediaItemId};
|
||||
|
||||
// ============================================================================
|
||||
// NoopEventPublisher
|
||||
// ============================================================================
|
||||
|
||||
pub struct NoopEventPublisher;
|
||||
|
||||
impl NoopEventPublisher {
|
||||
@@ -28,6 +19,12 @@ impl NoopEventPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopEventPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoopEventPublisher {
|
||||
async fn publish(&self, _event: DomainEvent) -> DomainResult<()> {
|
||||
@@ -35,10 +32,6 @@ impl EventPublisher for NoopEventPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NoopMediaProvider
|
||||
// ============================================================================
|
||||
|
||||
pub struct NoopMediaProvider;
|
||||
|
||||
impl NoopMediaProvider {
|
||||
@@ -47,6 +40,12 @@ impl NoopMediaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopMediaProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IMediaProvider for NoopMediaProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
@@ -82,10 +81,6 @@ impl IMediaProvider for NoopMediaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NoopActivityLog
|
||||
// ============================================================================
|
||||
|
||||
pub struct NoopActivityLog;
|
||||
|
||||
impl NoopActivityLog {
|
||||
@@ -94,6 +89,12 @@ impl NoopActivityLog {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopActivityLog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityLogCommand for NoopActivityLog {
|
||||
async fn log(
|
||||
@@ -113,10 +114,6 @@ impl ActivityLogQuery for NoopActivityLog {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NoopLibrarySync
|
||||
// ============================================================================
|
||||
|
||||
pub struct NoopLibrarySync;
|
||||
|
||||
impl NoopLibrarySync {
|
||||
@@ -125,6 +122,12 @@ impl NoopLibrarySync {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopLibrarySync {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LibrarySyncAdapter for NoopLibrarySync {
|
||||
async fn sync_provider(
|
||||
|
||||
@@ -2,11 +2,6 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
// ============================================================================
|
||||
// Validation Error
|
||||
// ============================================================================
|
||||
|
||||
/// Errors that occur when parsing/validating value objects
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ValidationError {
|
||||
@@ -26,16 +21,10 @@ pub enum ValidationError {
|
||||
SecretTooShort { min: usize, actual: usize },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email (using email_address crate for RFC-compliant validation)
|
||||
// ============================================================================
|
||||
|
||||
/// A validated email address using RFC-compliant validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Email(email_address::EmailAddress);
|
||||
|
||||
impl Email {
|
||||
/// Create a new validated email address
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
||||
let value = value.as_ref().trim().to_lowercase();
|
||||
let addr: email_address::EmailAddress = value
|
||||
@@ -44,7 +33,6 @@ impl Email {
|
||||
Ok(Self(addr))
|
||||
}
|
||||
|
||||
/// Get the inner value
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
@@ -91,17 +79,9 @@ impl<'de> Deserialize<'de> for Email {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password
|
||||
// ============================================================================
|
||||
|
||||
/// A validated password input (NOT the hash).
|
||||
///
|
||||
/// Enforces minimum length of 8 characters.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
/// Minimum password length (NIST recommendation)
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
|
||||
impl Password {
|
||||
@@ -129,7 +109,7 @@ impl AsRef<str> for Password {
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally hide password content in Debug
|
||||
// Intentionally hidden in Debug
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Password(***)")
|
||||
@@ -159,69 +139,8 @@ impl<'de> Deserialize<'de> for Password {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
// Password must NOT implement Serialize to prevent accidental exposure
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod email_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(Email::new("user@example.com").is_ok());
|
||||
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
|
||||
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_normalizes() {
|
||||
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||
assert_eq!(email.as_ref(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_at() {
|
||||
assert!(Email::new("userexample.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_domain() {
|
||||
assert!(Email::new("user@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_local() {
|
||||
assert!(Email::new("@example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod password_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_password() {
|
||||
assert!(Password::new("secret123").is_ok());
|
||||
assert!(Password::new("12345678").is_ok()); // Exactly 8 chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_too_short() {
|
||||
assert!(Password::new("1234567").is_err()); // 7 chars
|
||||
assert!(Password::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_debug_hides_content() {
|
||||
let password = Password::new("supersecret").unwrap();
|
||||
let debug = format!("{:?}", password);
|
||||
assert!(!debug.contains("supersecret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "tests/auth.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls who can view a channel's broadcast and stream.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccessMode {
|
||||
@@ -11,7 +10,6 @@ pub enum AccessMode {
|
||||
OwnerOnly,
|
||||
}
|
||||
|
||||
/// Position of the channel logo watermark overlay.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LogoPosition {
|
||||
|
||||
@@ -40,17 +40,12 @@ macro_rules! uuid_id {
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use uuid_id;
|
||||
|
||||
uuid_id!(UserId);
|
||||
uuid_id!(ChannelId);
|
||||
uuid_id!(SlotId);
|
||||
uuid_id!(BlockId);
|
||||
uuid_id!(ScheduleId);
|
||||
|
||||
/// Opaque media item identifier -- format is provider-specific.
|
||||
/// The domain never inspects the string; it just passes it back to the provider.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MediaItemId(String);
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ use url::Url;
|
||||
|
||||
use super::auth::ValidationError;
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Configuration Newtypes
|
||||
// ============================================================================
|
||||
|
||||
/// OIDC Issuer URL - validated URL for the identity provider
|
||||
///
|
||||
/// Stores the original string to preserve exact formatting (e.g., trailing slashes)
|
||||
/// since OIDC providers expect issuer URLs to match exactly.
|
||||
// Stores original string to preserve exact formatting — OIDC providers expect issuer URLs to match exactly
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct IssuerUrl(String);
|
||||
@@ -19,7 +12,6 @@ pub struct IssuerUrl(String);
|
||||
impl IssuerUrl {
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
||||
let value = value.as_ref().trim().to_string();
|
||||
// Validate URL format but store original string to preserve exact formatting
|
||||
Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
@@ -50,7 +42,6 @@ impl From<IssuerUrl> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Client Identifier
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ClientId(String);
|
||||
@@ -90,7 +81,7 @@ impl From<ClientId> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Client Secret - hidden in Debug output
|
||||
// Hidden in Debug for security
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ClientSecret(String);
|
||||
|
||||
@@ -99,7 +90,6 @@ impl ClientSecret {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// Check if the secret is empty (for public clients)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.trim().is_empty()
|
||||
}
|
||||
@@ -111,6 +101,7 @@ impl AsRef<str> for ClientSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden in Debug for security
|
||||
impl fmt::Debug for ClientSecret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ClientSecret(***)")
|
||||
@@ -130,9 +121,8 @@ impl<'de> Deserialize<'de> for ClientSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: ClientSecret should NOT implement Serialize
|
||||
// ClientSecret must NOT implement Serialize
|
||||
|
||||
/// OAuth Redirect URL - validated URL
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct RedirectUrl(Url);
|
||||
@@ -174,7 +164,6 @@ impl From<RedirectUrl> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Resource Identifier (optional audience)
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ResourceId(String);
|
||||
@@ -214,11 +203,6 @@ impl From<ResourceId> for String {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Flow Newtypes (for type-safe session storage)
|
||||
// ============================================================================
|
||||
|
||||
/// CSRF Token for OIDC state parameter
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CsrfToken(String);
|
||||
|
||||
@@ -240,7 +224,6 @@ impl fmt::Display for CsrfToken {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nonce for OIDC ID token verification
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OidcNonce(String);
|
||||
|
||||
@@ -262,7 +245,7 @@ impl fmt::Display for OidcNonce {
|
||||
}
|
||||
}
|
||||
|
||||
/// PKCE Code Verifier
|
||||
// Hidden in Debug for security
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PkceVerifier(String);
|
||||
|
||||
@@ -278,14 +261,14 @@ impl AsRef<str> for PkceVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide PKCE verifier in Debug (security)
|
||||
// Hidden in Debug for security
|
||||
impl fmt::Debug for PkceVerifier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "PkceVerifier(***)")
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth2 Authorization Code
|
||||
// Hidden in Debug for security
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationCode(String);
|
||||
|
||||
@@ -301,7 +284,7 @@ impl AsRef<str> for AuthorizationCode {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide authorization code in Debug (security)
|
||||
// Hidden in Debug for security
|
||||
impl fmt::Debug for AuthorizationCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "AuthorizationCode(***)")
|
||||
@@ -315,24 +298,14 @@ impl<'de> Deserialize<'de> for AuthorizationCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete authorization URL data returned when starting OIDC flow
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorizationUrlData {
|
||||
/// The URL to redirect the user to
|
||||
pub url: Url,
|
||||
/// CSRF token to store in session
|
||||
pub csrf_token: CsrfToken,
|
||||
/// Nonce to store in session
|
||||
pub nonce: OidcNonce,
|
||||
/// PKCE verifier to store in session
|
||||
pub pkce_verifier: PkceVerifier,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Newtypes
|
||||
// ============================================================================
|
||||
|
||||
/// JWT signing secret with minimum length requirement
|
||||
pub const MIN_JWT_SECRET_LENGTH: usize = 32;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
@@ -350,7 +323,6 @@ impl JwtSecret {
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Create without validation (for development/testing)
|
||||
pub fn new_unchecked(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
@@ -368,63 +340,6 @@ impl fmt::Debug for JwtSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod oidc_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_valid() {
|
||||
assert!(IssuerUrl::new("https://auth.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_invalid() {
|
||||
assert!(IssuerUrl::new("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_id_non_empty() {
|
||||
assert!(ClientId::new("my-client").is_ok());
|
||||
assert!(ClientId::new("").is_err());
|
||||
assert!(ClientId::new(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_secret_hides_in_debug() {
|
||||
let secret = ClientSecret::new("super-secret");
|
||||
let debug = format!("{:?}", secret);
|
||||
assert!(!debug.contains("super-secret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
|
||||
mod secret_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jwt_secret_production_check() {
|
||||
let short = "short";
|
||||
let long = "a".repeat(32);
|
||||
|
||||
// Production mode enforces length
|
||||
assert!(JwtSecret::new(short, true).is_err());
|
||||
assert!(JwtSecret::new(&long, true).is_ok());
|
||||
|
||||
// Development mode allows short secrets
|
||||
assert!(JwtSecret::new(short, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secrets_hide_in_debug() {
|
||||
let jwt = JwtSecret::new_unchecked("secret");
|
||||
assert!(!format!("{:?}", jwt).contains("secret"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "tests/oidc.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The broad category of a media item.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
@@ -9,75 +8,48 @@ pub enum ContentType {
|
||||
Short,
|
||||
}
|
||||
|
||||
/// Provider-agnostic filter for querying media items.
|
||||
///
|
||||
/// Each field is optional -- omitting it means "no constraint on this dimension".
|
||||
/// The `IMediaProvider` adapter interprets these fields in terms of its own API.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MediaFilter {
|
||||
pub content_type: Option<ContentType>,
|
||||
pub genres: Vec<String>,
|
||||
/// Starting year of a decade: 1990 means 1990-1999.
|
||||
pub decade: Option<u16>,
|
||||
pub tags: Vec<String>,
|
||||
pub min_duration_secs: Option<u32>,
|
||||
pub max_duration_secs: Option<u32>,
|
||||
/// Abstract groupings interpreted by each provider (Jellyfin library, Plex section,
|
||||
/// filesystem path, etc.). An empty list means "all available content".
|
||||
pub collections: Vec<String>,
|
||||
/// Filter to one or more TV series by name. Use with `content_type: Episode`.
|
||||
/// With `Sequential` strategy each series plays in chronological order.
|
||||
/// Multiple series are OR-combined: any episode from any listed show is eligible.
|
||||
#[serde(default)]
|
||||
pub series_names: Vec<String>,
|
||||
/// Free-text search term. Intended for library browsing; typically omitted
|
||||
/// during schedule generation.
|
||||
pub search_term: Option<String>,
|
||||
}
|
||||
|
||||
/// How the scheduling engine fills a time block with selected media items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FillStrategy {
|
||||
/// Greedy bin-packing: at each step pick the longest item that still fits,
|
||||
/// minimising dead air. Good for variety blocks.
|
||||
BestFit,
|
||||
/// Pick items in the order returned by the provider -- ideal for series
|
||||
/// where episode sequence matters.
|
||||
Sequential,
|
||||
/// Shuffle the pool randomly then fill sequentially. Good for "shuffle play" channels.
|
||||
Random,
|
||||
}
|
||||
|
||||
/// Controls when previously aired items become eligible to play again.
|
||||
///
|
||||
/// An item is *on cooldown* if *either* threshold is met.
|
||||
/// `min_available_ratio` is a safety valve: if honouring the cooldown would
|
||||
/// leave fewer items than this fraction of the total pool, the cooldown is
|
||||
/// ignored and all items become eligible. This prevents small libraries from
|
||||
/// running completely dry.
|
||||
const DEFAULT_COOLDOWN_DAYS: u32 = 30;
|
||||
const DEFAULT_MIN_AVAILABLE_RATIO: f32 = 0.2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecyclePolicy {
|
||||
/// Do not replay an item within this many calendar days.
|
||||
pub cooldown_days: Option<u32>,
|
||||
/// Do not replay an item within this many schedule generations.
|
||||
pub cooldown_generations: Option<u32>,
|
||||
/// Always keep at least this fraction (0.0-1.0) of the matching pool
|
||||
/// available for selection, even if their cooldown has not yet expired.
|
||||
pub min_available_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for RecyclePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cooldown_days: Some(30),
|
||||
cooldown_days: Some(DEFAULT_COOLDOWN_DAYS),
|
||||
cooldown_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
min_available_ratio: DEFAULT_MIN_AVAILABLE_RATIO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Day of week, used as key in weekly schedule configs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Weekday {
|
||||
@@ -106,8 +78,7 @@ impl From<chrono::Weekday> for Weekday {
|
||||
|
||||
impl Weekday {
|
||||
pub fn all() -> [Weekday; 7] {
|
||||
// ISO week order: Monday = index 0, Sunday = index 6.
|
||||
// The schedule engine depends on this order when iterating days.
|
||||
// ISO week order: Monday first. Schedule engine depends on this ordering.
|
||||
[
|
||||
Weekday::Monday,
|
||||
Weekday::Tuesday,
|
||||
@@ -121,24 +92,5 @@ impl Weekday {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_chrono_weekday_all_variants() {
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Mon), Weekday::Monday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Tue), Weekday::Tuesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Wed), Weekday::Wednesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Thu), Weekday::Thursday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Fri), Weekday::Friday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sat), Weekday::Saturday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sun), Weekday::Sunday);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_monday_first_sunday_last() {
|
||||
let days = Weekday::all();
|
||||
assert_eq!(days[0], Weekday::Monday);
|
||||
assert_eq!(days[6], Weekday::Sunday);
|
||||
}
|
||||
}
|
||||
#[path = "tests/scheduling.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::value_objects::ContentType;
|
||||
|
||||
/// Filter for searching the local library.
|
||||
///
|
||||
/// Uses private fields with builder methods and getters to enforce
|
||||
/// encapsulation and allow future validation.
|
||||
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibrarySearchFilter {
|
||||
provider_id: Option<String>,
|
||||
@@ -25,7 +23,6 @@ impl LibrarySearchFilter {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// Builder methods
|
||||
pub fn with_provider_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.provider_id = Some(id.into());
|
||||
self
|
||||
@@ -75,7 +72,6 @@ impl LibrarySearchFilter {
|
||||
self
|
||||
}
|
||||
|
||||
// Getters
|
||||
pub fn provider_id(&self) -> Option<&str> {
|
||||
self.provider_id.as_deref()
|
||||
}
|
||||
@@ -128,37 +124,11 @@ impl Default for LibrarySearchFilter {
|
||||
search_term: None,
|
||||
season_number: None,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
limit: DEFAULT_SEARCH_LIMIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_limit_is_50() {
|
||||
let f = LibrarySearchFilter::default();
|
||||
assert_eq!(f.limit(), 50);
|
||||
assert_eq!(f.offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chain() {
|
||||
let f = LibrarySearchFilter::new()
|
||||
.with_provider_id("jellyfin")
|
||||
.with_content_type(ContentType::Movie)
|
||||
.with_genres(vec!["Action".into()])
|
||||
.with_decade(1990)
|
||||
.with_limit(25)
|
||||
.with_offset(10);
|
||||
|
||||
assert_eq!(f.provider_id(), Some("jellyfin"));
|
||||
assert_eq!(f.content_type(), Some(&ContentType::Movie));
|
||||
assert_eq!(f.genres(), &["Action".to_string()]);
|
||||
assert_eq!(f.decade(), Some(1990));
|
||||
assert_eq!(f.limit(), 25);
|
||||
assert_eq!(f.offset(), 10);
|
||||
}
|
||||
}
|
||||
#[path = "tests/search.rs"]
|
||||
mod tests;
|
||||
|
||||
57
crates/domain/src/value_objects/tests/auth.rs
Normal file
57
crates/domain/src/value_objects/tests/auth.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use super::*;
|
||||
|
||||
mod email_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(Email::new("user@example.com").is_ok());
|
||||
assert!(Email::new("USER@EXAMPLE.COM").is_ok());
|
||||
assert!(Email::new(" user@example.com ").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_normalizes() {
|
||||
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||
assert_eq!(email.as_ref(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_at() {
|
||||
assert!(Email::new("userexample.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_domain() {
|
||||
assert!(Email::new("user@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_local() {
|
||||
assert!(Email::new("@example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod password_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_password() {
|
||||
assert!(Password::new("secret123").is_ok());
|
||||
assert!(Password::new("12345678").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_too_short() {
|
||||
assert!(Password::new("1234567").is_err());
|
||||
assert!(Password::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_debug_hides_content() {
|
||||
let password = Password::new("supersecret").unwrap();
|
||||
let debug = format!("{:?}", password);
|
||||
assert!(!debug.contains("supersecret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
51
crates/domain/src/value_objects/tests/oidc.rs
Normal file
51
crates/domain/src/value_objects/tests/oidc.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use super::*;
|
||||
|
||||
mod oidc_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_valid() {
|
||||
assert!(IssuerUrl::new("https://auth.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_invalid() {
|
||||
assert!(IssuerUrl::new("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_id_non_empty() {
|
||||
assert!(ClientId::new("my-client").is_ok());
|
||||
assert!(ClientId::new("").is_err());
|
||||
assert!(ClientId::new(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_secret_hides_in_debug() {
|
||||
let secret = ClientSecret::new("super-secret");
|
||||
let debug = format!("{:?}", secret);
|
||||
assert!(!debug.contains("super-secret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
|
||||
mod secret_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jwt_secret_production_check() {
|
||||
let short = "short";
|
||||
let long = "a".repeat(32);
|
||||
|
||||
assert!(JwtSecret::new(short, true).is_err());
|
||||
assert!(JwtSecret::new(&long, true).is_ok());
|
||||
|
||||
assert!(JwtSecret::new(short, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secrets_hide_in_debug() {
|
||||
let jwt = JwtSecret::new_unchecked("secret");
|
||||
assert!(!format!("{:?}", jwt).contains("secret"));
|
||||
}
|
||||
}
|
||||
19
crates/domain/src/value_objects/tests/scheduling.rs
Normal file
19
crates/domain/src/value_objects/tests/scheduling.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_chrono_weekday_all_variants() {
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Mon), Weekday::Monday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Tue), Weekday::Tuesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Wed), Weekday::Wednesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Thu), Weekday::Thursday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Fri), Weekday::Friday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sat), Weekday::Saturday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sun), Weekday::Sunday);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_monday_first_sunday_last() {
|
||||
let days = Weekday::all();
|
||||
assert_eq!(days[0], Weekday::Monday);
|
||||
assert_eq!(days[6], Weekday::Sunday);
|
||||
}
|
||||
26
crates/domain/src/value_objects/tests/search.rs
Normal file
26
crates/domain/src/value_objects/tests/search.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_limit_is_50() {
|
||||
let f = LibrarySearchFilter::default();
|
||||
assert_eq!(f.limit(), 50);
|
||||
assert_eq!(f.offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chain() {
|
||||
let f = LibrarySearchFilter::new()
|
||||
.with_provider_id("jellyfin")
|
||||
.with_content_type(ContentType::Movie)
|
||||
.with_genres(vec!["Action".into()])
|
||||
.with_decade(1990)
|
||||
.with_limit(25)
|
||||
.with_offset(10);
|
||||
|
||||
assert_eq!(f.provider_id(), Some("jellyfin"));
|
||||
assert_eq!(f.content_type(), Some(&ContentType::Movie));
|
||||
assert_eq!(f.genres(), &["Action".to_string()]);
|
||||
assert_eq!(f.decade(), Some(1990));
|
||||
assert_eq!(f.limit(), 25);
|
||||
assert_eq!(f.offset(), 10);
|
||||
}
|
||||
Reference in New Issue
Block a user