mediaschema 0.2.0

Product-agnostic media-primitive schema (buffa-generated)
Documentation
//! `Media` ↔ bson `Document` mapping.
//!
//! See module-root docs for the high-level mapping rules. This file
//! covers [`Media`], its nested [`Device`] / [`GeoLocation`]
//! VOs, and the [`MediaKind`] / [`MediaErrorFlags`] discriminants.

use ::bson::{Bson, Document};
use core::str::FromStr;
use mediaframe::{
  capture::{Device, GeoLocation},
  container::Format,
};

use crate::domain::{aggregates::media::Media, enums::MediaKind, MediaErrorFlags, Uuid7};

use super::{error::MongoError, util::*};

// ---------------------------------------------------------------------------
// MediaKind / MediaErrorFlags helpers
// ---------------------------------------------------------------------------

fn media_kind_to_i32(k: MediaKind) -> i32 {
  match k {
    MediaKind::Video => 0,
    MediaKind::Audio => 1,
  }
}

fn media_kind_from_i64(v: i64, field: &'static str) -> Result<MediaKind, MongoError> {
  match v {
    0 => Ok(MediaKind::Video),
    1 => Ok(MediaKind::Audio),
    _ => Err(MongoError::IntOutOfRange {
      field: smol_str::SmolStr::from(field),
      value: v,
    }),
  }
}

// ---------------------------------------------------------------------------
// `mediaframe::capture::Device` ↔ `{ make, model }`
// ---------------------------------------------------------------------------

fn device_to_bson(d: &Device) -> Bson {
  let mut doc = Document::new();
  doc.insert("make", Bson::String(d.make().to_owned()));
  doc.insert("model", Bson::String(d.model().to_owned()));
  Bson::Document(doc)
}

fn device_from_bson(b: Bson, field: &'static str) -> Result<Device, MongoError> {
  let mut d = as_doc(b, field)?;
  let make = as_smol(take(&mut d, "make")?, "make")?;
  let model = as_smol(take(&mut d, "model")?, "model")?;
  Ok(Device::new().with_make(make).with_model(model))
}

// ---------------------------------------------------------------------------
// `mediaframe::capture::GeoLocation` ↔ `{ lat, lon, altitude }`
// ---------------------------------------------------------------------------
//
// `altitude` is now `Option<f32>` (was `Option<f64>`); persisted as a
// bson `Double`, narrowed to `f32` on the way back in. `try_new`
// re-enforces the lat/lon range invariants at the bson edge.

fn geo_to_bson(g: &GeoLocation) -> Bson {
  let mut doc = Document::new();
  doc.insert("lat", Bson::Double(g.lat()));
  doc.insert("lon", Bson::Double(g.lon()));
  doc.insert(
    "altitude",
    g.altitude()
      .map(|a| Bson::Double(a as f64))
      .unwrap_or(Bson::Null),
  );
  Bson::Document(doc)
}

fn geo_from_bson(b: Bson, field: &'static str) -> Result<GeoLocation, MongoError> {
  let mut d = as_doc(b, field)?;
  let lat = as_f64(take(&mut d, "lat")?, "lat")?;
  let lon = as_f64(take(&mut d, "lon")?, "lon")?;
  let altitude = opt(take_opt(&mut d, "altitude"), |bb| as_f32(bb, "altitude"))?;
  Ok(GeoLocation::try_new(lat, lon, altitude)?)
}

// ---------------------------------------------------------------------------
// Media
// ---------------------------------------------------------------------------

