use chrono::{SecondsFormat, Utc};
use futures::TryStreamExt;
use laterite_core::strata::*;
use laterite_core::Db;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::image::{self, ImageError};
use crate::ingest::blob_path;
use crate::preset::Preset;
use crate::record::{MediaRecord, RecordError};
use crate::{Kind, StorageDriver, StorageError};
#[derive(Iden)]
pub enum MediaVariants {
Table,
Id,
MediaId,
Preset,
Path,
Width,
Height,
Size,
ContentType,
CreatedAt,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Variant {
pub id: i64,
pub media_id: i64,
pub preset: String,
pub path: String,
pub width: Option<i64>,
pub height: Option<i64>,
pub size: i64,
pub content_type: String,
pub created_at: String,
}
#[derive(Debug, thiserror::Error)]
pub enum VariantError {
#[error(transparent)]
Record(#[from] RecordError),
#[error(transparent)]
Storage(#[from] StorageError),
#[error(transparent)]
Image(#[from] ImageError),
#[error("the file is not an image")]
NotImage,
#[error("the blob could not be read: {0}")]
Read(String),
}
const COLUMNS: [MediaVariants; 9] = [
MediaVariants::Id,
MediaVariants::MediaId,
MediaVariants::Preset,
MediaVariants::Path,
MediaVariants::Width,
MediaVariants::Height,
MediaVariants::Size,
MediaVariants::ContentType,
MediaVariants::CreatedAt,
];
fn from_row(r: &sqlx::any::AnyRow) -> Result<Variant, sqlx::Error> {
Ok(Variant {
id: r.get_int("id")?,
media_id: r.get_int("media_id")?,
preset: r.get_text("preset")?,
path: r.get_text("path")?,
width: r.get_int_opt("width")?,
height: r.get_int_opt("height")?,
size: r.get_int("size")?,
content_type: r.get_text("content_type")?,
created_at: r.get_text("created_at")?,
})
}
pub fn variant_path(hash: &str, preset: &str, extension: &str) -> String {
format!(
"variants/{}/{}/{hash}/{preset}.{extension}",
&hash[0..2],
&hash[2..4]
)
}
pub async fn find(db: &Db, media_id: i64, preset: &str) -> Result<Option<Variant>, RecordError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns(COLUMNS)
.from(MediaVariants::Table)
.and_where(Expr::col(MediaVariants::MediaId).eq(media_id))
.and_where(Expr::col(MediaVariants::Preset).eq(preset))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(row.as_ref().map(from_row).transpose()?)
}
pub async fn list_for(db: &Db, media_id: i64) -> Result<Vec<Variant>, RecordError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns(COLUMNS)
.from(MediaVariants::Table)
.and_where(Expr::col(MediaVariants::MediaId).eq(media_id))
.order_by(MediaVariants::Id, Order::Asc)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
Ok(rows.iter().map(from_row).collect::<Result<_, _>>()?)
}
pub async fn delete_for(db: &Db, media_id: i64) -> Result<u64, RecordError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(MediaVariants::Table)
.and_where(Expr::col(MediaVariants::MediaId).eq(media_id))
.to_owned(),
);
Ok(bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?
.rows_affected())
}
async fn insert(
db: &Db,
media_id: i64,
preset: &str,
path: &str,
rendered: &image::Rendered,
) -> Result<Variant, RecordError> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true);
let stmt = Query::insert()
.into_table(MediaVariants::Table)
.columns([
MediaVariants::MediaId,
MediaVariants::Preset,
MediaVariants::Path,
MediaVariants::Width,
MediaVariants::Height,
MediaVariants::Size,
MediaVariants::ContentType,
MediaVariants::CreatedAt,
])
.values_panic([
media_id.into(),
preset.into(),
path.into(),
i64::from(rendered.width).into(),
i64::from(rendered.height).into(),
(rendered.bytes.len() as i64).into(),
rendered.content_type.into(),
now.clone().into(),
])
.to_owned();
let id = insert_returning_id(db, stmt, MediaVariants::Id).await?;
Ok(Variant {
id,
media_id,
preset: preset.to_string(),
path: path.to_string(),
width: Some(i64::from(rendered.width)),
height: Some(i64::from(rendered.height)),
size: rendered.bytes.len() as i64,
content_type: rendered.content_type.to_string(),
created_at: now,
})
}
#[derive(Default)]
pub struct Variants {
locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
}
impl Variants {
pub fn new() -> Self {
Self::default()
}
pub async fn ensure(
&self,
db: &Db,
disk: &dyn StorageDriver,
record: &MediaRecord,
preset: &Preset,
) -> Result<Variant, VariantError> {
if record.kind() != Kind::Image || record.content_type == "image/svg+xml" {
return Err(VariantError::NotImage);
}
if let Some(v) = find(db, record.id, &preset.name).await? {
return Ok(v);
}
let key = format!("{}:{}:{}", record.disk, record.hash, preset.name);
let lock = {
let mut locks = self.locks.lock().await;
Arc::clone(locks.entry(key.clone()).or_default())
};
let result = {
let _held = lock.lock().await;
match find(db, record.id, &preset.name).await? {
Some(v) => Ok(v),
None => make(db, disk, record, preset).await,
}
};
let mut locks = self.locks.lock().await;
if Arc::strong_count(&lock) <= 2 {
locks.remove(&key);
}
result
}
}
async fn make(
db: &Db,
disk: &dyn StorageDriver,
record: &MediaRecord,
preset: &Preset,
) -> Result<Variant, VariantError> {
let mut stream = disk.get(&blob_path(&record.hash)).await?;
let mut bytes: Vec<u8> = Vec::with_capacity(usize::try_from(record.size).unwrap_or(0));
while let Some(chunk) = stream
.try_next()
.await
.map_err(|e| VariantError::Read(e.to_string()))?
{
bytes.extend_from_slice(&chunk);
}
let orientation = record
.meta
.get("orientation")
.and_then(|o| o.as_u64())
.and_then(|o| u8::try_from(o).ok())
.unwrap_or(1);
let preset_owned = preset.clone();
let rendered =
tokio::task::spawn_blocking(move || image::render(&bytes, &preset_owned, orientation))
.await
.map_err(|e| VariantError::Read(e.to_string()))??;
let path = variant_path(&record.hash, &preset.name, rendered.extension);
disk.put(&path, crate::stream_of(rendered.bytes.clone()))
.await?;
Ok(insert(db, record.id, &preset.name, &path, &rendered).await?)
}