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.
This commit is contained in:
parent
642ec17069
commit
144abb2f8b
@ -1,7 +1,6 @@
|
|||||||
//! Tauri commands for database/storage operations
|
//! Tauri commands for database/storage operations
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::Semaphore;
|
|
||||||
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -10,12 +9,19 @@ use tauri::State;
|
|||||||
use crate::credentials::CredentialStore;
|
use crate::credentials::CredentialStore;
|
||||||
use crate::storage::Database;
|
use crate::storage::Database;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
use crate::thumbnail::{ThumbnailCache, ThumbnailCacheStats, ThumbnailWorker};
|
use crate::thumbnail::ThumbnailCache;
|
||||||
use crate::repository::types::{ImageType, ImageOptions};
|
|
||||||
use crate::repository::MediaRepository;
|
|
||||||
|
|
||||||
use super::SmartCacheWrapper;
|
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
|
/// Wrapper for thread-safe database access
|
||||||
pub struct DatabaseWrapper(pub Mutex<Database>);
|
pub struct DatabaseWrapper(pub Mutex<Database>);
|
||||||
|
|
||||||
@ -1223,491 +1229,6 @@ pub async fn storage_get_pending_sync_count(
|
|||||||
Ok(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<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<'_, super::repository::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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// 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<String>,
|
|
||||||
pub primary_image_tag: Option<String>,
|
|
||||||
pub premiere_date: Option<String>,
|
|
||||||
pub end_date: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
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<Option<CachedPerson>, 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<CachedItemPerson>,
|
|
||||||
) -> 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<Vec<CachedItemPerson>, 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<String>,
|
|
||||||
pub audio_track_language: Option<String>,
|
|
||||||
pub audio_track_index: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
audio_track_language: Option<String>,
|
|
||||||
audio_track_index: Option<i32>,
|
|
||||||
) -> 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<Option<SeriesAudioPreference>, 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
175
src-tauri/src/commands/storage/people.rs
Normal file
175
src-tauri/src/commands/storage/people.rs
Normal file
@ -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<String>,
|
||||||
|
pub primary_image_tag: Option<String>,
|
||||||
|
pub premiere_date: Option<String>,
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
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<Option<CachedPerson>, 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<CachedItemPerson>,
|
||||||
|
) -> 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<Vec<CachedItemPerson>, 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)
|
||||||
|
}
|
||||||
|
|
||||||
92
src-tauri/src/commands/storage/series_prefs.rs
Normal file
92
src-tauri/src/commands/storage/series_prefs.rs
Normal file
@ -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<String>,
|
||||||
|
pub audio_track_language: Option<String>,
|
||||||
|
pub audio_track_index: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
audio_track_language: Option<String>,
|
||||||
|
audio_track_index: Option<i32>,
|
||||||
|
) -> 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<Option<SeriesAudioPreference>, 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)
|
||||||
|
}
|
||||||
241
src-tauri/src/commands/storage/thumbnails.rs
Normal file
241
src-tauri/src/commands/storage/thumbnails.rs
Normal file
@ -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))
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user