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}