backbone_bucket/application/service/
video_thumbnail_service.rs1use std::sync::Arc;
6
7use chrono::Utc;
8use uuid::Uuid;
9
10use super::error::{ServiceError, ServiceResult};
11use crate::domain::entity::{
12 ProcessingJob, ProcessingJobType, JobStatus, Thumbnail, ThumbnailSize,
13};
14use crate::infrastructure::persistence::{
15 ProcessingJobRepository, ThumbnailRepository, StoredFileRepository,
16};
17
18const VIDEO_MIME_PREFIXES: &[&str] = &["video/"];
19
20const DEFAULT_VIDEO_THUMBNAIL_SIZES: &[(ThumbnailSize, i32, i32)] = &[
21 (ThumbnailSize::Small, 64, 64),
22 (ThumbnailSize::Medium, 128, 128),
23 (ThumbnailSize::Large, 256, 256),
24];
25
26pub struct VideoThumbnailService {
27 job_repo: Arc<ProcessingJobRepository>,
28 thumb_repo: Arc<ThumbnailRepository>,
29 file_repo: Arc<StoredFileRepository>,
30}
31
32impl VideoThumbnailService {
33 pub fn new(
34 job_repo: Arc<ProcessingJobRepository>,
35 thumb_repo: Arc<ThumbnailRepository>,
36 file_repo: Arc<StoredFileRepository>,
37 ) -> Self {
38 Self { job_repo, thumb_repo, file_repo }
39 }
40
41 pub async fn enqueue(&self, file_id: Uuid, priority: Option<i32>) -> ServiceResult<ProcessingJob> {
42 let file = self.file_repo
43 .find_by_id(&file_id.to_string())
44 .await
45 .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
46 .ok_or(ServiceError::NotFound)?;
47
48 if !is_video(&file.mime_type) {
49 return Err(ServiceError::Validation(format!(
50 "File {} is not a video (mime: {})", file_id, file.mime_type
51 )));
52 }
53
54 let _ = &ProcessingJobType::VideoThumbnail;
56
57 let input = serde_json::json!({
58 "sizes": DEFAULT_VIDEO_THUMBNAIL_SIZES.iter().map(|(size, w, h)| {
59 serde_json::json!({ "size": format!("{:?}", size), "width": w, "height": h })
60 }).collect::<Vec<_>>(),
61 });
62
63 let job = ProcessingJob::builder()
64 .file_id(file_id)
65 .job_type(ProcessingJobType::VideoThumbnail)
66 .status(JobStatus::Pending)
67 .priority(priority.unwrap_or(0))
68 .input_data(input)
69 .retry_count(0)
70 .max_retries(3)
71 .build()
72 .map_err(|e| ServiceError::Validation(e))?;
73
74 let created = self.job_repo
75 .create(&job)
76 .await
77 .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
78
79 Ok(created)
80 }
81
82 pub async fn record_thumbnail(
83 &self,
84 file_id: Uuid,
85 size: ThumbnailSize,
86 width: i32,
87 height: i32,
88 storage_key: &str,
89 size_bytes: i64,
90 generation_time_ms: Option<i32>,
91 ) -> ServiceResult<Thumbnail> {
92 let thumb = Thumbnail::builder()
93 .file_id(file_id)
94 .size(size)
95 .width(width)
96 .height(height)
97 .storage_key(storage_key.to_string())
98 .mime_type("image/webp".to_string())
99 .format("webp".to_string())
100 .quality(80)
101 .size_bytes(size_bytes)
102 .generated_at(Utc::now())
103 .generation_time_ms(generation_time_ms.unwrap_or(0))
104 .source_version(1)
105 .is_stale(false)
106 .build()
107 .map_err(|e| ServiceError::Validation(e))?;
108
109 let created = self.thumb_repo
110 .create(&thumb)
111 .await
112 .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
113
114 Ok(created)
115 }
116
117 pub async fn complete_job(&self, job_id: Uuid, _file_id: Uuid) -> ServiceResult<ProcessingJob> {
118 let mut job = self.find_job(job_id).await?;
119
120 let result = serde_json::json!({
121 "completed_at": Utc::now().to_rfc3339(),
122 "thumbnails_generated": true,
123 });
124
125 job.status = JobStatus::Completed;
126 job.result_data = Some(result);
127 job.completed_at = Some(Utc::now());
128 job.metadata.touch();
129
130 let id_str = job.id.to_string();
131 let updated = self.job_repo
132 .update(&id_str, &job)
133 .await
134 .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
135 .ok_or(ServiceError::NotFound)?;
136
137 Ok(updated)
140 }
141
142 pub async fn fail_job(&self, job_id: Uuid, error_message: &str) -> ServiceResult<ProcessingJob> {
143 let mut job = self.find_job(job_id).await?;
144
145 job.status = JobStatus::Failed;
146 job.error_message = Some(error_message.to_string());
147 job.completed_at = Some(Utc::now());
148 job.metadata.touch();
149
150 let id_str = job.id.to_string();
151 self.job_repo
152 .update(&id_str, &job)
153 .await
154 .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
155 .ok_or(ServiceError::NotFound)
156 }
157
158 pub async fn get_thumbnails(&self, _file_id: Uuid) -> ServiceResult<Vec<Thumbnail>> {
159 Ok(vec![])
161 }
162
163 pub async fn regenerate(&self, file_id: Uuid) -> ServiceResult<ProcessingJob> {
164 self.enqueue(file_id, Some(1)).await
166 }
167
168 async fn find_job(&self, job_id: Uuid) -> ServiceResult<ProcessingJob> {
169 self.job_repo
170 .find_by_id(&job_id.to_string())
171 .await
172 .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
173 .ok_or(ServiceError::NotFound)
174 }
175}
176
177fn is_video(mime_type: &str) -> bool {
178 VIDEO_MIME_PREFIXES.iter().any(|prefix| mime_type.starts_with(prefix))
179}