Refactor schedule and user repositories into modular structure
- Moved schedule repository logic into separate modules for SQLite and PostgreSQL implementations. - Created a mapping module for shared data structures and mapping functions in the schedule repository. - Added new mapping module for user repository to handle user data transformations. - Implemented PostgreSQL and SQLite user repository adapters with necessary CRUD operations. - Added tests for user repository functionality, including saving, finding, and deleting users.
This commit is contained in:
57
k-tv-backend/domain/src/services/channel.rs
Normal file
57
k-tv-backend/domain/src/services/channel.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::entities::Channel;
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::repositories::ChannelRepository;
|
||||
use crate::value_objects::{ChannelId, UserId};
|
||||
|
||||
/// Service for managing channels (CRUD + ownership enforcement).
|
||||
pub struct ChannelService {
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
}
|
||||
|
||||
impl ChannelService {
|
||||
pub fn new(channel_repo: Arc<dyn ChannelRepository>) -> Self {
|
||||
Self { channel_repo }
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
owner_id: UserId,
|
||||
name: &str,
|
||||
timezone: &str,
|
||||
) -> DomainResult<Channel> {
|
||||
let channel = Channel::new(owner_id, name, timezone);
|
||||
self.channel_repo.save(&channel).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: ChannelId) -> DomainResult<Channel> {
|
||||
self.channel_repo
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::ChannelNotFound(id))
|
||||
}
|
||||
|
||||
pub async fn find_all(&self) -> DomainResult<Vec<Channel>> {
|
||||
self.channel_repo.find_all().await
|
||||
}
|
||||
|
||||
pub async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>> {
|
||||
self.channel_repo.find_by_owner(owner_id).await
|
||||
}
|
||||
|
||||
pub async fn update(&self, channel: Channel) -> DomainResult<Channel> {
|
||||
self.channel_repo.save(&channel).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
/// Delete a channel, enforcing that `requester_id` is the owner.
|
||||
pub async fn delete(&self, id: ChannelId, requester_id: UserId) -> DomainResult<()> {
|
||||
let channel = self.find_by_id(id).await?;
|
||||
if channel.owner_id != requester_id {
|
||||
return Err(DomainError::forbidden("You don't own this channel"));
|
||||
}
|
||||
self.channel_repo.delete(id).await
|
||||
}
|
||||
}
|
||||
11
k-tv-backend/domain/src/services/mod.rs
Normal file
11
k-tv-backend/domain/src/services/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Domain Services
|
||||
//!
|
||||
//! Services contain the business logic of the application.
|
||||
|
||||
pub mod channel;
|
||||
pub mod schedule;
|
||||
pub mod user;
|
||||
|
||||
pub use channel::ChannelService;
|
||||
pub use schedule::ScheduleEngineService;
|
||||
pub use user::UserService;
|
||||
119
k-tv-backend/domain/src/services/schedule/fill.rs
Normal file
119
k-tv-backend/domain/src/services/schedule/fill.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
use crate::entities::MediaItem;
|
||||
use crate::value_objects::{FillStrategy, MediaItemId};
|
||||
|
||||
pub(super) fn fill_block<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
strategy: &FillStrategy,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
match strategy {
|
||||
FillStrategy::BestFit => fill_best_fit(pool, target_secs),
|
||||
FillStrategy::Sequential => fill_sequential(candidates, pool, target_secs, last_item_id),
|
||||
FillStrategy::Random => {
|
||||
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
||||
indices.shuffle(&mut rand::thread_rng());
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
for i in indices {
|
||||
let item = &pool[i];
|
||||
if item.duration_secs <= remaining {
|
||||
remaining -= item.duration_secs;
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let best = pool
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, item)| {
|
||||
!used.contains(idx) && item.duration_secs <= remaining
|
||||
})
|
||||
.max_by_key(|(_, item)| item.duration_secs);
|
||||
|
||||
match best {
|
||||
Some((idx, item)) => {
|
||||
remaining -= item.duration_secs;
|
||||
used.insert(idx);
|
||||
selected.push(item);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
target_secs: u32,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Set of item IDs currently eligible to air.
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| &i.id).collect();
|
||||
|
||||
// Find where in the full ordered list to resume.
|
||||
// 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())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Walk candidates in order from start_idx, wrapping around once,
|
||||
// skipping any that are on cooldown (not in `available`).
|
||||
let ordered: Vec<&MediaItem> = (0..candidates.len())
|
||||
.map(|i| &candidates[(start_idx + i) % candidates.len()])
|
||||
.filter(|item| available.contains(&item.id))
|
||||
.collect();
|
||||
|
||||
// Greedily fill the block's time budget in episode order.
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
for item in ordered {
|
||||
if item.duration_secs <= remaining {
|
||||
remaining -= item.duration_secs;
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -1,153 +1,23 @@
|
||||
//! Domain Services
|
||||
//!
|
||||
//! Services contain the business logic of the application.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use rand::seq::SliceRandom;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::{
|
||||
BlockContent, CurrentBroadcast, GeneratedSchedule, MediaItem, PlaybackRecord,
|
||||
ProgrammingBlock, ScheduledSlot,
|
||||
BlockContent, CurrentBroadcast, GeneratedSchedule, PlaybackRecord, ProgrammingBlock,
|
||||
ScheduledSlot,
|
||||
};
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::ports::IMediaProvider;
|
||||
use crate::repositories::{ChannelRepository, ScheduleRepository, UserRepository};
|
||||
use crate::repositories::{ChannelRepository, ScheduleRepository};
|
||||
use crate::value_objects::{
|
||||
BlockId, ChannelId, Email, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
||||
BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UserService
|
||||
// ============================================================================
|
||||
|
||||
/// Service for managing users.
|
||||
pub struct UserService {
|
||||
user_repository: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(user_repository: Arc<dyn UserRepository>) -> Self {
|
||||
Self { user_repository }
|
||||
}
|
||||
|
||||
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<crate::entities::User> {
|
||||
if let Some(user) = self.user_repository.find_by_subject(subject).await? {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
if let Some(mut user) = self.user_repository.find_by_email(email).await? {
|
||||
if user.subject != subject {
|
||||
user.subject = subject.to_string();
|
||||
self.user_repository.save(&user).await?;
|
||||
}
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
let email = Email::try_from(email)?;
|
||||
let user = crate::entities::User::new(subject, email);
|
||||
self.user_repository.save(&user).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<crate::entities::User> {
|
||||
self.user_repository
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::UserNotFound(id))
|
||||
}
|
||||
|
||||
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<crate::entities::User>> {
|
||||
self.user_repository.find_by_email(email).await
|
||||
}
|
||||
|
||||
pub async fn create_local(
|
||||
&self,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> DomainResult<crate::entities::User> {
|
||||
let email = Email::try_from(email)?;
|
||||
let user = crate::entities::User::new_local(email, password_hash);
|
||||
self.user_repository.save(&user).await?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ChannelService
|
||||
// ============================================================================
|
||||
|
||||
/// Service for managing channels (CRUD + ownership enforcement).
|
||||
pub struct ChannelService {
|
||||
channel_repo: Arc<dyn ChannelRepository>,
|
||||
}
|
||||
|
||||
impl ChannelService {
|
||||
pub fn new(channel_repo: Arc<dyn ChannelRepository>) -> Self {
|
||||
Self { channel_repo }
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
owner_id: crate::value_objects::UserId,
|
||||
name: &str,
|
||||
timezone: &str,
|
||||
) -> DomainResult<crate::entities::Channel> {
|
||||
let channel = crate::entities::Channel::new(owner_id, name, timezone);
|
||||
self.channel_repo.save(&channel).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(
|
||||
&self,
|
||||
id: ChannelId,
|
||||
) -> DomainResult<crate::entities::Channel> {
|
||||
self.channel_repo
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::ChannelNotFound(id))
|
||||
}
|
||||
|
||||
pub async fn find_all(&self) -> DomainResult<Vec<crate::entities::Channel>> {
|
||||
self.channel_repo.find_all().await
|
||||
}
|
||||
|
||||
pub async fn find_by_owner(
|
||||
&self,
|
||||
owner_id: crate::value_objects::UserId,
|
||||
) -> DomainResult<Vec<crate::entities::Channel>> {
|
||||
self.channel_repo.find_by_owner(owner_id).await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
channel: crate::entities::Channel,
|
||||
) -> DomainResult<crate::entities::Channel> {
|
||||
self.channel_repo.save(&channel).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
/// Delete a channel, enforcing that `requester_id` is the owner.
|
||||
pub async fn delete(
|
||||
&self,
|
||||
id: ChannelId,
|
||||
requester_id: crate::value_objects::UserId,
|
||||
) -> DomainResult<()> {
|
||||
let channel = self.find_by_id(id).await?;
|
||||
if channel.owner_id != requester_id {
|
||||
return Err(DomainError::forbidden("You don't own this channel"));
|
||||
}
|
||||
self.channel_repo.delete(id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ScheduleEngineService
|
||||
// ============================================================================
|
||||
mod fill;
|
||||
mod recycle;
|
||||
|
||||
/// Core scheduling engine.
|
||||
///
|
||||
@@ -186,7 +56,7 @@ impl ScheduleEngineService {
|
||||
/// 3. Clip the interval to `[from, from + 48h)`.
|
||||
/// 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_sequential`).
|
||||
/// (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.
|
||||
@@ -451,9 +321,9 @@ impl ScheduleEngineService {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let pool = Self::apply_recycle_policy(&candidates, history, policy, generation);
|
||||
let pool = recycle::apply_recycle_policy(&candidates, history, policy, generation);
|
||||
let target_secs = (end - start).num_seconds() as u32;
|
||||
let selected = Self::fill_block(&candidates, &pool, target_secs, strategy, last_item_id);
|
||||
let selected = fill::fill_block(&candidates, &pool, target_secs, strategy, last_item_id);
|
||||
|
||||
let mut slots = Vec::new();
|
||||
let mut cursor = start;
|
||||
@@ -476,178 +346,4 @@ impl ScheduleEngineService {
|
||||
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recycle policy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// 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).
|
||||
fn apply_recycle_policy(
|
||||
candidates: &[MediaItem],
|
||||
history: &[PlaybackRecord],
|
||||
policy: &RecyclePolicy,
|
||||
current_generation: u32,
|
||||
) -> Vec<MediaItem> {
|
||||
let now = Utc::now();
|
||||
|
||||
let excluded: HashSet<MediaItemId> = history
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
let by_days = policy
|
||||
.cooldown_days
|
||||
.map(|days| (now - record.played_at).num_days() < days as i64)
|
||||
.unwrap_or(false);
|
||||
|
||||
let by_gen = policy
|
||||
.cooldown_generations
|
||||
.map(|gens| {
|
||||
current_generation.saturating_sub(record.generation) < gens
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
by_days || by_gen
|
||||
})
|
||||
.map(|r| r.item_id.clone())
|
||||
.collect();
|
||||
|
||||
let available: Vec<MediaItem> = candidates
|
||||
.iter()
|
||||
.filter(|i| !excluded.contains(&i.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let min_count =
|
||||
(candidates.len() as f32 * policy.min_available_ratio).ceil() as usize;
|
||||
|
||||
if available.len() < min_count {
|
||||
// Pool too small after applying cooldowns — recycle everything.
|
||||
candidates.to_vec()
|
||||
} else {
|
||||
available
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fill strategies
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fn fill_block<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
strategy: &FillStrategy,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
match strategy {
|
||||
FillStrategy::BestFit => Self::fill_best_fit(pool, target_secs),
|
||||
FillStrategy::Sequential => {
|
||||
Self::fill_sequential(candidates, pool, target_secs, last_item_id)
|
||||
}
|
||||
FillStrategy::Random => {
|
||||
let mut indices: Vec<usize> = (0..pool.len()).collect();
|
||||
indices.shuffle(&mut rand::thread_rng());
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
for i in indices {
|
||||
let item = &pool[i];
|
||||
if item.duration_secs <= remaining {
|
||||
remaining -= item.duration_secs;
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy bin-packing: at each step pick the longest item that still fits
|
||||
/// in the remaining budget, without repeating items within the same block.
|
||||
fn fill_best_fit(pool: &[MediaItem], target_secs: u32) -> Vec<&MediaItem> {
|
||||
let mut remaining = target_secs;
|
||||
let mut selected: Vec<&MediaItem> = Vec::new();
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
|
||||
loop {
|
||||
let best = pool
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, item)| {
|
||||
!used.contains(idx) && item.duration_secs <= remaining
|
||||
})
|
||||
.max_by_key(|(_, item)| item.duration_secs);
|
||||
|
||||
match best {
|
||||
Some((idx, item)) => {
|
||||
remaining -= item.duration_secs;
|
||||
used.insert(idx);
|
||||
selected.push(item);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
fn fill_sequential<'a>(
|
||||
candidates: &'a [MediaItem],
|
||||
pool: &'a [MediaItem],
|
||||
target_secs: u32,
|
||||
last_item_id: Option<&MediaItemId>,
|
||||
) -> Vec<&'a MediaItem> {
|
||||
if pool.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Set of item IDs currently eligible to air.
|
||||
let available: HashSet<&MediaItemId> = pool.iter().map(|i| &i.id).collect();
|
||||
|
||||
// Find where in the full ordered list to resume.
|
||||
// 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())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Walk candidates in order from start_idx, wrapping around once,
|
||||
// skipping any that are on cooldown (not in `available`).
|
||||
let ordered: Vec<&MediaItem> = (0..candidates.len())
|
||||
.map(|i| &candidates[(start_idx + i) % candidates.len()])
|
||||
.filter(|item| available.contains(&item.id))
|
||||
.collect();
|
||||
|
||||
// Greedily fill the block's time budget in episode order.
|
||||
let mut remaining = target_secs;
|
||||
let mut result = Vec::new();
|
||||
for item in ordered {
|
||||
if item.duration_secs <= remaining {
|
||||
remaining -= item.duration_secs;
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
55
k-tv-backend/domain/src/services/schedule/recycle.rs
Normal file
55
k-tv-backend/domain/src/services/schedule/recycle.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::entities::{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],
|
||||
policy: &RecyclePolicy,
|
||||
current_generation: u32,
|
||||
) -> Vec<MediaItem> {
|
||||
let now = Utc::now();
|
||||
|
||||
let excluded: HashSet<MediaItemId> = history
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
let by_days = policy
|
||||
.cooldown_days
|
||||
.map(|days| (now - record.played_at).num_days() < days as i64)
|
||||
.unwrap_or(false);
|
||||
|
||||
let by_gen = policy
|
||||
.cooldown_generations
|
||||
.map(|gens| current_generation.saturating_sub(record.generation) < gens)
|
||||
.unwrap_or(false);
|
||||
|
||||
by_days || by_gen
|
||||
})
|
||||
.map(|r| r.item_id.clone())
|
||||
.collect();
|
||||
|
||||
let available: Vec<MediaItem> = candidates
|
||||
.iter()
|
||||
.filter(|i| !excluded.contains(&i.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let min_count =
|
||||
(candidates.len() as f32 * policy.min_available_ratio).ceil() as usize;
|
||||
|
||||
if available.len() < min_count {
|
||||
// Pool too small after applying cooldowns — recycle everything.
|
||||
candidates.to_vec()
|
||||
} else {
|
||||
available
|
||||
}
|
||||
}
|
||||
60
k-tv-backend/domain/src/services/user.rs
Normal file
60
k-tv-backend/domain/src/services/user.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::User;
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::repositories::UserRepository;
|
||||
use crate::value_objects::Email;
|
||||
|
||||
/// Service for managing users.
|
||||
pub struct UserService {
|
||||
user_repository: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(user_repository: Arc<dyn UserRepository>) -> Self {
|
||||
Self { user_repository }
|
||||
}
|
||||
|
||||
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<User> {
|
||||
if let Some(user) = self.user_repository.find_by_subject(subject).await? {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
if let Some(mut user) = self.user_repository.find_by_email(email).await? {
|
||||
if user.subject != subject {
|
||||
user.subject = subject.to_string();
|
||||
self.user_repository.save(&user).await?;
|
||||
}
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
let email = Email::try_from(email)?;
|
||||
let user = User::new(subject, email);
|
||||
self.user_repository.save(&user).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<User> {
|
||||
self.user_repository
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::UserNotFound(id))
|
||||
}
|
||||
|
||||
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
|
||||
self.user_repository.find_by_email(email).await
|
||||
}
|
||||
|
||||
pub async fn create_local(
|
||||
&self,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> DomainResult<User> {
|
||||
let email = Email::try_from(email)?;
|
||||
let user = User::new_local(email, password_hash);
|
||||
self.user_repository.save(&user).await?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
227
k-tv-backend/domain/src/value_objects/auth.rs
Normal file
227
k-tv-backend/domain/src/value_objects/auth.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
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 {
|
||||
#[error("Invalid email format: {0}")]
|
||||
InvalidEmail(String),
|
||||
|
||||
#[error("Password must be at least {min} characters, got {actual}")]
|
||||
PasswordTooShort { min: usize, actual: usize },
|
||||
|
||||
#[error("Invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
#[error("Value cannot be empty: {0}")]
|
||||
Empty(String),
|
||||
|
||||
#[error("Secret too short: minimum {min} bytes required, got {actual}")]
|
||||
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
|
||||
.parse()
|
||||
.map_err(|_| ValidationError::InvalidEmail(value.clone()))?;
|
||||
Ok(Self(addr))
|
||||
}
|
||||
|
||||
/// Get the inner value
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Email {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Email {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Email {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Email {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Email {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.0.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Email {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password
|
||||
// ============================================================================
|
||||
|
||||
/// A validated password input (NOT the hash).
|
||||
///
|
||||
/// Enforces minimum length of 6 characters.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
/// Minimum password length (NIST recommendation)
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
|
||||
impl Password {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||
let value = value.into();
|
||||
|
||||
if value.len() < MIN_PASSWORD_LENGTH {
|
||||
return Err(ValidationError::PasswordTooShort {
|
||||
min: MIN_PASSWORD_LENGTH,
|
||||
actual: value.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Password {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally hide password content in Debug
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Password(***)")
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Password {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Password {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[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("***"));
|
||||
}
|
||||
}
|
||||
}
|
||||
6
k-tv-backend/domain/src/value_objects/ids.rs
Normal file
6
k-tv-backend/domain/src/value_objects/ids.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type UserId = Uuid;
|
||||
pub type ChannelId = Uuid;
|
||||
pub type SlotId = Uuid;
|
||||
pub type BlockId = Uuid;
|
||||
14
k-tv-backend/domain/src/value_objects/mod.rs
Normal file
14
k-tv-backend/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Value Objects for K-Notes Domain
|
||||
//!
|
||||
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
|
||||
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
|
||||
|
||||
pub mod auth;
|
||||
pub mod ids;
|
||||
pub mod oidc;
|
||||
pub mod scheduling;
|
||||
|
||||
pub use auth::*;
|
||||
pub use ids::*;
|
||||
pub use oidc::*;
|
||||
pub use scheduling::*;
|
||||
@@ -1,174 +1,8 @@
|
||||
//! Value Objects for K-Notes Domain
|
||||
//!
|
||||
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
|
||||
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type UserId = Uuid;
|
||||
|
||||
// ============================================================================
|
||||
// Validation Error
|
||||
// ============================================================================
|
||||
|
||||
/// Errors that occur when parsing/validating value objects
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ValidationError {
|
||||
#[error("Invalid email format: {0}")]
|
||||
InvalidEmail(String),
|
||||
|
||||
#[error("Password must be at least {min} characters, got {actual}")]
|
||||
PasswordTooShort { min: usize, actual: usize },
|
||||
|
||||
#[error("Invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
#[error("Value cannot be empty: {0}")]
|
||||
Empty(String),
|
||||
|
||||
#[error("Secret too short: minimum {min} bytes required, got {actual}")]
|
||||
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
|
||||
.parse()
|
||||
.map_err(|_| ValidationError::InvalidEmail(value.clone()))?;
|
||||
Ok(Self(addr))
|
||||
}
|
||||
|
||||
/// Get the inner value
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Email {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Email {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Email {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Email {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Email {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.0.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Email {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password
|
||||
// ============================================================================
|
||||
|
||||
/// A validated password input (NOT the hash).
|
||||
///
|
||||
/// Enforces minimum length of 6 characters.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
/// Minimum password length (NIST recommendation)
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
|
||||
impl Password {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||
let value = value.into();
|
||||
|
||||
if value.len() < MIN_PASSWORD_LENGTH {
|
||||
return Err(ValidationError::PasswordTooShort {
|
||||
min: MIN_PASSWORD_LENGTH,
|
||||
actual: value.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Password {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally hide password content in Debug
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Password(***)")
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Password {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Password {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
||||
use super::auth::ValidationError;
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Configuration Newtypes
|
||||
@@ -534,130 +368,6 @@ impl fmt::Debug for JwtSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel / Schedule types
|
||||
// ============================================================================
|
||||
|
||||
pub type ChannelId = Uuid;
|
||||
pub type SlotId = Uuid;
|
||||
pub type BlockId = Uuid;
|
||||
|
||||
/// Opaque media item identifier — format is provider-specific internally.
|
||||
/// The domain never inspects the string; it just passes it back to the provider.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MediaItemId(String);
|
||||
|
||||
impl MediaItemId {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for MediaItemId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MediaItemId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MediaItemId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MediaItemId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The broad category of a media item.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
Movie,
|
||||
Episode,
|
||||
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.
|
||||
#[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_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
@@ -666,62 +376,6 @@ impl Default for RecyclePolicy {
|
||||
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("***"));
|
||||
}
|
||||
}
|
||||
|
||||
mod oidc_tests {
|
||||
use super::*;
|
||||
|
||||
118
k-tv-backend/domain/src/value_objects/scheduling.rs
Normal file
118
k-tv-backend/domain/src/value_objects/scheduling.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Opaque media item identifier — format is provider-specific internally.
|
||||
/// The domain never inspects the string; it just passes it back to the provider.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MediaItemId(String);
|
||||
|
||||
impl MediaItemId {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for MediaItemId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MediaItemId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for MediaItemId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MediaItemId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The broad category of a media item.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
Movie,
|
||||
Episode,
|
||||
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.
|
||||
#[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_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user