jellytau_lib/commands/storage/
thumbnails.rs1use serde::Deserialize;
6use std::sync::{Arc, OnceLock};
7use tauri::State;
8use tokio::sync::Semaphore;
9
10use super::{DatabaseWrapper, ThumbnailCacheWrapper};
11use crate::commands::repository::RepositoryManagerWrapper;
12use crate::repository::types::{ImageOptions, ImageType};
13use crate::repository::MediaRepository;
14use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
15
16#[tauri::command]
19#[specta::specta]
20pub async fn thumbnail_get_cached(
21 db: State<'_, DatabaseWrapper>,
22 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
23 item_id: String,
24 image_type: String,
25 tag: String,
26) -> Result<Option<String>, String> {
27 let db_service = {
28 let database = db.0.lock().map_err(|e| e.to_string())?;
29 Arc::new(database.service())
30 };
31
32 let result = thumbnail_cache
33 .0
34 .get_cached_path(db_service, &item_id, &image_type, &tag)
35 .await
36 .map(|p| p.to_string_lossy().to_string());
37
38 Ok(result)
39}
40
41#[tauri::command]
44#[specta::specta]
45pub async fn thumbnail_save(
46 db: State<'_, DatabaseWrapper>,
47 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
48 item_id: String,
49 image_type: String,
50 tag: String,
51 url: String,
52) -> Result<String, String> {
53 let worker = ThumbnailWorker::new();
55 let data = worker
56 .download_with_retry(&url, 2)
57 .await
58 .map_err(|e| e.to_string())?;
59
60 let db_service = {
62 let database = db.0.lock().map_err(|e| e.to_string())?;
63 Arc::new(database.service())
64 };
65
66 let path = thumbnail_cache
67 .0
68 .save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None)
69 .await?;
70 Ok(path.to_string_lossy().to_string())
71}
72
73#[tauri::command]
75#[specta::specta]
76pub async fn thumbnail_get_stats(
77 db: State<'_, DatabaseWrapper>,
78 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
79) -> Result<ThumbnailCacheStats, String> {
80 let db_service = {
81 let database = db.0.lock().map_err(|e| e.to_string())?;
82 Arc::new(database.service())
83 };
84
85 let total_size_bytes = thumbnail_cache.0.get_cache_size(db_service.clone()).await;
86 let item_count = thumbnail_cache.0.get_item_count(db_service.clone()).await;
87 let limit_bytes = thumbnail_cache.0.get_limit(db_service).await;
88
89 Ok(ThumbnailCacheStats {
90 total_size_bytes,
91 item_count,
92 limit_bytes,
93 })
94}
95
96#[tauri::command]
98#[specta::specta]
99pub async fn thumbnail_set_limit(
100 db: State<'_, DatabaseWrapper>,
101 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
102 limit_bytes: u64,
103) -> Result<(), String> {
104 let db_service = {
105 let database = db.0.lock().map_err(|e| e.to_string())?;
106 Arc::new(database.service())
107 };
108
109 thumbnail_cache.0.set_limit(db_service, limit_bytes).await
110}
111
112#[tauri::command]
114#[specta::specta]
115pub async fn thumbnail_clear_cache(
116 db: State<'_, DatabaseWrapper>,
117 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
118) -> Result<(), String> {
119 let db_service = {
120 let database = db.0.lock().map_err(|e| e.to_string())?;
121 Arc::new(database.service())
122 };
123
124 thumbnail_cache.0.clear_cache(db_service).await
125}
126
127#[tauri::command]
129#[specta::specta]
130pub async fn thumbnail_delete_item(
131 db: State<'_, DatabaseWrapper>,
132 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
133 item_id: String,
134) -> Result<(), String> {
135 let db_service = {
136 let database = db.0.lock().map_err(|e| e.to_string())?;
137 Arc::new(database.service())
138 };
139
140 thumbnail_cache.0.delete_item(db_service, &item_id).await
141}
142
143fn mime_from_ext(ext: Option<&str>) -> &'static str {
144 match ext {
145 Some("jpg") | Some("jpeg") => "image/jpeg",
146 Some("png") => "image/png",
147 Some("gif") => "image/gif",
148 Some("webp") => "image/webp",
149 _ => "image/jpeg",
150 }
151}
152
153static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
157
158fn image_semaphore() -> &'static Semaphore {
159 IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
160}
161
162#[derive(specta::Type, Debug, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct GetImageRequest {
166 pub item_id: String,
167 pub image_type: String,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub max_width: Option<u32>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub max_height: Option<u32>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub tag: Option<String>,
174}
175
176#[tauri::command]
179#[specta::specta]
180pub async fn image_get_url(
181 repository_manager: State<'_, RepositoryManagerWrapper>,
182 thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
183 db: State<'_, DatabaseWrapper>,
184 repository_handle: String,
185 request: GetImageRequest,
186) -> Result<String, String> {
187 use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
188 use std::fs;
189
190 let tag = request.tag.as_deref().unwrap_or("default");
191
192 let db_service = {
194 let database = db.0.lock().map_err(|e| e.to_string())?;
195 Arc::new(database.service())
196 };
197
198 if let Some(cached_path) = thumbnail_cache
200 .0
201 .get_cached_path(
202 db_service.clone(),
203 &request.item_id,
204 &request.image_type,
205 tag,
206 )
207 .await
208 {
209 let image_data =
210 fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
211 let base64_data = BASE64.encode(&image_data);
212 let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
213 return Ok(format!("data:{};base64,{}", mime_type, base64_data));
214 }
215
216 let _permit = image_semaphore()
219 .acquire()
220 .await
221 .map_err(|_| "Image download semaphore closed".to_string())?;
222
223 let repository = repository_manager
224 .0
225 .get(&repository_handle)
226 .ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
227
228 let image_type_enum = match request.image_type.as_str() {
229 "Primary" => ImageType::Primary,
230 "Backdrop" => ImageType::Backdrop,
231 "Banner" => ImageType::Banner,
232 "Thumb" => ImageType::Thumb,
233 "Logo" => ImageType::Logo,
234 _ => ImageType::Primary,
235 };
236
237 let options = ImageOptions {
238 max_width: request.max_width,
239 max_height: request.max_height,
240 quality: Some(90),
241 tag: request.tag.clone(),
242 };
243
244 let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
245 let image_data = repository
246 .download_bytes(&server_url)
247 .await
248 .map_err(|e| format!("Failed to download image: {}", e))?;
249
250 let cached_path = thumbnail_cache
251 .0
252 .save_thumbnail(
253 db_service,
254 &request.item_id,
255 &request.image_type,
256 tag,
257 &image_data,
258 request.max_width.map(|w| w as i32),
259 request.max_height.map(|h| h as i32),
260 )
261 .await?;
262
263 let base64_data = BASE64.encode(&image_data);
264 let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
265 Ok(format!("data:{};base64,{}", mime_type, base64_data))
266}