use derive_more::IsVariant;
use jiff::Timestamp as JiffTimestamp;
use crate::domain::{Location, Uuid7, WatchedLocation};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MediaFile<Id = Uuid7> {
id: Id,
media_id: Id,
created_at: Option<JiffTimestamp>,
location: Location<Id>,
watched_location_id: Id,
watch_volume: Id,
}
impl MediaFile<Uuid7> {
pub fn try_new(
id: Uuid7,
media_id: Uuid7,
created_at: Option<JiffTimestamp>,
location: Location<Uuid7>,
watched_location: &WatchedLocation<Uuid7>,
) -> Result<Self, MediaFileError> {
if id.is_nil() {
return Err(MediaFileError::NilId);
}
if media_id.is_nil() {
return Err(MediaFileError::NilMediaId);
}
let watched_location_id = *watched_location.id_ref();
if watched_location_id.is_nil() {
return Err(MediaFileError::NilWatchedLocationId);
}
let watch_volume = *watched_location.volume_ref();
if location_volume(&location) != &watch_volume {
return Err(MediaFileError::VolumeMismatch);
}
Ok(Self {
id,
media_id,
created_at,
location,
watched_location_id,
watch_volume,
})
}
}
impl<Id> MediaFile<Id> {
#[inline(always)]
#[must_use]
pub const fn from_parts(
id: Id,
media_id: Id,
created_at: Option<JiffTimestamp>,
location: Location<Id>,
watched_location_id: Id,
watch_volume: Id,
) -> Self {
Self {
id,
media_id,
created_at,
location,
watched_location_id,
watch_volume,
}
}
#[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]
pub fn name(&self) -> &str {
match &self.location {
Location::Local(local) => local.file_name(),
}
}
#[inline(always)]
pub const fn created_at_ref(&self) -> Option<&JiffTimestamp> {
self.created_at.as_ref()
}
#[inline(always)]
pub const fn location_ref(&self) -> &Location<Id> {
&self.location
}
#[inline(always)]
pub const fn watched_location_id_ref(&self) -> &Id {
&self.watched_location_id
}
#[inline(always)]
pub const fn watch_volume_ref(&self) -> &Id {
&self.watch_volume
}
#[inline(always)]
#[must_use]
pub const fn with_created_at(mut self, t: Option<JiffTimestamp>) -> Self {
self.created_at = t;
self
}
#[inline]
pub fn try_with_location(mut self, location: Location<Id>) -> Result<Self, MediaFileError>
where
Id: PartialEq,
{
self.try_set_location(location)?;
Ok(self)
}
#[inline(always)]
#[must_use]
pub fn with_media_id(mut self, media_id: Id) -> Self {
self.media_id = media_id;
self
}
#[inline]
pub fn try_with_watched_location(
mut self,
watched_location: &WatchedLocation<Id>,
) -> Result<Self, MediaFileError>
where
Id: Clone + PartialEq,
{
self.try_set_watched_location(watched_location)?;
Ok(self)
}
#[inline(always)]
pub const fn set_created_at(&mut self, t: Option<JiffTimestamp>) -> &mut Self {
self.created_at = t;
self
}
#[inline]
pub fn try_set_location(&mut self, location: Location<Id>) -> Result<&mut Self, MediaFileError>
where
Id: PartialEq,
{
if location_volume(&location) != &self.watch_volume {
return Err(MediaFileError::VolumeMismatch);
}
self.location = location;
Ok(self)
}
#[inline(always)]
pub fn set_media_id(&mut self, media_id: Id) -> &mut Self {
self.media_id = media_id;
self
}
#[inline]
pub fn try_set_watched_location(
&mut self,
watched_location: &WatchedLocation<Id>,
) -> Result<&mut Self, MediaFileError>
where
Id: Clone + PartialEq,
{
if location_volume(&self.location) != watched_location.volume_ref() {
return Err(MediaFileError::VolumeMismatch);
}
self.watched_location_id = watched_location.id_ref().clone();
self.watch_volume = watched_location.volume_ref().clone();
Ok(self)
}
}
#[inline]
fn location_volume<Id>(location: &Location<Id>) -> &Id {
match location {
Location::Local(local) => local.volume_ref(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IsVariant, thiserror::Error)]
#[non_exhaustive]
pub enum MediaFileError {
#[error("MediaFile id must not be the nil UUID")]
NilId,
#[error("MediaFile media_id (Media) must not be the nil UUID")]
NilMediaId,
#[error("MediaFile watched_location_id must not be the nil UUID")]
NilWatchedLocationId,
#[error("MediaFile location volume must match its WatchedLocation volume")]
VolumeMismatch,
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
fn real_ts() -> JiffTimestamp {
JiffTimestamp::from_millisecond(1_700_000_000_000).expect("valid timestamp")
}
fn loc(volume: Uuid7) -> Location<Uuid7> {
Location::try_local_uuid7(volume, ["Movies", "clip.mp4"]).expect("valid location")
}
fn named_loc(volume: Uuid7, name: &str) -> Location<Uuid7> {
Location::try_local_uuid7(volume, ["Movies", name]).expect("valid location")
}
fn watch(volume: Uuid7) -> WatchedLocation<Uuid7> {
WatchedLocation::try_new(Uuid7::new(), volume, JiffTimestamp::default()).expect("valid watch")
}
#[test]
fn try_new_happy_path() {
let id = Uuid7::new();
let media_id = Uuid7::new();
let vol = Uuid7::new();
let wl = watch(vol);
let f = MediaFile::try_new(id, media_id, Some(real_ts()), loc(vol), &wl)
.expect("valid construction must succeed");
assert_eq!(f.id_ref(), &id);
assert_eq!(f.media_id_ref(), &media_id);
assert_eq!(f.name(), "clip.mp4");
assert_eq!(f.created_at_ref(), Some(&real_ts()));
assert_eq!(f.watched_location_id_ref(), wl.id_ref());
assert_eq!(f.watch_volume_ref(), &vol);
assert!(f.location_ref().is_local());
let local = f.location_ref().unwrap_local_ref();
assert_eq!(local.volume_ref(), &vol);
assert_eq!(local.components_slice(), &["Movies", "clip.mp4"]);
}
#[test]
fn from_parts_round_trips_a_validated_instance() {
let vol = Uuid7::new();
let wl = watch(vol);
let original = MediaFile::try_new(Uuid7::new(), Uuid7::new(), Some(real_ts()), loc(vol), &wl)
.expect("valid construction must succeed");
let rebuilt = MediaFile::from_parts(
*original.id_ref(),
*original.media_id_ref(),
original.created_at_ref().copied(),
original.location_ref().clone(),
*original.watched_location_id_ref(),
*original.watch_volume_ref(),
);
assert_eq!(rebuilt, original);
}
#[test]
fn name_is_derived_from_location() {
let vol = Uuid7::new();
let wl = watch(vol);
let f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
None,
named_loc(vol, "holiday.mkv"),
&wl,
)
.unwrap();
assert_eq!(f.name(), "holiday.mkv");
let f = f
.try_with_location(named_loc(vol, "renamed.mp4"))
.expect("same-volume rename must succeed");
assert_eq!(f.name(), "renamed.mp4");
}
#[test]
fn created_at_stored_faithfully() {
let epoch = JiffTimestamp::default();
let vol = Uuid7::new();
let wl = watch(vol);
let f = MediaFile::try_new(Uuid7::new(), Uuid7::new(), Some(epoch), loc(vol), &wl).unwrap();
assert_eq!(
f.created_at_ref(),
Some(&epoch),
"Some(epoch) must be preserved faithfully"
);
let f = f.with_created_at(None);
assert!(f.created_at_ref().is_none());
let f = f.with_created_at(Some(real_ts()));
assert_eq!(f.created_at_ref(), Some(&real_ts()));
let mut f = f;
f.set_created_at(Some(epoch));
assert_eq!(f.created_at_ref(), Some(&epoch));
}
#[test]
fn try_new_rejects_nil_id() {
let vol = Uuid7::new();
let r = MediaFile::try_new(
Uuid7::nil(),
Uuid7::new(),
Some(real_ts()),
loc(vol),
&watch(vol),
);
assert_eq!(r.err(), Some(MediaFileError::NilId));
assert!(MediaFileError::NilId.is_nil_id());
}
#[test]
fn try_new_rejects_nil_media_id() {
let vol = Uuid7::new();
let r = MediaFile::try_new(
Uuid7::new(),
Uuid7::nil(),
Some(real_ts()),
loc(vol),
&watch(vol),
);
assert_eq!(r.err(), Some(MediaFileError::NilMediaId));
assert!(MediaFileError::NilMediaId.is_nil_media_id());
}
#[test]
fn nil_watched_location_id_variant_is_present_and_displays() {
let e = MediaFileError::NilWatchedLocationId;
assert!(e.is_nil_watched_location_id());
assert_eq!(
format!("{e}"),
"MediaFile watched_location_id must not be the nil UUID",
);
}
#[test]
fn try_new_rejects_cross_volume_watch() {
let file_vol = Uuid7::new();
let watch_vol = Uuid7::new();
let r = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
Some(real_ts()),
loc(file_vol),
&watch(watch_vol),
);
assert_eq!(r.err(), Some(MediaFileError::VolumeMismatch));
assert!(MediaFileError::VolumeMismatch.is_volume_mismatch());
}
#[test]
fn try_set_location_rejects_cross_volume_move() {
let vol = Uuid7::new();
let wl = watch(vol);
let mut f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
None,
named_loc(vol, "clip.mp4"),
&wl,
)
.unwrap();
let other_vol = Uuid7::new();
let r = f.try_set_location(named_loc(other_vol, "moved.mp4"));
assert_eq!(r, Err(MediaFileError::VolumeMismatch));
assert_eq!(f.name(), "clip.mp4", "rejected move must not mutate self");
assert_eq!(f.location_ref().unwrap_local_ref().volume_ref(), &vol);
f.try_set_location(named_loc(vol, "renamed.mp4"))
.expect("same-volume move must succeed");
assert_eq!(f.name(), "renamed.mp4");
let r = f.clone().try_with_location(named_loc(other_vol, "x.mp4"));
assert_eq!(r.err(), Some(MediaFileError::VolumeMismatch));
}
#[test]
fn try_set_watched_location_rejects_cross_volume_watch() {
let vol = Uuid7::new();
let wl = watch(vol);
let mut f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
None,
named_loc(vol, "clip.mp4"),
&wl,
)
.unwrap();
let other = watch(Uuid7::new());
let r = f.try_set_watched_location(&other);
assert_eq!(r, Err(MediaFileError::VolumeMismatch));
assert_eq!(
f.watched_location_id_ref(),
wl.id_ref(),
"rejected re-point must not mutate"
);
let same_vol_watch = watch(vol);
f.try_set_watched_location(&same_vol_watch)
.expect("same-volume re-point must succeed");
assert_eq!(f.watched_location_id_ref(), same_vol_watch.id_ref());
assert_eq!(f.watch_volume_ref(), &vol);
}
#[test]
fn builders_chain() {
let media_id = Uuid7::new();
let vol = Uuid7::new();
let wl = watch(vol);
let f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
None,
named_loc(vol, "old.mp4"),
&wl,
)
.unwrap()
.with_media_id(media_id)
.try_with_location(named_loc(vol, "new.mkv"))
.unwrap()
.with_created_at(Some(real_ts()));
assert_eq!(f.name(), "new.mkv");
assert_eq!(f.media_id_ref(), &media_id);
assert_eq!(f.created_at_ref(), Some(&real_ts()));
assert_eq!(f.watched_location_id_ref(), wl.id_ref());
assert_eq!(f.location_ref().unwrap_local_ref().volume_ref(), &vol);
}
#[test]
fn setters_mutate_in_place() {
let vol = Uuid7::new();
let wl = watch(vol);
let mut f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
Some(real_ts()),
named_loc(vol, "clip.mp4"),
&wl,
)
.unwrap();
let media_id = Uuid7::new();
f.set_media_id(media_id);
f.try_set_location(named_loc(vol, "renamed.mp4")).unwrap();
f.set_created_at(None);
assert_eq!(f.name(), "renamed.mp4");
assert_eq!(f.media_id_ref(), &media_id);
assert!(f.created_at_ref().is_none());
assert_eq!(f.location_ref().unwrap_local_ref().volume_ref(), &vol);
}
}