use derive_more::IsVariant;
use jiff::Timestamp as JiffTimestamp;
use mediaframe::{
capture::{Device, GeoLocation},
container::Format,
};
use mediatime::Timestamp as MediaTimestamp;
use crate::domain::{ErrorInfo, FileChecksum, MediaErrorFlags, MediaKind, Uuid7};
#[derive(Debug, Clone, PartialEq)]
pub struct Media<Id = Uuid7> {
id: Id,
checksum: FileChecksum,
format: Format,
size: u64,
duration: Option<MediaTimestamp>,
kind: MediaKind,
nb_streams: u32,
nb_chapters: u32,
files: std::vec::Vec<Id>,
chapters: std::vec::Vec<Id>,
video_id: Option<Id>,
audio_id: Option<Id>,
subtitle_id: Option<Id>,
error_flags: MediaErrorFlags,
probe_error: Option<ErrorInfo>,
capture_date: Option<JiffTimestamp>,
device: Option<Device>,
gps: Option<GeoLocation>,
}
impl Media<Uuid7> {
pub fn try_new(
id: Uuid7,
checksum: FileChecksum,
format: Format,
size: u64,
kind: MediaKind,
) -> Result<Self, MediaError> {
if id.is_nil() {
return Err(MediaError::NilId);
}
if checksum.is_zero() {
return Err(MediaError::ZeroChecksum);
}
Ok(Self {
id,
checksum,
format,
size,
duration: None,
kind,
nb_streams: 0,
nb_chapters: 0,
files: std::vec::Vec::new(),
chapters: std::vec::Vec::new(),
video_id: None,
audio_id: None,
subtitle_id: None,
error_flags: MediaErrorFlags::new(),
probe_error: None,
capture_date: None,
device: None,
gps: None,
})
}
}
impl<Id> Media<Id> {
#[inline(always)]
pub const fn id_ref(&self) -> &Id {
&self.id
}
#[inline(always)]
pub const fn checksum_ref(&self) -> &FileChecksum {
&self.checksum
}
#[inline(always)]
pub const fn format_ref(&self) -> &Format {
&self.format
}
#[inline(always)]
pub const fn size(&self) -> u64 {
self.size
}
#[inline(always)]
pub const fn duration_ref(&self) -> Option<&MediaTimestamp> {
self.duration.as_ref()
}
#[inline(always)]
pub const fn kind(&self) -> MediaKind {
self.kind
}
#[inline(always)]
pub const fn nb_streams(&self) -> u32 {
self.nb_streams
}
#[inline(always)]
pub const fn nb_chapters(&self) -> u32 {
self.nb_chapters
}
#[inline(always)]
pub const fn files_slice(&self) -> &[Id] {
self.files.as_slice()
}
#[inline(always)]
pub const fn chapters_slice(&self) -> &[Id] {
self.chapters.as_slice()
}
#[inline(always)]
pub const fn video_id_ref(&self) -> Option<&Id> {
self.video_id.as_ref()
}
#[inline(always)]
pub const fn audio_id_ref(&self) -> Option<&Id> {
self.audio_id.as_ref()
}
#[inline(always)]
pub const fn subtitle_id_ref(&self) -> Option<&Id> {
self.subtitle_id.as_ref()
}
#[inline(always)]
pub const fn error_flags(&self) -> MediaErrorFlags {
self.error_flags
}
#[inline(always)]
pub const fn probe_error_ref(&self) -> Option<&ErrorInfo> {
self.probe_error.as_ref()
}
#[inline(always)]
pub const fn capture_date_ref(&self) -> Option<&JiffTimestamp> {
self.capture_date.as_ref()
}
#[inline(always)]
pub const fn device_ref(&self) -> Option<&Device> {
self.device.as_ref()
}
#[inline(always)]
pub const fn gps_ref(&self) -> Option<&GeoLocation> {
self.gps.as_ref()
}
#[inline]
pub fn try_with_duration(mut self, d: Option<MediaTimestamp>) -> Result<Self, MediaError> {
if let Some(ts) = d {
if ts.pts() < 0 {
return Err(MediaError::NegativeDuration);
}
}
self.duration = d;
Ok(self)
}
#[inline(always)]
#[must_use]
pub const fn with_nb_streams(mut self, n: u32) -> Self {
self.nb_streams = n;
self
}
#[inline(always)]
#[must_use]
pub const fn with_nb_chapters(mut self, n: u32) -> Self {
self.nb_chapters = n;
self
}
#[inline(always)]
#[must_use]
pub fn with_files(mut self, files: std::vec::Vec<Id>) -> Self {
self.files = files;
self
}
#[inline(always)]
#[must_use]
pub fn push_file(mut self, file: Id) -> Self {
self.files.push(file);
self
}
#[inline(always)]
#[must_use]
pub fn with_chapters(mut self, chapters: std::vec::Vec<Id>) -> Self {
self.chapters = chapters;
self
}
#[inline(always)]
#[must_use]
pub fn push_chapter(mut self, chapter: Id) -> Self {
self.chapters.push(chapter);
self
}
#[inline(always)]
#[must_use]
pub fn with_video_id(mut self, video_id: Option<Id>) -> Self {
self.video_id = video_id;
self
}
#[inline(always)]
#[must_use]
pub fn with_audio_id(mut self, audio_id: Option<Id>) -> Self {
self.audio_id = audio_id;
self
}
#[inline(always)]
#[must_use]
pub fn with_subtitle_id(mut self, subtitle_id: Option<Id>) -> Self {
self.subtitle_id = subtitle_id;
self
}
#[inline(always)]
#[must_use]
pub const fn with_error_flags(mut self, flags: MediaErrorFlags) -> Self {
self.error_flags = flags;
self
}
#[inline(always)]
#[must_use]
pub fn with_probe_error(mut self, e: Option<ErrorInfo>) -> Self {
self.probe_error = e;
self
}
#[inline(always)]
#[must_use]
pub const fn with_capture_date(mut self, t: Option<JiffTimestamp>) -> Self {
self.capture_date = t;
self
}
#[inline(always)]
#[must_use]
pub fn with_device(mut self, d: Option<Device>) -> Self {
self.device = d;
self
}
#[inline(always)]
#[must_use]
pub const fn with_gps(mut self, g: Option<GeoLocation>) -> Self {
self.gps = g;
self
}
#[inline]
pub fn try_set_duration(&mut self, d: Option<MediaTimestamp>) -> Result<&mut Self, MediaError> {
if let Some(ts) = d {
if ts.pts() < 0 {
return Err(MediaError::NegativeDuration);
}
}
self.duration = d;
Ok(self)
}
#[inline(always)]
pub const fn set_nb_streams(&mut self, n: u32) -> &mut Self {
self.nb_streams = n;
self
}
#[inline(always)]
pub const fn set_nb_chapters(&mut self, n: u32) -> &mut Self {
self.nb_chapters = n;
self
}
#[inline(always)]
pub fn set_files(&mut self, files: std::vec::Vec<Id>) -> &mut Self {
self.files = files;
self
}
#[inline(always)]
pub fn set_chapters(&mut self, chapters: std::vec::Vec<Id>) -> &mut Self {
self.chapters = chapters;
self
}
#[inline(always)]
pub fn add_chapter(&mut self, chapter: Id) -> &mut Self {
self.chapters.push(chapter);
self
}
#[inline(always)]
pub fn add_file(&mut self, file: Id) -> &mut Self {
self.files.push(file);
self
}
#[inline(always)]
pub fn set_video_id(&mut self, video_id: Option<Id>) -> &mut Self {
self.video_id = video_id;
self
}
#[inline(always)]
pub fn set_audio_id(&mut self, audio_id: Option<Id>) -> &mut Self {
self.audio_id = audio_id;
self
}
#[inline(always)]
pub fn set_subtitle_id(&mut self, subtitle_id: Option<Id>) -> &mut Self {
self.subtitle_id = subtitle_id;
self
}
#[inline(always)]
pub const fn set_error_flags(&mut self, flags: MediaErrorFlags) -> &mut Self {
self.error_flags = flags;
self
}
#[inline(always)]
pub fn set_probe_error(&mut self, e: Option<ErrorInfo>) -> &mut Self {
self.probe_error = e;
self
}
#[inline(always)]
pub const fn set_capture_date(&mut self, t: Option<JiffTimestamp>) -> &mut Self {
self.capture_date = t;
self
}
#[inline(always)]
pub fn set_device(&mut self, d: Option<Device>) -> &mut Self {
self.device = d;
self
}
#[inline(always)]
pub const fn set_gps(&mut self, g: Option<GeoLocation>) -> &mut Self {
self.gps = g;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IsVariant, thiserror::Error)]
#[non_exhaustive]
pub enum MediaError {
#[error("Media id must not be the nil UUID")]
NilId,
#[error("Media checksum must not be the all-zero sentinel (file must be probed)")]
ZeroChecksum,
#[error("Media duration must not be negative")]
NegativeDuration,
}
#[cfg(all(test, feature = "std"))]
mod tests {
use core::num::NonZeroU32;
use mediatime::Timebase;
use super::*;
fn fake_checksum() -> FileChecksum {
let mut bytes = [0u8; 32];
bytes[0] = 0x01;
FileChecksum::from_bytes(bytes)
}
fn real_ts() -> JiffTimestamp {
JiffTimestamp::from_millisecond(1_700_000_000_000).expect("valid timestamp")
}
#[test]
fn try_new_happy_path() {
let id = Uuid7::new();
let cs = fake_checksum();
let m = Media::try_new(id, cs, Format::Mp4, 12_345, MediaKind::Video)
.expect("valid construction must succeed");
assert_eq!(m.id_ref(), &id);
assert_eq!(m.checksum_ref(), &cs);
assert_eq!(m.format_ref(), &Format::Mp4);
assert_eq!(m.size(), 12_345);
assert!(m.kind().is_video());
assert!(
m.files_slice().is_empty(),
"files start empty on construction"
);
assert!(m.video_id_ref().is_none());
assert!(m.audio_id_ref().is_none());
assert!(m.subtitle_id_ref().is_none());
assert!(m.duration_ref().is_none());
assert_eq!(m.error_flags(), MediaErrorFlags::new());
assert!(m.probe_error_ref().is_none());
assert!(m.capture_date_ref().is_none());
assert!(m.device_ref().is_none());
assert!(m.gps_ref().is_none());
}
#[test]
fn try_new_rejects_nil_id() {
let r = Media::try_new(
Uuid7::nil(),
fake_checksum(),
Format::Mp4,
0,
MediaKind::Video,
);
assert_eq!(r.err(), Some(MediaError::NilId));
assert!(MediaError::NilId.is_nil_id());
}
#[test]
fn try_new_rejects_zero_checksum() {
let r = Media::try_new(
Uuid7::new(),
FileChecksum::new(),
Format::Mp4,
0,
MediaKind::Video,
);
assert_eq!(r.err(), Some(MediaError::ZeroChecksum));
assert!(MediaError::ZeroChecksum.is_zero_checksum());
}
#[test]
fn files_round_trip() {
let a = Uuid7::new();
let b = Uuid7::new();
let m = Media::try_new(
Uuid7::new(),
fake_checksum(),
Format::Mp4,
0,
MediaKind::Video,
)
.unwrap()
.push_file(a)
.with_files(vec![a, b]);
assert_eq!(m.files_slice(), &[a, b]);
let mut m = m;
let c = Uuid7::new();
m.add_file(c);
assert_eq!(m.files_slice(), &[a, b, c]);
m.set_files(vec![c]);
assert_eq!(m.files_slice(), &[c]);
}
#[test]
fn capture_date_stored_faithfully() {
let epoch = JiffTimestamp::default();
let m = Media::try_new(
Uuid7::new(),
fake_checksum(),
Format::Mp4,
0,
MediaKind::Video,
)
.unwrap()
.with_capture_date(Some(epoch));
assert_eq!(
m.capture_date_ref(),
Some(&epoch),
"Some(epoch) must be preserved faithfully"
);
let m = m.with_capture_date(Some(real_ts()));
assert_eq!(m.capture_date_ref(), Some(&real_ts()));
let m = m.with_capture_date(None);
assert!(m.capture_date_ref().is_none());
let mut m = m;
m.set_capture_date(Some(epoch));
assert_eq!(m.capture_date_ref(), Some(&epoch));
}
#[test]
fn builders_chain() {
let id = Uuid7::new();
let video_id = Uuid7::new();
let audio_id = Uuid7::new();
let gps = GeoLocation::try_new(37.7749, -122.4194, Some(20.0)).expect("valid coordinates");
let m = Media::try_new(id, fake_checksum(), Format::Mp4, 12_345, MediaKind::Video)
.unwrap()
.with_video_id(Some(video_id))
.with_audio_id(Some(audio_id))
.with_error_flags(MediaErrorFlags::VIDEO_ERROR)
.with_capture_date(Some(real_ts()))
.with_device(Some(
Device::new().with_make("Apple").with_model("iPhone 15 Pro"),
))
.with_gps(Some(gps));
assert_eq!(m.video_id_ref(), Some(&video_id));
assert_eq!(m.audio_id_ref(), Some(&audio_id));
assert!(m.subtitle_id_ref().is_none());
assert_eq!(m.error_flags(), MediaErrorFlags::VIDEO_ERROR);
assert!(m.capture_date_ref().is_some());
let dev = m.device_ref().expect("device set");
assert_eq!(dev.make(), "Apple");
assert_eq!(dev.model(), "iPhone 15 Pro");
let gps = m.gps_ref().expect("gps set");
assert_eq!(gps.lat(), 37.7749);
assert_eq!(gps.lon(), -122.4194);
assert_eq!(gps.altitude(), Some(20.0));
}
#[test]
fn setters_mutate_in_place() {
let mut m = Media::try_new(
Uuid7::new(),
fake_checksum(),
Format::Mp4,
0,
MediaKind::Video,
)
.unwrap();
m.set_video_id(Some(Uuid7::new()));
m.set_error_flags(MediaErrorFlags::AUDIO_ERROR | MediaErrorFlags::SUBTITLE_ERROR);
m.set_gps(Some(
GeoLocation::try_new(0.0, 0.0, None).expect("valid coordinates"),
));
assert!(m.video_id_ref().is_some());
assert!(m
.error_flags()
.contains(MediaErrorFlags::AUDIO_ERROR | MediaErrorFlags::SUBTITLE_ERROR));
assert_eq!(m.gps_ref().map(GeoLocation::altitude), Some(None));
}
fn sample_media() -> Media {
Media::try_new(
Uuid7::new(),
fake_checksum(),
Format::Mp4,
0,
MediaKind::Video,
)
.unwrap()
}
fn pts(value: i64) -> MediaTimestamp {
MediaTimestamp::new(value, Timebase::new(1, NonZeroU32::new(1000).unwrap()))
}
#[test]
fn try_with_duration_accepts_zero_positive_and_none() {
let m = sample_media().try_with_duration(Some(pts(0))).unwrap();
assert_eq!(m.duration_ref().map(MediaTimestamp::pts), Some(0));
let m = m.try_with_duration(Some(pts(48_000))).unwrap();
assert_eq!(m.duration_ref().map(MediaTimestamp::pts), Some(48_000));
let m = m.try_with_duration(None).unwrap();
assert!(m.duration_ref().is_none());
}
#[test]
fn try_with_duration_rejects_negative() {
let r = sample_media().try_with_duration(Some(pts(-1)));
assert_eq!(r.err(), Some(MediaError::NegativeDuration));
assert!(MediaError::NegativeDuration.is_negative_duration());
}
#[test]
fn try_set_duration_accepts_zero_positive_and_none() {
let mut m = sample_media();
m.try_set_duration(Some(pts(0))).unwrap();
assert_eq!(m.duration_ref().map(MediaTimestamp::pts), Some(0));
m.try_set_duration(Some(pts(48_000))).unwrap();
assert_eq!(m.duration_ref().map(MediaTimestamp::pts), Some(48_000));
m.try_set_duration(None).unwrap();
assert!(m.duration_ref().is_none());
}
#[test]
fn try_set_duration_rejects_negative_and_preserves_prior_value() {
let mut m = sample_media().try_with_duration(Some(pts(48_000))).unwrap();
let r = m.try_set_duration(Some(pts(-1)));
assert_eq!(r.err(), Some(MediaError::NegativeDuration));
assert_eq!(
m.duration_ref().map(MediaTimestamp::pts),
Some(48_000),
"rejected try_set_duration must not mutate the prior value"
);
}
}