Skip to main content

flix_model/
numbers.rs

1//! This module contains season and episode numbers and related errors.
2
3use core::fmt;
4use core::ops::RangeInclusive;
5use core::str::FromStr;
6use std::collections::HashSet;
7
8use seamantic::sea_orm;
9
10/// Newtype for representing season numbers.
11#[derive(
12	Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
13)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "serde", serde(transparent))]
16#[repr(transparent)]
17pub struct SeasonNumber(u32);
18
19impl SeasonNumber {
20	/// Create a [`SeasonNumber`] from an integer.
21	#[inline]
22	#[must_use]
23	pub const fn new(value: u32) -> Self {
24		Self(value)
25	}
26}
27
28impl fmt::Display for SeasonNumber {
29	#[inline]
30	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31		self.0.fmt(f)
32	}
33}
34
35impl FromStr for SeasonNumber {
36	type Err = <u32 as FromStr>::Err;
37
38	#[inline]
39	fn from_str(s: &str) -> Result<Self, Self::Err> {
40		u32::from_str(s).map(Self)
41	}
42}
43
44/// Newtype for representing episode numbers.
45#[derive(
46	Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sea_orm::DeriveValueType,
47)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature = "serde", serde(transparent))]
50#[repr(transparent)]
51pub struct EpisodeNumber(u32);
52
53impl EpisodeNumber {
54	/// Create an [`EpisodeNumber`] from an integer.
55	#[inline]
56	#[must_use]
57	pub const fn new(value: u32) -> Self {
58		Self(value)
59	}
60
61	/// Get the underlying value.
62	#[inline]
63	#[must_use]
64	pub const fn into_inner(self) -> u32 {
65		self.0
66	}
67}
68
69impl fmt::Display for EpisodeNumber {
70	#[inline]
71	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72		self.0.fmt(f)
73	}
74}
75
76impl FromStr for EpisodeNumber {
77	type Err = <u32 as FromStr>::Err;
78
79	#[inline]
80	fn from_str(s: &str) -> Result<Self, Self::Err> {
81		u32::from_str(s).map(Self)
82	}
83}
84
85/// Potential errors when building [`EpisodeNumbers`].
86#[derive(Debug, Clone, Copy, thiserror::Error)]
87#[expect(clippy::exhaustive_enums, reason = "unlikely to add new variants")]
88pub enum EpisodeNumbersError {
89	/// There are no episodes.
90	#[error("zero episodes")]
91	Zero,
92	/// There are gaps in the episodes.
93	#[error("noncontiguous episodes")]
94	Noncontiguous,
95}
96
97/// A wrapper for handling single and multi-episode entries.
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100#[expect(
101	clippy::module_name_repetitions,
102	reason = "Episodes is not a good name"
103)]
104pub struct EpisodeNumbers(RangeInclusive<EpisodeNumber>);
105
106impl TryFrom<&[EpisodeNumber]> for EpisodeNumbers {
107	type Error = EpisodeNumbersError;
108
109	#[inline]
110	fn try_from(value: &[EpisodeNumber]) -> Result<Self, Self::Error> {
111		match value {
112			[] => Err(EpisodeNumbersError::Zero),
113			[n] => Ok(Self(*n..=*n)),
114			_ => {
115				// min and max will always exist
116				let min = value.iter().copied().min().unwrap_or_default();
117				let max = value.iter().copied().max().unwrap_or_default();
118				let len = value.len();
119
120				if usize::try_from(max.0.saturating_sub(min.0).saturating_add(1)) != Ok(len) {
121					return Err(EpisodeNumbersError::Noncontiguous);
122				}
123
124				let set: HashSet<_> = value.iter().copied().collect();
125				if set.len() != len {
126					return Err(EpisodeNumbersError::Noncontiguous);
127				}
128
129				Ok(Self(min..=max))
130			}
131		}
132	}
133}
134
135impl EpisodeNumbers {
136	/// Create an [`EpisodeNumbers`] from a starting number and a count.
137	/// `count` should be zero for single episodes.
138	#[inline]
139	#[must_use]
140	pub fn new(start: EpisodeNumber, count: u8) -> Self {
141		Self(start..=EpisodeNumber(start.0.saturating_add(count.into())))
142	}
143
144	/// Get the range of episodes.
145	#[inline]
146	#[must_use]
147	pub const fn as_range(&self) -> &RangeInclusive<EpisodeNumber> {
148		&self.0
149	}
150
151	/// Render this [`EpisodeNumbers`] as a range. If only one episode is
152	/// is present it renders as `01`, if multiple it renders as `01-02`.
153	#[inline]
154	#[must_use]
155	pub fn range_string(&self) -> String {
156		let start = self.0.start();
157		let end = self.0.end();
158
159		if start == end {
160			format!("{start:02}")
161		} else {
162			format!("{start:02}-{end:02}")
163		}
164	}
165}
166
167#[cfg(test)]
168mod tests {
169	use super::{EpisodeNumber, EpisodeNumbers, SeasonNumber};
170
171	#[test]
172	fn use_fn() {
173		_ = SeasonNumber::new(0);
174		let e = EpisodeNumber::new(0);
175		_ = EpisodeNumbers::new(e, 1);
176	}
177}