From 144abb2f8b35449d28746b2f74a826b9c76b2ada Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 20 Jun 2026 17:08:34 +0200 Subject: [PATCH] Workstream C: convert commands/storage to a folder module, extract thumbnail/people/series-prefs commands - 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. --- .../commands/{storage.rs => storage/mod.rs} | 501 +----------------- src-tauri/src/commands/storage/people.rs | 175 ++++++ .../src/commands/storage/series_prefs.rs | 92 ++++ src-tauri/src/commands/storage/thumbnails.rs | 241 +++++++++ 4 files changed, 519 insertions(+), 490 deletions(-) rename src-tauri/src/commands/{storage.rs => storage/mod.rs} (75%) create mode 100644 src-tauri/src/commands/storage/people.rs create mode 100644 src-tauri/src/commands/storage/series_prefs.rs create mode 100644 src-tauri/src/commands/storage/thumbnails.rs diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage/mod.rs similarity index 75% rename from src-tauri/src/commands/storage.rs rename to src-tauri/src/commands/storage/mod.rs index 24e898f..779ecdb 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage/mod.rs @@ -1,7 +1,6 @@ //! Tauri commands for database/storage operations -use std::sync::{Arc, Mutex, OnceLock}; -use tokio::sync::Semaphore; +use std::sync::{Arc, Mutex}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; @@ -10,12 +9,19 @@ use tauri::State; use crate::credentials::CredentialStore; use crate::storage::Database; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; -use crate::thumbnail::{ThumbnailCache, ThumbnailCacheStats, ThumbnailWorker}; -use crate::repository::types::{ImageType, ImageOptions}; -use crate::repository::MediaRepository; +use crate::thumbnail::ThumbnailCache; use super::SmartCacheWrapper; +// Cohesive command clusters in their own submodules, re-exported so the command +// names remain at `commands::storage::*` (invoke_handler unchanged). +mod people; +mod series_prefs; +mod thumbnails; +pub use people::*; +pub use series_prefs::*; +pub use thumbnails::*; + /// Wrapper for thread-safe database access pub struct DatabaseWrapper(pub Mutex); @@ -1223,491 +1229,6 @@ pub async fn storage_get_pending_sync_count( Ok(count) } -// ============================================================================= -// Thumbnail Cache Commands -// ============================================================================= - -/// 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, 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 { - // 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 { - 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 = 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_height: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, -} - -/// 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<'_, super::repository::RepositoryManagerWrapper>, - thumbnail_cache: State<'_, ThumbnailCacheWrapper>, - db: State<'_, DatabaseWrapper>, - repository_handle: String, - request: GetImageRequest, -) -> Result { - 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)) -} - -// ============================================================================= -// People/Cast Cache Commands -// ============================================================================= - -/// Cached person info returned to frontend -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CachedPerson { - pub id: String, - pub server_id: String, - pub name: String, - pub overview: Option, - pub primary_image_tag: Option, - pub premiere_date: Option, - pub end_date: Option, -} - -/// Item-person association for caching -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CachedItemPerson { - pub item_id: String, - pub person_id: String, - pub server_id: String, - pub person_type: String, - pub role: Option, - pub sort_order: i32, -} - -/// Save a person to the cache -#[tauri::command] -pub async fn storage_save_person( - db: State<'_, DatabaseWrapper>, - person: CachedPerson, -) -> Result<(), String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "INSERT OR REPLACE INTO people ( - id, server_id, name, overview, primary_image_tag, - premiere_date, end_date, synced_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", - vec![ - QueryParam::String(person.id), - QueryParam::String(person.server_id), - QueryParam::String(person.name), - person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null), - person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null), - person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null), - person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null), - ], - ); - - db_service.execute(query).await.map_err(|e| e.to_string())?; - Ok(()) -} - -/// Get a cached person by ID -#[tauri::command] -pub async fn storage_get_person( - db: State<'_, DatabaseWrapper>, - person_id: String, -) -> Result, String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "SELECT id, server_id, name, overview, primary_image_tag, premiere_date, end_date - FROM people WHERE id = ?", - vec![QueryParam::String(person_id)], - ); - - let result = db_service - .query_optional(query, |row| { - Ok(CachedPerson { - id: row.get(0)?, - server_id: row.get(1)?, - name: row.get(2)?, - overview: row.get(3)?, - primary_image_tag: row.get(4)?, - premiere_date: row.get(5)?, - end_date: row.get(6)?, - }) - }) - .await - .map_err(|e| e.to_string())?; - - Ok(result) -} - -/// Save item-person associations (batch) -#[tauri::command] -pub async fn storage_save_item_people( - db: State<'_, DatabaseWrapper>, - associations: Vec, -) -> Result<(), String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - // Clone associations for the closure - let associations_clone = associations.clone(); - - // Use transaction for batch insert - db_service.transaction(move |tx| { - for assoc in &associations_clone { - let query = Query::with_params( - "INSERT OR REPLACE INTO item_people ( - item_id, person_id, server_id, person_type, role, sort_order, synced_at - ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", - vec![ - QueryParam::String(assoc.item_id.clone()), - QueryParam::String(assoc.person_id.clone()), - QueryParam::String(assoc.server_id.clone()), - QueryParam::String(assoc.person_type.clone()), - assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), - QueryParam::Int(assoc.sort_order), - ], - ); - tx.execute(query)?; - } - Ok(()) - }).await.map_err(|e| e.to_string())?; - - Ok(()) -} - -/// Get people for an item (with person details joined) -#[tauri::command] -pub async fn storage_get_item_people( - db: State<'_, DatabaseWrapper>, - item_id: String, -) -> Result, String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "SELECT ip.item_id, ip.person_id, ip.server_id, ip.person_type, ip.role, ip.sort_order - FROM item_people ip - WHERE ip.item_id = ? - ORDER BY ip.sort_order ASC", - vec![QueryParam::String(item_id)], - ); - - let people = db_service - .query_many(query, |row| { - Ok(CachedItemPerson { - item_id: row.get(0)?, - person_id: row.get(1)?, - server_id: row.get(2)?, - person_type: row.get(3)?, - role: row.get(4)?, - sort_order: row.get(5)?, - }) - }) - .await - .map_err(|e| e.to_string())?; - - Ok(people) -} - -// ===== Series Audio Preferences ===== - -/// Audio track preference for a series -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SeriesAudioPreference { - pub series_id: String, - pub audio_track_display_title: Option, - pub audio_track_language: Option, - pub audio_track_index: Option, -} - -/// Save user's preferred audio track for a series -#[tauri::command] -pub async fn storage_save_series_audio_preference( - db: State<'_, DatabaseWrapper>, - user_id: String, - series_id: String, - server_id: String, - audio_track_display_title: Option, - audio_track_language: Option, - audio_track_index: Option, -) -> Result<(), String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "INSERT INTO series_audio_preferences - (user_id, series_id, server_id, audio_track_display_title, audio_track_language, audio_track_index, updated_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT (user_id, series_id, server_id) DO UPDATE SET - audio_track_display_title = excluded.audio_track_display_title, - audio_track_language = excluded.audio_track_language, - audio_track_index = excluded.audio_track_index, - updated_at = datetime('now')", - vec![ - QueryParam::String(user_id), - QueryParam::String(series_id), - QueryParam::String(server_id), - audio_track_display_title.map(QueryParam::String).unwrap_or(QueryParam::Null), - audio_track_language.map(QueryParam::String).unwrap_or(QueryParam::Null), - audio_track_index.map(|i| QueryParam::Int64(i as i64)).unwrap_or(QueryParam::Null), - ], - ); - - db_service.execute(query).await.map_err(|e| e.to_string())?; - Ok(()) -} - -/// Get user's preferred audio track for a series -#[tauri::command] -pub async fn storage_get_series_audio_preference( - db: State<'_, DatabaseWrapper>, - user_id: String, - series_id: String, -) -> Result, String> { - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "SELECT series_id, audio_track_display_title, audio_track_language, audio_track_index - FROM series_audio_preferences - WHERE user_id = ? AND series_id = ?", - vec![QueryParam::String(user_id), QueryParam::String(series_id)], - ); - - let preference = db_service - .query_optional(query, |row| { - Ok(SeriesAudioPreference { - series_id: row.get(0)?, - audio_track_display_title: row.get(1)?, - audio_track_language: row.get(2)?, - audio_track_index: row.get(3)?, - }) - }) - .await - .map_err(|e| e.to_string())?; - - Ok(preference) -} #[cfg(test)] mod tests { diff --git a/src-tauri/src/commands/storage/people.rs b/src-tauri/src/commands/storage/people.rs new file mode 100644 index 0000000..7d4970b --- /dev/null +++ b/src-tauri/src/commands/storage/people.rs @@ -0,0 +1,175 @@ +//! Person/cast metadata cache commands. + +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use super::DatabaseWrapper; +use crate::storage::db_service::{DatabaseService, Query, QueryParam}; + + +/// Cached person info returned to frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CachedPerson { + pub id: String, + pub server_id: String, + pub name: String, + pub overview: Option, + pub primary_image_tag: Option, + pub premiere_date: Option, + pub end_date: Option, +} + +/// Item-person association for caching +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CachedItemPerson { + pub item_id: String, + pub person_id: String, + pub server_id: String, + pub person_type: String, + pub role: Option, + pub sort_order: i32, +} + +/// Save a person to the cache +#[tauri::command] +pub async fn storage_save_person( + db: State<'_, DatabaseWrapper>, + person: CachedPerson, +) -> Result<(), String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "INSERT OR REPLACE INTO people ( + id, server_id, name, overview, primary_image_tag, + premiere_date, end_date, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", + vec![ + QueryParam::String(person.id), + QueryParam::String(person.server_id), + QueryParam::String(person.name), + person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null), + person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null), + person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null), + person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null), + ], + ); + + db_service.execute(query).await.map_err(|e| e.to_string())?; + Ok(()) +} + +/// Get a cached person by ID +#[tauri::command] +pub async fn storage_get_person( + db: State<'_, DatabaseWrapper>, + person_id: String, +) -> Result, String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "SELECT id, server_id, name, overview, primary_image_tag, premiere_date, end_date + FROM people WHERE id = ?", + vec![QueryParam::String(person_id)], + ); + + let result = db_service + .query_optional(query, |row| { + Ok(CachedPerson { + id: row.get(0)?, + server_id: row.get(1)?, + name: row.get(2)?, + overview: row.get(3)?, + primary_image_tag: row.get(4)?, + premiere_date: row.get(5)?, + end_date: row.get(6)?, + }) + }) + .await + .map_err(|e| e.to_string())?; + + Ok(result) +} + +/// Save item-person associations (batch) +#[tauri::command] +pub async fn storage_save_item_people( + db: State<'_, DatabaseWrapper>, + associations: Vec, +) -> Result<(), String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + // Clone associations for the closure + let associations_clone = associations.clone(); + + // Use transaction for batch insert + db_service.transaction(move |tx| { + for assoc in &associations_clone { + let query = Query::with_params( + "INSERT OR REPLACE INTO item_people ( + item_id, person_id, server_id, person_type, role, sort_order, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", + vec![ + QueryParam::String(assoc.item_id.clone()), + QueryParam::String(assoc.person_id.clone()), + QueryParam::String(assoc.server_id.clone()), + QueryParam::String(assoc.person_type.clone()), + assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), + QueryParam::Int(assoc.sort_order), + ], + ); + tx.execute(query)?; + } + Ok(()) + }).await.map_err(|e| e.to_string())?; + + Ok(()) +} + +/// Get people for an item (with person details joined) +#[tauri::command] +pub async fn storage_get_item_people( + db: State<'_, DatabaseWrapper>, + item_id: String, +) -> Result, String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "SELECT ip.item_id, ip.person_id, ip.server_id, ip.person_type, ip.role, ip.sort_order + FROM item_people ip + WHERE ip.item_id = ? + ORDER BY ip.sort_order ASC", + vec![QueryParam::String(item_id)], + ); + + let people = db_service + .query_many(query, |row| { + Ok(CachedItemPerson { + item_id: row.get(0)?, + person_id: row.get(1)?, + server_id: row.get(2)?, + person_type: row.get(3)?, + role: row.get(4)?, + sort_order: row.get(5)?, + }) + }) + .await + .map_err(|e| e.to_string())?; + + Ok(people) +} + diff --git a/src-tauri/src/commands/storage/series_prefs.rs b/src-tauri/src/commands/storage/series_prefs.rs new file mode 100644 index 0000000..4d4c1cd --- /dev/null +++ b/src-tauri/src/commands/storage/series_prefs.rs @@ -0,0 +1,92 @@ +//! Per-series preferred audio track commands. + +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use super::DatabaseWrapper; +use crate::storage::db_service::{DatabaseService, Query, QueryParam}; + + +/// Audio track preference for a series +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesAudioPreference { + pub series_id: String, + pub audio_track_display_title: Option, + pub audio_track_language: Option, + pub audio_track_index: Option, +} + +/// Save user's preferred audio track for a series +#[tauri::command] +pub async fn storage_save_series_audio_preference( + db: State<'_, DatabaseWrapper>, + user_id: String, + series_id: String, + server_id: String, + audio_track_display_title: Option, + audio_track_language: Option, + audio_track_index: Option, +) -> Result<(), String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "INSERT INTO series_audio_preferences + (user_id, series_id, server_id, audio_track_display_title, audio_track_language, audio_track_index, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT (user_id, series_id, server_id) DO UPDATE SET + audio_track_display_title = excluded.audio_track_display_title, + audio_track_language = excluded.audio_track_language, + audio_track_index = excluded.audio_track_index, + updated_at = datetime('now')", + vec![ + QueryParam::String(user_id), + QueryParam::String(series_id), + QueryParam::String(server_id), + audio_track_display_title.map(QueryParam::String).unwrap_or(QueryParam::Null), + audio_track_language.map(QueryParam::String).unwrap_or(QueryParam::Null), + audio_track_index.map(|i| QueryParam::Int64(i as i64)).unwrap_or(QueryParam::Null), + ], + ); + + db_service.execute(query).await.map_err(|e| e.to_string())?; + Ok(()) +} + +/// Get user's preferred audio track for a series +#[tauri::command] +pub async fn storage_get_series_audio_preference( + db: State<'_, DatabaseWrapper>, + user_id: String, + series_id: String, +) -> Result, String> { + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "SELECT series_id, audio_track_display_title, audio_track_language, audio_track_index + FROM series_audio_preferences + WHERE user_id = ? AND series_id = ?", + vec![QueryParam::String(user_id), QueryParam::String(series_id)], + ); + + let preference = db_service + .query_optional(query, |row| { + Ok(SeriesAudioPreference { + series_id: row.get(0)?, + audio_track_display_title: row.get(1)?, + audio_track_language: row.get(2)?, + audio_track_index: row.get(3)?, + }) + }) + .await + .map_err(|e| e.to_string())?; + + Ok(preference) +} diff --git a/src-tauri/src/commands/storage/thumbnails.rs b/src-tauri/src/commands/storage/thumbnails.rs new file mode 100644 index 0000000..b96beae --- /dev/null +++ b/src-tauri/src/commands/storage/thumbnails.rs @@ -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, 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 { + // 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 { + 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 = 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, +} + +/// 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 { + 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)) +}