use indexmap::IndexMap;
use smol_str::SmolStr;
use thiserror::Error;
use crate::domain::Uuid7;
use mediatime::TimeRange;
pub const MAX_CHAPTER_TITLE_BYTES: usize = 4 * 1024;
pub const MAX_CHAPTER_METADATA_ENTRIES: usize = 4096;
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter<Id = Uuid7> {
id: Id,
media_id: Id,
index: u32,
source_id: i64,
time_range: TimeRange,
title: SmolStr,
metadata: IndexMap<SmolStr, SmolStr>,
}
#[derive(Debug, Error, derive_more::IsVariant, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChapterError {
#[error("Chapter.id must be non-nil")]
NilId,
#[error("Chapter.media_id must be non-nil")]
NilMediaId,
#[error("Chapter.title exceeds {MAX_CHAPTER_TITLE_BYTES} bytes: got {got}")]
TitleTooLong {
got: usize,
},
#[error("Chapter.metadata exceeds {MAX_CHAPTER_METADATA_ENTRIES} entries: got {got}")]
MetadataTooLarge {
got: usize,
},
}
impl Chapter<Uuid7> {
pub fn try_new(
id: Uuid7,
media_id: Uuid7,
index: u32,
source_id: i64,
time_range: TimeRange,
) -> Result<Self, ChapterError> {
if id.is_nil() {
return Err(ChapterError::NilId);
}
if media_id.is_nil() {
return Err(ChapterError::NilMediaId);
}
Ok(Self {
id,
media_id,
index,
source_id,
time_range,
title: SmolStr::default(),
metadata: IndexMap::new(),
})
}
}
impl<Id> Chapter<Id> {
#[inline(always)]
pub const fn id_ref(&self) -> &Id {
&self.id
}
#[inline(always)]
pub const fn media_id_ref(&self) -> &Id {
&self.media_id
}
#[inline(always)]
pub const fn index(&self) -> u32 {
self.index
}
#[inline(always)]
pub const fn source_id(&self) -> i64 {
self.source_id
}
#[inline(always)]
pub const fn time_range_ref(&self) -> &TimeRange {
&self.time_range
}
#[inline(always)]
pub fn title(&self) -> &str {
self.title.as_str()
}
#[inline(always)]
pub const fn title_ref(&self) -> &SmolStr {
&self.title
}
#[inline(always)]
pub const fn metadata_ref(&self) -> &IndexMap<SmolStr, SmolStr> {
&self.metadata
}
#[inline(always)]
#[must_use]
pub const fn with_index(mut self, index: u32) -> Self {
self.index = index;
self
}
#[inline(always)]
#[must_use]
pub const fn with_source_id(mut self, source_id: i64) -> Self {
self.source_id = source_id;
self
}
#[inline(always)]
#[must_use]
pub const fn with_time_range(mut self, time_range: TimeRange) -> Self {
self.time_range = time_range;
self
}
#[inline]
pub fn try_with_title(mut self, title: impl Into<SmolStr>) -> Result<Self, ChapterError> {
let title = title.into();
if title.len() > MAX_CHAPTER_TITLE_BYTES {
return Err(ChapterError::TitleTooLong { got: title.len() });
}
self.title = title;
Ok(self)
}
#[inline]
pub fn try_with_metadata(
mut self,
metadata: IndexMap<SmolStr, SmolStr>,
) -> Result<Self, ChapterError> {
if metadata.len() > MAX_CHAPTER_METADATA_ENTRIES {
return Err(ChapterError::MetadataTooLarge {
got: metadata.len(),
});
}
self.metadata = metadata;
Ok(self)
}
#[inline(always)]
pub const fn set_index(&mut self, index: u32) -> &mut Self {
self.index = index;
self
}
#[inline(always)]
pub const fn set_source_id(&mut self, source_id: i64) -> &mut Self {
self.source_id = source_id;
self
}
#[inline(always)]
pub const fn set_time_range(&mut self, time_range: TimeRange) -> &mut Self {
self.time_range = time_range;
self
}
#[inline]
pub fn try_set_title(&mut self, title: impl Into<SmolStr>) -> Result<&mut Self, ChapterError> {
let title = title.into();
if title.len() > MAX_CHAPTER_TITLE_BYTES {
return Err(ChapterError::TitleTooLong { got: title.len() });
}
self.title = title;
Ok(self)
}
#[inline]
pub fn try_set_metadata(
&mut self,
metadata: IndexMap<SmolStr, SmolStr>,
) -> Result<&mut Self, ChapterError> {
if metadata.len() > MAX_CHAPTER_METADATA_ENTRIES {
return Err(ChapterError::MetadataTooLarge {
got: metadata.len(),
});
}
self.metadata = metadata;
Ok(self)
}
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
use core::num::NonZeroU32;
use mediatime::Timebase;
fn tb() -> Timebase {
Timebase::new(1, NonZeroU32::new(1000).unwrap())
}
#[test]
fn try_new_accepts_well_formed() {
let c = Chapter::try_new(
Uuid7::new(),
Uuid7::new(),
0,
0,
TimeRange::new(0, 1000, tb()),
)
.expect("well-formed");
assert_eq!(c.index(), 0);
assert_eq!(c.source_id(), 0);
assert!(c.title().is_empty());
assert!(c.metadata_ref().is_empty());
}
#[test]
fn try_new_rejects_nil_id() {
let err = Chapter::try_new(
Uuid7::nil(),
Uuid7::new(),
0,
0,
TimeRange::new(0, 1000, tb()),
)
.unwrap_err();
assert!(err.is_nil_id());
}
#[test]
fn try_new_rejects_nil_media_id() {
let err = Chapter::try_new(
Uuid7::new(),
Uuid7::nil(),
0,
0,
TimeRange::new(0, 1000, tb()),
)
.unwrap_err();
assert!(err.is_nil_media_id());
}
#[test]
fn try_with_title_rejects_oversize() {
let c = Chapter::try_new(
Uuid7::new(),
Uuid7::new(),
0,
0,
TimeRange::new(0, 1000, tb()),
)
.expect("well-formed");
let huge =
std::string::String::from_utf8(std::vec![b'a'; MAX_CHAPTER_TITLE_BYTES + 1]).unwrap();
let err = c.try_with_title(huge).unwrap_err();
assert!(
matches!(err, ChapterError::TitleTooLong { got } if got == MAX_CHAPTER_TITLE_BYTES + 1)
);
}
#[test]
fn try_with_metadata_rejects_oversize() {
let c = Chapter::try_new(
Uuid7::new(),
Uuid7::new(),
0,
0,
TimeRange::new(0, 1000, tb()),
)
.expect("well-formed");
let mut huge = IndexMap::new();
for i in 0..(MAX_CHAPTER_METADATA_ENTRIES + 1) {
huge.insert(SmolStr::from(std::format!("k{i}")), SmolStr::from("v"));
}
let err = c.try_with_metadata(huge).unwrap_err();
assert!(
matches!(err, ChapterError::MetadataTooLarge { got } if got == MAX_CHAPTER_METADATA_ENTRIES + 1)
);
}
#[test]
fn metadata_preserves_insertion_order() {
let c = Chapter::try_new(
Uuid7::new(),
Uuid7::new(),
0,
0,
TimeRange::new(0, 1000, tb()),
)
.expect("well-formed");
let mut bag = IndexMap::new();
bag.insert(SmolStr::from("artist"), SmolStr::from("Beethoven"));
bag.insert(SmolStr::from("genre"), SmolStr::from("classical"));
bag.insert(SmolStr::from("year"), SmolStr::from("1808"));
let c = c.try_with_metadata(bag).expect("ok");
let keys: std::vec::Vec<&str> = c.metadata_ref().keys().map(|k| k.as_str()).collect();
assert_eq!(keys, std::vec!["artist", "genre", "year"]);
}
}