1use std::{sync::Arc, time::Duration};
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use rustrails_support::runtime;
8use thiserror::Error;
9use url::Url;
10
11use crate::{
12 Blob, replace_extension,
13 service::{StorageError, StorageService},
14 sha256_hex,
15};
16
17#[derive(Debug, Error)]
19pub enum PreviewError {
20 #[error("no previewer accepted blob")]
22 Unsupported,
23 #[error(transparent)]
25 Storage(#[from] StorageError),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Preview {
31 blob: Blob,
32 key: String,
33 filename: String,
34 content_type: String,
35}
36
37impl Preview {
38 #[must_use]
40 pub fn new(blob: Blob) -> Self {
41 let digest = sha256_hex(blob.key());
42 Self {
43 blob,
44 key: format!("previews/{digest}"),
45 filename: replace_extension("preview", "png"),
46 content_type: "image/png".to_owned(),
47 }
48 }
49
50 #[must_use]
52 pub fn blob(&self) -> &Blob {
53 &self.blob
54 }
55
56 #[must_use]
58 pub fn key(&self) -> &str {
59 &self.key
60 }
61
62 #[must_use]
64 pub fn filename(&self) -> &str {
65 &self.filename
66 }
67
68 #[must_use]
70 pub fn content_type(&self) -> &str {
71 &self.content_type
72 }
73
74 pub async fn is_processed<S: StorageService + ?Sized>(
80 &self,
81 service: &S,
82 ) -> Result<bool, PreviewError> {
83 Ok(service.exists(&self.key).await?)
84 }
85
86 pub fn is_processed_sync<S: StorageService + ?Sized>(
92 &self,
93 service: &S,
94 ) -> Result<bool, PreviewError> {
95 runtime::block_on(self.is_processed(service))
96 }
97
98 pub async fn processed<S: StorageService + ?Sized>(
104 &self,
105 service: &S,
106 data: Bytes,
107 ) -> Result<Self, PreviewError> {
108 if !service.exists(&self.key).await? {
109 service.upload(&self.key, data).await?;
110 }
111 Ok(self.clone())
112 }
113
114 pub fn processed_sync<S: StorageService + ?Sized>(
120 &self,
121 service: &S,
122 data: Bytes,
123 ) -> Result<Self, PreviewError> {
124 runtime::block_on(self.processed(service, data))
125 }
126
127 pub async fn url<S: StorageService + ?Sized>(
133 &self,
134 service: &S,
135 expires_in: Duration,
136 ) -> Result<Url, PreviewError> {
137 Ok(service.url(&self.key, expires_in).await?)
138 }
139
140 pub fn url_sync<S: StorageService + ?Sized>(
146 &self,
147 service: &S,
148 expires_in: Duration,
149 ) -> Result<Url, PreviewError> {
150 runtime::block_on(self.url(service, expires_in))
151 }
152}
153
154#[async_trait]
156pub trait BlobPreviewer: Send + Sync {
157 fn name(&self) -> &str;
159
160 fn accepts(&self, blob: &Blob) -> bool;
162
163 async fn preview(&self, blob: &Blob, data: &Bytes) -> Result<Bytes, PreviewError>;
169}
170
171#[derive(Debug, Clone, Copy)]
173pub struct PdfPreviewer;
174
175#[derive(Debug, Clone, Copy)]
177pub struct VideoPreviewer;
178
179#[derive(Default, Clone)]
181pub struct PreviewRegistry {
182 previewers: Vec<Arc<dyn BlobPreviewer>>,
183}
184
185impl std::fmt::Debug for PreviewRegistry {
186 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 formatter
188 .debug_struct("PreviewRegistry")
189 .field("previewers", &self.previewers.len())
190 .finish()
191 }
192}
193
194impl PreviewRegistry {
195 #[must_use]
197 pub fn with_defaults() -> Self {
198 Self {
199 previewers: vec![Arc::new(PdfPreviewer), Arc::new(VideoPreviewer)],
200 }
201 }
202
203 pub async fn generate<S: StorageService + ?Sized>(
209 &self,
210 blob: Blob,
211 source: &Bytes,
212 service: &S,
213 ) -> Result<Preview, PreviewError> {
214 for previewer in &self.previewers {
215 if previewer.accepts(&blob) {
216 let bytes = previewer.preview(&blob, source).await?;
217 return Preview::new(blob).processed(service, bytes).await;
218 }
219 }
220 Err(PreviewError::Unsupported)
221 }
222
223 pub fn generate_sync<S: StorageService + ?Sized>(
229 &self,
230 blob: Blob,
231 source: &Bytes,
232 service: &S,
233 ) -> Result<Preview, PreviewError> {
234 runtime::block_on(self.generate(blob, source, service))
235 }
236}
237
238#[async_trait]
239impl BlobPreviewer for PdfPreviewer {
240 fn name(&self) -> &str {
241 "pdf"
242 }
243
244 fn accepts(&self, blob: &Blob) -> bool {
245 blob.content_type() == Some("application/pdf")
246 }
247
248 async fn preview(&self, blob: &Blob, _data: &Bytes) -> Result<Bytes, PreviewError> {
249 Ok(Bytes::from(
250 format!("pdf-preview:{}", blob.filename()).into_bytes(),
251 ))
252 }
253}
254
255#[async_trait]
256impl BlobPreviewer for VideoPreviewer {
257 fn name(&self) -> &str {
258 "video"
259 }
260
261 fn accepts(&self, blob: &Blob) -> bool {
262 blob.is_video()
263 }
264
265 async fn preview(&self, blob: &Blob, _data: &Bytes) -> Result<Bytes, PreviewError> {
266 Ok(Bytes::from(
267 format!("video-preview:{}", blob.filename()).into_bytes(),
268 ))
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use bytes::Bytes;
275 use rustrails_support::runtime;
276
277 use super::*;
278 use crate::{Blob, service::memory::MemoryService, test_support::run_sync_test};
279
280 fn blob(filename: &str, content_type: Option<&str>) -> Blob {
281 Blob::create(
282 Bytes::from(filename.as_bytes().to_vec()),
283 filename,
284 content_type,
285 Default::default(),
286 "memory",
287 )
288 .expect("blob should build")
289 }
290
291 #[tokio::test]
292 async fn test_pdf_previewer_generates_preview_bytes() {
293 let bytes = PdfPreviewer
294 .preview(
295 &blob("report.pdf", Some("application/pdf")),
296 &Bytes::from_static(b"%PDF-1.4"),
297 )
298 .await
299 .expect("preview should succeed");
300 assert_eq!(bytes, Bytes::from_static(b"pdf-preview:report.pdf"));
301 }
302
303 #[tokio::test]
304 async fn test_video_previewer_generates_preview_bytes() {
305 let bytes = VideoPreviewer
306 .preview(
307 &blob("movie.mp4", Some("video/mp4")),
308 &Bytes::from_static(b"mp4"),
309 )
310 .await
311 .expect("preview should succeed");
312 assert_eq!(bytes, Bytes::from_static(b"video-preview:movie.mp4"));
313 }
314
315 #[tokio::test]
316 async fn test_preview_registry_processes_pdf_preview() {
317 let registry = PreviewRegistry::with_defaults();
318 let service = MemoryService::new("memory").expect("service should build");
319 let preview = registry
320 .generate(
321 blob("report.pdf", Some("application/pdf")),
322 &Bytes::from_static(b"%PDF-1.4"),
323 &service,
324 )
325 .await
326 .expect("preview should succeed");
327 assert!(
328 service
329 .exists(preview.key())
330 .await
331 .expect("exists should succeed")
332 );
333 }
334
335 #[test]
336 fn test_preview_registry_generate_sync_processes_pdf_preview() {
337 run_sync_test(|| {
338 let registry = PreviewRegistry::with_defaults();
339 let service = MemoryService::new("memory").expect("service should build");
340 let preview = registry
341 .generate_sync(
342 blob("report.pdf", Some("application/pdf")),
343 &Bytes::from_static(b"%PDF-1.4"),
344 &service,
345 )
346 .expect("preview should succeed");
347 assert!(
348 runtime::block_on(service.exists(preview.key())).expect("exists should succeed")
349 );
350 });
351 }
352
353 #[tokio::test]
354 async fn test_preview_registry_rejects_unsupported_blob() {
355 let registry = PreviewRegistry::with_defaults();
356 let service = MemoryService::new("memory").expect("service should build");
357 let error = registry
358 .generate(
359 blob("image.png", Some("image/png")),
360 &Bytes::from_static(b"png"),
361 &service,
362 )
363 .await
364 .expect_err("preview should fail");
365 assert!(matches!(error, PreviewError::Unsupported));
366 }
367
368 #[tokio::test]
369 async fn test_preview_url_delegates_to_service() {
370 let service = MemoryService::new("memory").expect("service should build");
371 let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
372 let url = preview
373 .url(&service, Duration::from_secs(30))
374 .await
375 .expect("url should build");
376 assert!(url.as_str().contains("expires_in=30"));
377 }
378
379 #[test]
380 fn test_preview_is_processed_sync_reports_state() {
381 run_sync_test(|| {
382 let service = MemoryService::new("memory").expect("service should build");
383 let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
384 assert!(
385 !preview
386 .is_processed_sync(&service)
387 .expect("status should succeed")
388 );
389 runtime::block_on(service.upload(preview.key(), Bytes::from_static(b"preview")))
390 .expect("upload should succeed");
391 assert!(
392 preview
393 .is_processed_sync(&service)
394 .expect("status should succeed")
395 );
396 });
397 }
398
399 #[test]
400 fn test_preview_processed_sync_uploads_preview_when_missing() {
401 run_sync_test(|| {
402 let service = MemoryService::new("memory").expect("service should build");
403 let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
404
405 let processed = preview
406 .processed_sync(&service, Bytes::from_static(b"preview"))
407 .expect("processed_sync should succeed");
408
409 assert_eq!(processed, preview);
410 assert!(
411 runtime::block_on(service.exists(preview.key())).expect("exists should succeed")
412 );
413 });
414 }
415
416 #[test]
417 fn test_preview_url_sync_delegates_to_service() {
418 run_sync_test(|| {
419 let service = MemoryService::new("memory").expect("service should build");
420 let preview = Preview::new(blob("report.pdf", Some("application/pdf")));
421 let url = preview
422 .url_sync(&service, Duration::from_secs(30))
423 .expect("url_sync should build");
424 assert!(url.as_str().contains("expires_in=30"));
425 });
426 }
427}