use crate::{
domain::{Location, MediaFile, Uuid7},
sqlx::{
dto::{bytes_to_uuid7, millis_to_timestamp, timestamp_to_millis, uuid7_to_uuid},
SqlxError,
},
};
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct SqliteMediaFileRow {
pub id: std::vec::Vec<u8>,
pub media_id: std::vec::Vec<u8>,
pub created_at_ms: Option<i64>,
pub location_volume: std::vec::Vec<u8>,
pub location_path: String,
pub watched_location_id: std::vec::Vec<u8>,
pub watch_volume: std::vec::Vec<u8>,
}
fn location_path(location: &Location<Uuid7>) -> String {
match location {
Location::Local(local) => local
.components_slice()
.iter()
.map(AsRef::as_ref)
.collect::<std::vec::Vec<&str>>()
.join("/"),
}
}
impl From<&MediaFile<Uuid7>> for SqliteMediaFileRow {
fn from(f: &MediaFile<Uuid7>) -> Self {
let location_volume = match f.location_ref() {
Location::Local(local) => *local.volume_ref(),
};
Self {
id: uuid7_to_uuid(*f.id_ref()).as_bytes().to_vec(),
media_id: uuid7_to_uuid(*f.media_id_ref()).as_bytes().to_vec(),
created_at_ms: f.created_at_ref().map(|t| timestamp_to_millis(*t)),
location_volume: uuid7_to_uuid(location_volume).as_bytes().to_vec(),
location_path: location_path(f.location_ref()),
watched_location_id: uuid7_to_uuid(*f.watched_location_id_ref())
.as_bytes()
.to_vec(),
watch_volume: uuid7_to_uuid(*f.watch_volume_ref()).as_bytes().to_vec(),
}
}
}
impl TryFrom<SqliteMediaFileRow> for MediaFile<Uuid7> {
type Error = SqlxError;
fn try_from(r: SqliteMediaFileRow) -> Result<Self, Self::Error> {
let id = bytes_to_uuid7(&r.id)?;
let media_id = bytes_to_uuid7(&r.media_id)?;
let location_volume = bytes_to_uuid7(&r.location_volume)?;
let watched_location_id = bytes_to_uuid7(&r.watched_location_id)?;
let watch_volume = bytes_to_uuid7(&r.watch_volume)?;
let created_at = match r.created_at_ms {
None => None,
Some(ms) => Some(millis_to_timestamp(ms)?),
};
let location = Location::try_local_uuid7(location_volume, r.location_path.split('/'))
.map_err(|e| SqlxError::DomainConstructorRejected(format!("MediaFile.location: {e}")))?;
Ok(MediaFile::from_parts(
id,
media_id,
created_at,
location,
watched_location_id,
watch_volume,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct SqliteMediaFileRowRef<'r> {
pub id: &'r [u8],
pub media_id: &'r [u8],
pub created_at_ms: Option<i64>,
pub location_volume: &'r [u8],
pub location_path: &'r str,
pub watched_location_id: &'r [u8],
pub watch_volume: &'r [u8],
}
impl SqliteMediaFileRow {
pub fn as_ref(&self) -> SqliteMediaFileRowRef<'_> {
SqliteMediaFileRowRef {
id: &self.id,
media_id: &self.media_id,
created_at_ms: self.created_at_ms,
location_volume: &self.location_volume,
location_path: &self.location_path,
watched_location_id: &self.watched_location_id,
watch_volume: &self.watch_volume,
}
}
}
impl<'r> TryFrom<SqliteMediaFileRowRef<'r>> for MediaFile<Uuid7> {
type Error = SqlxError;
fn try_from(r: SqliteMediaFileRowRef<'r>) -> Result<Self, Self::Error> {
let id = bytes_to_uuid7(r.id)?;
let media_id = bytes_to_uuid7(r.media_id)?;
let location_volume = bytes_to_uuid7(r.location_volume)?;
let watched_location_id = bytes_to_uuid7(r.watched_location_id)?;
let watch_volume = bytes_to_uuid7(r.watch_volume)?;
let created_at = match r.created_at_ms {
None => None,
Some(ms) => Some(millis_to_timestamp(ms)?),
};
let location = Location::try_local_uuid7(location_volume, r.location_path.split('/'))
.map_err(|e| SqlxError::DomainConstructorRejected(format!("MediaFile.location: {e}")))?;
Ok(MediaFile::from_parts(
id,
media_id,
created_at,
location,
watched_location_id,
watch_volume,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::WatchedLocation;
use jiff::Timestamp as JiffTimestamp;
#[test]
fn media_file_roundtrip() {
let vol = Uuid7::new();
let wl = WatchedLocation::try_new(Uuid7::new(), vol, JiffTimestamp::default()).unwrap();
let location = Location::try_local_uuid7(vol, ["Movies", "2024", "clip.mp4"]).unwrap();
let f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
Some(JiffTimestamp::from_millisecond(1_700_000_000_000).unwrap()),
location,
&wl,
)
.unwrap();
let row: SqliteMediaFileRow = (&f).into();
let f2: MediaFile<Uuid7> = row.try_into().unwrap();
assert_eq!(f, f2);
assert_eq!(f2.name(), "clip.mp4");
}
#[test]
fn media_file_ref_roundtrip() {
let vol = Uuid7::new();
let wl = WatchedLocation::try_new(Uuid7::new(), vol, JiffTimestamp::default()).unwrap();
let location = Location::try_local_uuid7(vol, ["Movies", "2024", "clip.mp4"]).unwrap();
let f = MediaFile::try_new(
Uuid7::new(),
Uuid7::new(),
Some(JiffTimestamp::from_millisecond(1_700_000_000_000).unwrap()),
location,
&wl,
)
.unwrap();
let row: SqliteMediaFileRow = (&f).into();
let f2: MediaFile<Uuid7> = row.as_ref().try_into().unwrap();
assert_eq!(f, f2);
}
#[test]
fn media_file_roundtrip_without_created_at() {
let vol = Uuid7::new();
let wl = WatchedLocation::try_new(Uuid7::new(), vol, JiffTimestamp::default()).unwrap();
let location = Location::try_local_uuid7(vol, ["loose.mkv"]).unwrap();
let f = MediaFile::try_new(Uuid7::new(), Uuid7::new(), None, location, &wl).unwrap();
let row: SqliteMediaFileRow = (&f).into();
assert!(row.created_at_ms.is_none());
let f2: MediaFile<Uuid7> = row.try_into().unwrap();
assert_eq!(f, f2);
}
}