Skip to main content

jellytau_lib/commands/storage/
thumbnails.rs

1//! Thumbnail cache and image-URL commands.
2//!
3//! TRACES: UR-007 | JA-028 | DR-016
4
5use 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/// Get cached thumbnail path, returns None if not cached
17/// Also updates last_accessed timestamp for LRU tracking
18#[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/// Download and save a thumbnail to cache
42/// Returns the local file path on success
43#[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    // Download the image
54    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    // Save to cache
61    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/// Get thumbnail cache statistics
74#[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/// Set thumbnail cache storage limit in bytes
97#[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/// Clear all cached thumbnails
113#[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/// Delete cached thumbnails for a specific item
128#[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
153/// Limit concurrent image downloads to avoid saturating the connection pool.
154/// Without this, rendering a page with hundreds of album cards fires hundreds of
155/// concurrent HTTP requests, starving API calls and causing timeouts.
156static 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/// Request to get an image URL (with caching)
163#[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/// Get image as base64 data URL, caching if not already cached
177/// This extends the thumbnail system to serve all images through Rust with automatic caching
178#[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    // Get database service
193    let db_service = {
194        let database = db.0.lock().map_err(|e| e.to_string())?;
195        Arc::new(database.service())
196    };
197
198    // Check cache first
199    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    // Not cached — fetch from server and cache.
217    // Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
218    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}