From b2e71403d8e8222edce68c7af8c8cdfedc6d81fa Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 01:05:31 +0200 Subject: [PATCH] domain value objects: auth, scheduling, channel, oidc, search --- crates/domain/src/errors/mod.rs | 6 + crates/domain/src/value_objects/auth.rs | 227 +++++++++ crates/domain/src/value_objects/channel.rs | 23 + crates/domain/src/value_objects/mod.rs | 10 + crates/domain/src/value_objects/oidc.rs | 430 ++++++++++++++++++ crates/domain/src/value_objects/scheduling.rs | 144 ++++++ crates/domain/src/value_objects/search.rs | 164 +++++++ 7 files changed, 1004 insertions(+) create mode 100644 crates/domain/src/value_objects/auth.rs create mode 100644 crates/domain/src/value_objects/channel.rs create mode 100644 crates/domain/src/value_objects/oidc.rs create mode 100644 crates/domain/src/value_objects/scheduling.rs create mode 100644 crates/domain/src/value_objects/search.rs diff --git a/crates/domain/src/errors/mod.rs b/crates/domain/src/errors/mod.rs index 9c5780b..800cbdf 100644 --- a/crates/domain/src/errors/mod.rs +++ b/crates/domain/src/errors/mod.rs @@ -61,3 +61,9 @@ impl DomainError { } pub type DomainResult = Result; + +impl From for DomainError { + fn from(error: crate::value_objects::auth::ValidationError) -> Self { + DomainError::ValidationError(error.to_string()) + } +} diff --git a/crates/domain/src/value_objects/auth.rs b/crates/domain/src/value_objects/auth.rs new file mode 100644 index 0000000..c319e19 --- /dev/null +++ b/crates/domain/src/value_objects/auth.rs @@ -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) -> Result { + 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 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 for Email { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for Email { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for Email { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.0.as_ref()) + } +} + +impl<'de> Deserialize<'de> for Email { + fn deserialize>(deserializer: D) -> Result { + 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 8 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) -> Result { + 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 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 for Password { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for Password { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl<'de> Deserialize<'de> for Password { + fn deserialize>(deserializer: D) -> Result { + 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("***")); + } + } +} diff --git a/crates/domain/src/value_objects/channel.rs b/crates/domain/src/value_objects/channel.rs new file mode 100644 index 0000000..424e342 --- /dev/null +++ b/crates/domain/src/value_objects/channel.rs @@ -0,0 +1,23 @@ +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 { + #[default] + Public, + PasswordProtected, + AccountRequired, + OwnerOnly, +} + +/// Position of the channel logo watermark overlay. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum LogoPosition { + TopLeft, + #[default] + TopRight, + BottomLeft, + BottomRight, +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 87962a2..b5c5341 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,3 +1,13 @@ +pub mod auth; +pub mod channel; pub mod ids; +pub mod oidc; +pub mod scheduling; +pub mod search; +pub use auth::*; +pub use channel::*; pub use ids::*; +pub use oidc::*; +pub use scheduling::*; +pub use search::*; diff --git a/crates/domain/src/value_objects/oidc.rs b/crates/domain/src/value_objects/oidc.rs new file mode 100644 index 0000000..3203502 --- /dev/null +++ b/crates/domain/src/value_objects/oidc.rs @@ -0,0 +1,430 @@ +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; +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. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct IssuerUrl(String); + +impl IssuerUrl { + pub fn new(value: impl AsRef) -> Result { + 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)) + } +} + +impl AsRef for IssuerUrl { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for IssuerUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for IssuerUrl { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: IssuerUrl) -> Self { + val.0 + } +} + +/// OIDC Client Identifier +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ClientId(String); + +impl ClientId { + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_string(); + if value.is_empty() { + return Err(ValidationError::Empty("client_id".to_string())); + } + Ok(Self(value)) + } +} + +impl AsRef for ClientId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ClientId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ClientId { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: ClientId) -> Self { + val.0 + } +} + +/// OIDC Client Secret - hidden in Debug output +#[derive(Clone, PartialEq, Eq)] +pub struct ClientSecret(String); + +impl ClientSecret { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Check if the secret is empty (for public clients) + pub fn is_empty(&self) -> bool { + self.0.trim().is_empty() + } +} + +impl AsRef for ClientSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ClientSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ClientSecret(***)") + } +} + +impl fmt::Display for ClientSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "***") + } +} + +impl<'de> Deserialize<'de> for ClientSecret { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Self::new(s)) + } +} + +// Note: ClientSecret should 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); + +impl RedirectUrl { + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref().trim(); + let url = Url::parse(value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; + Ok(Self(url)) + } + + pub fn as_url(&self) -> &Url { + &self.0 + } +} + +impl AsRef for RedirectUrl { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for RedirectUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for RedirectUrl { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: RedirectUrl) -> Self { + val.0.to_string() + } +} + +/// OIDC Resource Identifier (optional audience) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ResourceId(String); + +impl ResourceId { + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_string(); + if value.is_empty() { + return Err(ValidationError::Empty("resource_id".to_string())); + } + Ok(Self(value)) + } +} + +impl AsRef for ResourceId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ResourceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ResourceId { + type Error = ValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(val: ResourceId) -> Self { + val.0 + } +} + +// ============================================================================ +// 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); + +impl CsrfToken { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for CsrfToken { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CsrfToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Nonce for OIDC ID token verification +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OidcNonce(String); + +impl OidcNonce { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for OidcNonce { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for OidcNonce { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// PKCE Code Verifier +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PkceVerifier(String); + +impl PkceVerifier { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for PkceVerifier { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Hide PKCE verifier in Debug (security) +impl fmt::Debug for PkceVerifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PkceVerifier(***)") + } +} + +/// OAuth2 Authorization Code +#[derive(Clone, PartialEq, Eq)] +pub struct AuthorizationCode(String); + +impl AuthorizationCode { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for AuthorizationCode { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Hide authorization code in Debug (security) +impl fmt::Debug for AuthorizationCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "AuthorizationCode(***)") + } +} + +impl<'de> Deserialize<'de> for AuthorizationCode { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Self::new(s)) + } +} + +/// 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)] +pub struct JwtSecret(String); + +impl JwtSecret { + pub fn new(value: impl Into, is_production: bool) -> Result { + let value = value.into(); + if is_production && value.len() < MIN_JWT_SECRET_LENGTH { + return Err(ValidationError::SecretTooShort { + min: MIN_JWT_SECRET_LENGTH, + actual: value.len(), + }); + } + Ok(Self(value)) + } + + /// Create without validation (for development/testing) + pub fn new_unchecked(value: impl Into) -> Self { + Self(value.into()) + } +} + +impl AsRef for JwtSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for JwtSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "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")); + } + } +} diff --git a/crates/domain/src/value_objects/scheduling.rs b/crates/domain/src/value_objects/scheduling.rs new file mode 100644 index 0000000..a1e0ab9 --- /dev/null +++ b/crates/domain/src/value_objects/scheduling.rs @@ -0,0 +1,144 @@ +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 { + 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, + pub genres: Vec, + /// Starting year of a decade: 1990 means 1990-1999. + pub decade: Option, + pub tags: Vec, + pub min_duration_secs: Option, + pub max_duration_secs: Option, + /// Abstract groupings interpreted by each provider (Jellyfin library, Plex section, + /// filesystem path, etc.). An empty list means "all available content". + pub collections: Vec, + /// 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, + /// Free-text search term. Intended for library browsing; typically omitted + /// during schedule generation. + pub search_term: Option, +} + +/// 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, + /// Do not replay an item within this many schedule generations. + pub cooldown_generations: Option, + /// 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, + } + } +} + +/// 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 { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} + +impl From for Weekday { + fn from(w: chrono::Weekday) -> Self { + match w { + chrono::Weekday::Mon => Weekday::Monday, + chrono::Weekday::Tue => Weekday::Tuesday, + chrono::Weekday::Wed => Weekday::Wednesday, + chrono::Weekday::Thu => Weekday::Thursday, + chrono::Weekday::Fri => Weekday::Friday, + chrono::Weekday::Sat => Weekday::Saturday, + chrono::Weekday::Sun => Weekday::Sunday, + } + } +} + +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. + [ + Weekday::Monday, + Weekday::Tuesday, + Weekday::Wednesday, + Weekday::Thursday, + Weekday::Friday, + Weekday::Saturday, + Weekday::Sunday, + ] + } +} + +#[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); + } +} diff --git a/crates/domain/src/value_objects/search.rs b/crates/domain/src/value_objects/search.rs new file mode 100644 index 0000000..b72d765 --- /dev/null +++ b/crates/domain/src/value_objects/search.rs @@ -0,0 +1,164 @@ +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. +#[derive(Debug, Clone)] +pub struct LibrarySearchFilter { + provider_id: Option, + content_type: Option, + series_names: Vec, + collection_id: Option, + genres: Vec, + decade: Option, + min_duration_secs: Option, + max_duration_secs: Option, + search_term: Option, + season_number: Option, + offset: u32, + limit: u32, +} + +impl LibrarySearchFilter { + pub fn new() -> Self { + Self::default() + } + + // Builder methods + pub fn with_provider_id(mut self, id: impl Into) -> Self { + self.provider_id = Some(id.into()); + self + } + pub fn with_content_type(mut self, ct: ContentType) -> Self { + self.content_type = Some(ct); + self + } + pub fn with_series_names(mut self, names: Vec) -> Self { + self.series_names = names; + self + } + pub fn with_collection_id(mut self, id: impl Into) -> Self { + self.collection_id = Some(id.into()); + self + } + pub fn with_genres(mut self, genres: Vec) -> Self { + self.genres = genres; + self + } + pub fn with_decade(mut self, decade: u16) -> Self { + self.decade = Some(decade); + self + } + pub fn with_min_duration_secs(mut self, secs: u32) -> Self { + self.min_duration_secs = Some(secs); + self + } + pub fn with_max_duration_secs(mut self, secs: u32) -> Self { + self.max_duration_secs = Some(secs); + self + } + pub fn with_search_term(mut self, term: impl Into) -> Self { + self.search_term = Some(term.into()); + self + } + pub fn with_season_number(mut self, n: u32) -> Self { + self.season_number = Some(n); + self + } + pub fn with_offset(mut self, offset: u32) -> Self { + self.offset = offset; + self + } + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + // Getters + pub fn provider_id(&self) -> Option<&str> { + self.provider_id.as_deref() + } + pub fn content_type(&self) -> Option<&ContentType> { + self.content_type.as_ref() + } + pub fn series_names(&self) -> &[String] { + &self.series_names + } + pub fn collection_id(&self) -> Option<&str> { + self.collection_id.as_deref() + } + pub fn genres(&self) -> &[String] { + &self.genres + } + pub fn decade(&self) -> Option { + self.decade + } + pub fn min_duration_secs(&self) -> Option { + self.min_duration_secs + } + pub fn max_duration_secs(&self) -> Option { + self.max_duration_secs + } + pub fn search_term(&self) -> Option<&str> { + self.search_term.as_deref() + } + pub fn season_number(&self) -> Option { + self.season_number + } + pub fn offset(&self) -> u32 { + self.offset + } + pub fn limit(&self) -> u32 { + self.limit + } +} + +impl Default for LibrarySearchFilter { + fn default() -> Self { + Self { + provider_id: None, + content_type: None, + series_names: vec![], + collection_id: None, + genres: vec![], + decade: None, + min_duration_secs: None, + max_duration_secs: None, + search_term: None, + season_number: None, + offset: 0, + limit: 50, + } + } +} + +#[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); + } +}