Workstream C: convert commands/storage to a folder module, extract thumbnail/people/series-prefs commands
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 14m27s
Traceability Validation / Check Requirement Traces (push) Failing after 4s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 1m42s

- Move commands/storage.rs to commands/storage/mod.rs.
- Extract three cohesive, self-contained clusters:
  - storage/thumbnails.rs: thumbnail cache + image-URL commands.
  - storage/people.rs: person/cast metadata cache commands.
  - storage/series_prefs.rs: per-series preferred audio track commands.
- Re-exported via pub use so command names stay at commands::storage::*;
  invoke_handler unchanged. mod.rs shrinks from 1979 to 1500 lines, all tests pass.
This commit is contained in:
2026-06-20 17:08:34 +02:00
parent 642ec17069
commit 144abb2f8b
4 changed files with 519 additions and 490 deletions
@@ -0,0 +1,241 @@
//! Thumbnail cache and image-URL commands.
use std::sync::{Arc, OnceLock};
use tokio::sync::Semaphore;
use serde::Deserialize;
use tauri::State;
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
use crate::commands::repository::RepositoryManagerWrapper;
use crate::repository::types::{ImageOptions, ImageType};
use crate::repository::MediaRepository;
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
/// Get cached thumbnail path, returns None if not cached
/// Also updates last_accessed timestamp for LRU tracking
#[tauri::command]
pub async fn thumbnail_get_cached(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
item_id: String,
image_type: String,
tag: String,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let result = thumbnail_cache.0
.get_cached_path(db_service, &item_id, &image_type, &tag)
.await
.map(|p| p.to_string_lossy().to_string());
Ok(result)
}
/// Download and save a thumbnail to cache
/// Returns the local file path on success
#[tauri::command]
pub async fn thumbnail_save(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
item_id: String,
image_type: String,
tag: String,
url: String,
) -> Result<String, String> {
// Download the image
let worker = ThumbnailWorker::new();
let data = worker
.download_with_retry(&url, 2)
.await
.map_err(|e| e.to_string())?;
// Save to cache
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
Ok(path.to_string_lossy().to_string())
}
/// Get thumbnail cache statistics
#[tauri::command]
pub async fn thumbnail_get_stats(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
) -> Result<ThumbnailCacheStats, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let total_size_bytes = thumbnail_cache.0.get_cache_size(db_service.clone()).await;
let item_count = thumbnail_cache.0.get_item_count(db_service.clone()).await;
let limit_bytes = thumbnail_cache.0.get_limit(db_service).await;
Ok(ThumbnailCacheStats {
total_size_bytes,
item_count,
limit_bytes,
})
}
/// Set thumbnail cache storage limit in bytes
#[tauri::command]
pub async fn thumbnail_set_limit(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
limit_bytes: u64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
thumbnail_cache.0.set_limit(db_service, limit_bytes).await
}
/// Clear all cached thumbnails
#[tauri::command]
pub async fn thumbnail_clear_cache(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
thumbnail_cache.0.clear_cache(db_service).await
}
/// Delete cached thumbnails for a specific item
#[tauri::command]
pub async fn thumbnail_delete_item(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
item_id: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
thumbnail_cache.0.delete_item(db_service, &item_id).await
}
fn mime_from_ext(ext: Option<&str>) -> &'static str {
match ext {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/jpeg",
}
}
/// Limit concurrent image downloads to avoid saturating the connection pool.
/// Without this, rendering a page with hundreds of album cards fires hundreds of
/// concurrent HTTP requests, starving API calls and causing timeouts.
static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
fn image_semaphore() -> &'static Semaphore {
IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
}
/// Request to get an image URL (with caching)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetImageRequest {
pub item_id: String,
pub image_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
/// Get image as base64 data URL, caching if not already cached
/// This extends the thumbnail system to serve all images through Rust with automatic caching
#[tauri::command]
pub async fn image_get_url(
repository_manager: State<'_, RepositoryManagerWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
db: State<'_, DatabaseWrapper>,
repository_handle: String,
request: GetImageRequest,
) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use std::fs;
let tag = request.tag.as_deref().unwrap_or("default");
// Get database service
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Check cache first
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
db_service.clone(),
&request.item_id,
&request.image_type,
tag,
).await {
let image_data = fs::read(&cached_path)
.map_err(|e| format!("Failed to read cached image: {}", e))?;
let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
}
// Not cached — fetch from server and cache.
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
let _permit = image_semaphore().acquire().await
.map_err(|_| "Image download semaphore closed".to_string())?;
let repository = repository_manager.0.get(&repository_handle)
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
let image_type_enum = match request.image_type.as_str() {
"Primary" => ImageType::Primary,
"Backdrop" => ImageType::Backdrop,
"Banner" => ImageType::Banner,
"Thumb" => ImageType::Thumb,
"Logo" => ImageType::Logo,
_ => ImageType::Primary,
};
let options = ImageOptions {
max_width: request.max_width,
max_height: request.max_height,
quality: Some(90),
tag: request.tag.clone(),
};
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
let image_data = repository.download_bytes(&server_url).await
.map_err(|e| format!("Failed to download image: {}", e))?;
let cached_path = thumbnail_cache.0.save_thumbnail(
db_service,
&request.item_id,
&request.image_type,
tag,
&image_data,
request.max_width.map(|w| w as i32),
request.max_height.map(|h| h as i32),
).await?;
let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
Ok(format!("data:{};base64,{}", mime_type, base64_data))
}