impl From<&Media<Uuid7>> for Document {
  fn from(m: &Media<Uuid7>) -> Self {
    let mut d = Document::new();
    d.insert("_id", uuid7_to_bson(*m.id_ref()));
    d.insert("checksum", checksum_to_bson(m.checksum_ref()));
    d.insert("format", Bson::String(m.format_ref().as_str().to_owned()));
    d.insert("size", Bson::Int64(m.size() as i64));
    d.insert(
      "duration",
      m.duration_ref()
        .map(|t| media_ts_to_bson(*t))
        .unwrap_or(Bson::Null),
    );
    d.insert("kind", Bson::Int32(media_kind_to_i32(m.kind())));
    d.insert("nb_streams", Bson::Int64(i64::from(m.nb_streams())));
    d.insert("nb_chapters", Bson::Int64(i64::from(m.nb_chapters())));
    d.insert(
      "video_id",
      m.video_id_ref()
        .map(|i| uuid7_to_bson(*i))
        .unwrap_or(Bson::Null),
    );
    d.insert(
      "audio_id",
      m.audio_id_ref()
        .map(|i| uuid7_to_bson(*i))
        .unwrap_or(Bson::Null),
    );
    d.insert(
      "subtitle_id",
      m.subtitle_id_ref()
        .map(|i| uuid7_to_bson(*i))
        .unwrap_or(Bson::Null),
    );
    d.insert("error_flags", Bson::Int64(m.error_flags().bits() as i64));
    d.insert(
      "probe_error",
      m.probe_error_ref()
        .map(error_info_to_bson)
        .unwrap_or(Bson::Null),
    );
    d.insert(
      "capture_date",
      m.capture_date_ref()
        .map(|t| jiff_to_bson(*t))
        .unwrap_or(Bson::Null),
    );
    d.insert(
      "device",
      m.device_ref().map(device_to_bson).unwrap_or(Bson::Null),
    );
    d.insert("gps", m.gps_ref().map(geo_to_bson).unwrap_or(Bson::Null));
    d
  }
}

impl TryFrom<Document> for Media<Uuid7> {
  type Error = MongoError;

  fn try_from(mut d: Document) -> Result<Self, Self::Error> {
    let id = uuid7_from_bson(take(&mut d, "_id")?, "_id")?;
    let checksum = checksum_from_bson(take(&mut d, "checksum")?, "checksum")?;
    // `Format: FromStr<Err = Infallible>` — unknown slugs land in
    // `Other`, so the parse is total.
    let Ok(format) = Format::from_str(&as_str(take(&mut d, "format")?, "format")?);
    let size = as_u64(take(&mut d, "size")?, "size")?;
    let kind = media_kind_from_i64(as_i64(take(&mut d, "kind")?, "kind")?, "kind")?;
    let mut m = Media::try_new(id, checksum, format, size, kind)?;

    if let Some(b) = take_opt(&mut d, "nb_streams") {
      let n = as_u64(b, "nb_streams")?;
      let n32 = u32::try_from(n).map_err(|_| MongoError::IntOutOfRange {
        field: smol_str::SmolStr::from("nb_streams"),
        value: n as i64,
      })?;
      m.set_nb_streams(n32);
    }
    if let Some(b) = take_opt(&mut d, "nb_chapters") {
      let n = as_u64(b, "nb_chapters")?;
      let n32 = u32::try_from(n).map_err(|_| MongoError::IntOutOfRange {
        field: smol_str::SmolStr::from("nb_chapters"),
        value: n as i64,
      })?;
      m.set_nb_chapters(n32);
    }
    if let Some(b) = take_opt(&mut d, "duration") {
      m.try_set_duration(Some(media_ts_from_bson(b, "duration")?))?;
    }
    if let Some(b) = take_opt(&mut d, "video_id") {
      m.set_video_id(Some(uuid7_from_bson(b, "video_id")?));
    }
    if let Some(b) = take_opt(&mut d, "audio_id") {
      m.set_audio_id(Some(uuid7_from_bson(b, "audio_id")?));
    }
    if let Some(b) = take_opt(&mut d, "subtitle_id") {
      m.set_subtitle_id(Some(uuid7_from_bson(b, "subtitle_id")?));
    }
    if let Some(b) = take_opt(&mut d, "error_flags") {
      let bits = as_u64(b, "error_flags")?;
      let bits16 = u16::try_from(bits).map_err(|_| MongoError::IntOutOfRange {
        field: smol_str::SmolStr::from("error_flags"),
        value: bits as i64,
      })?;
      m.set_error_flags(MediaErrorFlags::from_bits_truncate(bits16));
    }
    if let Some(b) = take_opt(&mut d, "probe_error") {
      m.set_probe_error(Some(error_info_from_bson(b, "probe_error")?));
    }
    if let Some(b) = take_opt(&mut d, "capture_date") {
      m.set_capture_date(Some(jiff_from_bson(b, "capture_date")?));
    }
    if let Some(b) = take_opt(&mut d, "device") {
      m.set_device(Some(device_from_bson(b, "device")?));
    }
    if let Some(b) = take_opt(&mut d, "gps") {
      m.set_gps(Some(geo_from_bson(b, "gps")?));
    }
    Ok(m)
  }
}

