domain value objects: auth, scheduling, channel, oidc, search

This commit is contained in:
2026-07-12 01:05:31 +02:00
parent 3ff146615f
commit b2e71403d8
7 changed files with 1004 additions and 0 deletions

View 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 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<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("***"));
}
}
}

View File

@@ -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,
}

View File

@@ -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::*;

View File

@@ -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<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))
}
}
impl AsRef<str> 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<String> for IssuerUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<IssuerUrl> 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<String>) -> Result<Self, ValidationError> {
let value = value.into().trim().to_string();
if value.is_empty() {
return Err(ValidationError::Empty("client_id".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> 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<String> for ClientId {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ClientId> 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<String>) -> 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<str> 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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<str>) -> Result<Self, ValidationError> {
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<str> 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<String> for RedirectUrl {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<RedirectUrl> 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<String>) -> Result<Self, ValidationError> {
let value = value.into().trim().to_string();
if value.is_empty() {
return Err(ValidationError::Empty("resource_id".to_string()));
}
Ok(Self(value))
}
}
impl AsRef<str> 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<String> for ResourceId {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ResourceId> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<String>, is_production: bool) -> Result<Self, ValidationError> {
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<String>) -> Self {
Self(value.into())
}
}
impl AsRef<str> 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"));
}
}
}

View File

@@ -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<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,
}
}
}
/// 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<chrono::Weekday> 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);
}
}

View File

@@ -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<String>,
content_type: Option<ContentType>,
series_names: Vec<String>,
collection_id: Option<String>,
genres: Vec<String>,
decade: Option<u16>,
min_duration_secs: Option<u32>,
max_duration_secs: Option<u32>,
search_term: Option<String>,
season_number: Option<u32>,
offset: u32,
limit: u32,
}
impl LibrarySearchFilter {
pub fn new() -> Self {
Self::default()
}
// Builder methods
pub fn with_provider_id(mut self, id: impl Into<String>) -> 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<String>) -> Self {
self.series_names = names;
self
}
pub fn with_collection_id(mut self, id: impl Into<String>) -> Self {
self.collection_id = Some(id.into());
self
}
pub fn with_genres(mut self, genres: Vec<String>) -> 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<String>) -> 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<u16> {
self.decade
}
pub fn min_duration_secs(&self) -> Option<u32> {
self.min_duration_secs
}
pub fn max_duration_secs(&self) -> Option<u32> {
self.max_duration_secs
}
pub fn search_term(&self) -> Option<&str> {
self.search_term.as_deref()
}
pub fn season_number(&self) -> Option<u32> {
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);
}
}