use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use bytes::Bytes;
use rustrails_support::runtime;
use thiserror::Error;
use url::Url;
use crate::{
Blob, replace_extension,
service::{StorageError, StorageService},
sha256_hex,
};
#[derive(Debug, Error)]
pub enum PreviewError {
#[error("no previewer accepted blob")]
Unsupported,
#[error(transparent)]
Storage(#[from] StorageError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Preview {
blob: Blob,
key: String,
filename: String,
content_type: String,
}
impl Preview {
#[must_use]
pub fn new(blob: Blob) -> Self {
let digest = sha256_hex(blob.key());
Self {
blob,
key: format!("previews/{digest}"),
filename: replace_extension("preview", "png"),
content_type: "image/png".to_owned(),
}
}
#[must_use]
pub fn blob(&self) -> &Blob {
&self.blob
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn filename(&self) -> &str {
&self.filename
}
#[must_use]
pub fn content_type(&self) -> &str {
&self.content_type
}
pub async fn is_processed<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<bool, PreviewError> {
Ok(service.exists(&self.key).await?)
}
pub fn is_processed_sync<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<bool, PreviewError> {
runtime::block_on(self.is_processed(service))
}
pub async fn processed<S: StorageService + ?Sized>(
&self,
service: &S,
data: Bytes,
) -> Result<Self, PreviewError> {
if !service.exists(&self.key).await? {
service.upload(&self.key, data).await?;
}
Ok(self.clone())
}
pub fn processed_sync<S: StorageService + ?Sized>(
&self,
service: &S,
data: Bytes,
) -> Result<Self, PreviewError> {
runtime::block_on(self.processed(service, data))
}
pub async fn url<S: StorageService + ?Sized>(
&self,
service: &S,
expires_in: Duration,
) -> Result<Url, PreviewError> {
Ok(service.url(&self.key, expires_in).await?)
}
pub fn url_sync<S: StorageService + ?Sized>(
&self,
service: &S,
expires_in: Duration,
) -> Result<Url, PreviewError> {
runtime::block_on(self.url(service, expires_in))
}
}
#[async_trait]
pub trait BlobPreviewer: Send + Sync {
fn name(&self) -> &str;
fn accepts(&self, blob: &Blob) -> bool;
async fn preview(&self, blob: &Blob, data: &Bytes) -> Result<Bytes, PreviewError>;
}
#[derive(Debug, Clone, Copy)]
pub struct PdfPreviewer;
#[derive(Debug, Clone, Copy)]
pub struct VideoPreviewer;
#[derive(Default, Clone)]
pub struct PreviewRegistry {
previewers: Vec<Arc<dyn BlobPreviewer>>,
}
impl std::fmt::Debug for PreviewRegistry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PreviewRegistry")
.field("previewers", &self.previewers.len())
.finish()
}
}
impl PreviewRegistry {
#[must_use]
pub fn with_defaults() -> Self {
Self {
previewers: vec![Arc::new(PdfPreviewer), Arc::new(VideoPreviewer)],
}
}
pub async fn generate<S: StorageService + ?Sized>(
&self,
blob: Blob,
source: &Bytes,
service: &S,
) -> Result<Preview, PreviewError> {
for previewer in &self.previewers {
if previewer.accepts(&blob) {
let bytes = previewer.preview(&blob, source).await?;
return Preview::new(blob).processed(service, bytes).await;
}
}
Err(PreviewError::Unsupported)
}
pub fn generate_sync<S: StorageService + ?Sized>(
&self,
blob: Blob,
source: &Bytes,
service: &S,
) -> Result<Preview, PreviewError> {
runtime::block_on(self.generate(blob, source, service))
}
}
#[async_trait]
impl BlobPreviewer for PdfPreviewer {
fn name(&self) -> &str {
"pdf"
}
fn accepts(&self, blob: &Blob) -> bool {
blob.content_type() == Some("application/pdf")
}
async fn preview(&self, blob: &Blob, _data: &Bytes) -> Result<Bytes, PreviewError> {
Ok(Bytes::from(
format!("pdf-preview:{}", blob.filename()).into_bytes(),
))
}
}
#[async_trait]
impl BlobPreviewer for VideoPreviewer {
fn name(&self) -> &str {
"video"
}
fn accepts(&self, blob: &Blob) -> bool {
blob.is_video()
}
async fn preview(&self, blob: &Blob, _data: &Bytes) -> Result<Bytes, PreviewError> {
Ok(Bytes::from(
format!("video-preview:{}", blob.filename()).into_bytes(),
))
}
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use rustrails_support::runtime;
use super::*;
use crate::{Blob, service::memory::MemoryService, test_support::run_sync_test};
fn blob(filename: &str, content_type: Option<&str>) -> Blob {
Blob::create(
Bytes::from(filename.as_bytes().to_vec()),
filename,
content_type,
Default::default(),
"memory",
)
.expect("blob should build")
}
#[tokio::test]
async fn test_pdf_previewer_generates_preview_bytes() {
let bytes = PdfPreviewer
.preview(
&blob("report.pdf", Some("application/pdf")),
&Bytes::from_static(b"%PDF-1.4"),
)
.await
.expect("preview should succeed");
assert_eq!(bytes, Bytes::from_static(b"pdf-preview:report.pdf"));
}
#[tokio::test]
async fn test_video_previewer_generates_preview_bytes() {
let bytes = VideoPreviewer
.preview(
&blob("movie.mp4", Some("video/mp4")),
&Bytes::from_static(b"mp4"),
)
.await
.expect("preview should succeed");
assert_eq!(bytes, Bytes::from_static(b"video-preview:movie.mp4"));
}
#[tokio::test]
async fn test_preview_registry_processes_pdf_preview() {
let registry = PreviewRegistry::with_defaults();
let service = MemoryService::new("memory").expect("service should build");
let preview = registry
.generate(
blob("report.pdf", Some("application/pdf")),
&Bytes::from_static(b"%PDF-1.4"),
&service,
)
.await
.expect("preview should succeed");
assert!(
service
.exists(preview.key())
.await
.expect("exists should succeed")
);
}
#[test]
fn test_preview_registry_generate_sync_processes_pdf_preview() {
run_sync_test(|| {
let registry = PreviewRegistry::with_defaults();
let service = MemoryService::new("memory").expect("service should build");
let preview = registry
.generate_sync(
blob("report.pdf", Some("application/pdf")),
&Bytes::from_static(b"%PDF-1.4"),
&service,
)
.expect("preview should succeed");
assert!(
runtime::block_on(service.exists(preview.key())).expect("exists should succeed")
);
});
}
#[tokio::test]
async fn test_preview_registry_rejects_unsupported_blob() {
let registry = PreviewRegistry::with_defaults();
let service = MemoryService::new("memory").expect("service should build");
let error = registry
.generate(
blob("image.png", Some("image/png")),
&Bytes::from_static(b"png"),
&service,
)
.await
.expect_err("preview should fail");
assert!(matches!(error, PreviewError::Unsupported));
}
#[tokio::test]
async fn test_preview_url_delegates_to_service() {
let service = MemoryService::new("memory").expect("service should build");
let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
let url = preview
.url(&service, Duration::from_secs(30))
.await
.expect("url should build");
assert!(url.as_str().contains("expires_in=30"));
}
#[test]
fn test_preview_is_processed_sync_reports_state() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
assert!(
!preview
.is_processed_sync(&service)
.expect("status should succeed")
);
runtime::block_on(service.upload(preview.key(), Bytes::from_static(b"preview")))
.expect("upload should succeed");
assert!(
preview
.is_processed_sync(&service)
.expect("status should succeed")
);
});
}
#[test]
fn test_preview_processed_sync_uploads_preview_when_missing() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
let processed = preview
.processed_sync(&service, Bytes::from_static(b"preview"))
.expect("processed_sync should succeed");
assert_eq!(processed, preview);
assert!(
runtime::block_on(service.exists(preview.key())).expect("exists should succeed")
);
});
}
#[test]
fn test_preview_url_sync_delegates_to_service() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
let url = preview
.url_sync(&service, Duration::from_secs(30))
.expect("url_sync should build");
assert!(url.as_str().contains("expires_in=30"));
});
}
}