// ===========================================================================
// Tests
// ===========================================================================

#[cfg(test)]
mod tests {
  use super::*;
  use crate::domain::primitives::{ErrorCode, ErrorInfo, FileChecksum};
  use core::num::NonZeroU32;
  use jiff::Timestamp as JiffTimestamp;
  use mediatime::{Timebase, Timestamp as MediaTimestamp};

  fn mp4() -> Format {
    Format::Mp4
  }

  fn cs() -> FileChecksum {
    let mut b = [0u8; 32];
    b[0] = 1;
    FileChecksum::from_bytes(b)
  }

  fn tb() -> Timebase {
    Timebase::new(1, NonZeroU32::new(1000).unwrap())
  }

  #[test]
  fn media_minimal_roundtrip() {
    let m = Media::try_new(Uuid7::new(), cs(), mp4(), 12_345, MediaKind::Video).unwrap();
    let doc: Document = (&m).into();
    let m2: Media<Uuid7> = doc.try_into().unwrap();
    assert_eq!(m, m2);
  }

  #[test]
  fn media_full_roundtrip() {
    let m = Media::try_new(Uuid7::new(), cs(), mp4(), 999_999, MediaKind::Audio)
      .unwrap()
      .with_video_id(Some(Uuid7::new()))
      .with_audio_id(Some(Uuid7::new()))
      .with_subtitle_id(Some(Uuid7::new()))
      .try_with_duration(Some(MediaTimestamp::new(60_000, tb())))
      .unwrap()
      .with_error_flags(MediaErrorFlags::AUDIO_ERROR | MediaErrorFlags::SUBTITLE_ERROR)
      .with_probe_error(Some(ErrorInfo::new(ErrorCode::ProbeCorrupt, "bad header")))
      .with_capture_date(Some(JiffTimestamp::default()))
      .with_device(Some(
        Device::new().with_make("Apple").with_model("iPhone 15 Pro"),
      ))
      .with_gps(Some(
        GeoLocation::try_new(37.7749, -122.4194, Some(20.0)).unwrap(),
      ));
    let doc: Document = (&m).into();
    let m2: Media<Uuid7> = doc.try_into().unwrap();
    assert_eq!(m, m2);
  }

  #[test]
  fn media_missing_id_errors() {
    let mut d = Document::new();
    d.insert("checksum", checksum_to_bson(&cs()));
    let err = Media::<Uuid7>::try_from(d).unwrap_err();
    assert!(err.is_missing_field());
  }

  #[test]
  fn media_zero_checksum_rejected() {
    let mut d = Document::new();
    d.insert("_id", uuid7_to_bson(Uuid7::new()));
    d.insert("checksum", checksum_to_bson(&FileChecksum::new()));
    d.insert("format", "mp4");
    d.insert("size", Bson::Int64(0));
    d.insert("kind", Bson::Int32(0));
    let err = Media::<Uuid7>::try_from(d).unwrap_err();
    assert!(err.is_media());
  }
}