Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix.
267 lines
8.1 KiB
Rust
267 lines
8.1 KiB
Rust
//! Thumbnail cache and image-URL commands.
|
|
//!
|
|
//! TRACES: UR-007 | JA-028 | DR-016
|
|
|
|
use serde::Deserialize;
|
|
use std::sync::{Arc, OnceLock};
|
|
use tauri::State;
|
|
use tokio::sync::Semaphore;
|
|
|
|
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]
|
|
#[specta::specta]
|
|
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]
|
|
#[specta::specta]
|
|
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]
|
|
#[specta::specta]
|
|
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]
|
|
#[specta::specta]
|
|
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]
|
|
#[specta::specta]
|
|
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]
|
|
#[specta::specta]
|
|
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(specta::Type, 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]
|
|
#[specta::specta]
|
|
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::general_purpose::STANDARD as BASE64, Engine as _};
|
|
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))
|
|
}
|