Many improvemtns and fixes related to decoupling of svelte and rust on android.
This commit is contained in:
@@ -39,7 +39,7 @@ pub async fn auth_initialize(
|
||||
};
|
||||
|
||||
// Create session object from active session with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||
|
||||
let session = Session {
|
||||
user_id: active_session.user_id,
|
||||
@@ -80,7 +80,7 @@ pub async fn auth_login(
|
||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||
|
||||
// Create session from auth result with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||
|
||||
let session = Session {
|
||||
user_id: result.user.id.clone(),
|
||||
@@ -156,10 +156,13 @@ pub async fn auth_set_session(
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
// Normalize the server URL if session is provided
|
||||
let normalized_session = session.map(|mut s| {
|
||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
|
||||
s
|
||||
});
|
||||
let normalized_session = match session {
|
||||
Some(mut s) => {
|
||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
||||
Some(s)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
auth_manager.0.set_session(normalized_session).await;
|
||||
Ok(())
|
||||
|
||||
@@ -956,7 +956,26 @@ pub async fn start_download(
|
||||
debug!("Download task started for download_id: {}", download_id);
|
||||
let worker = DownloadWorker::new();
|
||||
|
||||
match worker.download(&task).await {
|
||||
// Progress callback that emits events to the frontend
|
||||
let progress_app = app_clone.clone();
|
||||
let progress_item_id = item_id_clone.clone();
|
||||
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
||||
let progress = total_bytes
|
||||
.filter(|&t| t > 0)
|
||||
.map(|t| bytes_downloaded as f64 / t as f64)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let event = DownloadEvent::Progress {
|
||||
download_id,
|
||||
item_id: progress_item_id.clone(),
|
||||
bytes_downloaded: bytes_downloaded as i64,
|
||||
total_bytes: total_bytes.map(|t| t as i64),
|
||||
progress,
|
||||
};
|
||||
let _ = progress_app.emit("download-event", event);
|
||||
};
|
||||
|
||||
match worker.download(&task, on_progress).await {
|
||||
Ok(result) => {
|
||||
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
|
||||
|
||||
|
||||
@@ -195,4 +195,64 @@ mod tests {
|
||||
assert!(json.contains(&pos.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playback_mode_deserialization_from_frontend() {
|
||||
// Test what frontend sends for Idle mode
|
||||
let idle_json = r#"{"type":"idle"}"#;
|
||||
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||
assert_eq!(mode, PlaybackMode::Idle);
|
||||
|
||||
// Test what frontend sends for Local mode
|
||||
let local_json = r#"{"type":"local"}"#;
|
||||
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||
assert_eq!(mode, PlaybackMode::Local);
|
||||
|
||||
// Test what frontend sends for Remote mode
|
||||
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
||||
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||
match mode {
|
||||
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||
_ => panic!("Expected Remote mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_tracks_context_deserialization() {
|
||||
use crate::commands::PlayTracksContext;
|
||||
|
||||
// Test Search context (the recently fixed issue)
|
||||
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||
let context: PlayTracksContext = serde_json::from_str(search_json)
|
||||
.expect("Failed to deserialize search context");
|
||||
match context {
|
||||
PlayTracksContext::Search { search_query } => {
|
||||
assert_eq!(search_query, "test query");
|
||||
}
|
||||
_ => panic!("Expected Search context"),
|
||||
}
|
||||
|
||||
// Test Playlist context
|
||||
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
||||
.expect("Failed to deserialize playlist context");
|
||||
match context {
|
||||
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
||||
assert_eq!(playlist_id, "pl-123");
|
||||
assert_eq!(playlist_name, "My Playlist");
|
||||
}
|
||||
_ => panic!("Expected Playlist context"),
|
||||
}
|
||||
|
||||
// Test Custom context
|
||||
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
||||
.expect("Failed to deserialize custom context");
|
||||
match context {
|
||||
PlayTracksContext::Custom { label } => {
|
||||
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Custom context"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem {
|
||||
album: item.album.clone(),
|
||||
album_id: item.album_id.clone(),
|
||||
duration: item.duration,
|
||||
primary_image_tag: None,
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
media_type: match item.media_type {
|
||||
crate::player::MediaType::Audio => "audio".to_string(),
|
||||
crate::player::MediaType::Video => "video".to_string(),
|
||||
@@ -138,43 +138,20 @@ pub enum VideoBackend {
|
||||
Html5,
|
||||
}
|
||||
|
||||
/// Request to play a single item
|
||||
/// Request to play a single video item
|
||||
///
|
||||
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
||||
/// to avoid Tauri Android serialization issues with complex objects.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayItemRequest {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
/// Album ID (Jellyfin ID) for remote transfer context
|
||||
#[serde(default)]
|
||||
pub album_id: Option<String>,
|
||||
/// Playlist ID (Jellyfin ID) for remote transfer context
|
||||
#[serde(default)]
|
||||
pub playlist_id: Option<String>,
|
||||
pub duration: Option<f64>,
|
||||
pub artwork_url: Option<String>,
|
||||
pub media_type: MediaType,
|
||||
pub stream_url: String,
|
||||
pub jellyfin_item_id: Option<String>,
|
||||
/// Video codec (e.g., "h264", "hevc") for video media
|
||||
#[serde(default)]
|
||||
pub video_codec: Option<String>,
|
||||
pub video_codec: String,
|
||||
/// Whether the video requires server-side transcoding
|
||||
#[serde(default)]
|
||||
pub needs_transcoding: bool,
|
||||
/// Video width in pixels
|
||||
#[serde(default)]
|
||||
pub video_width: Option<u32>,
|
||||
/// Video height in pixels
|
||||
#[serde(default)]
|
||||
pub video_height: Option<u32>,
|
||||
/// Series ID (for TV show episodes) - used for series audio preferences
|
||||
#[serde(default)]
|
||||
pub series_id: Option<String>,
|
||||
/// Server ID - used for series audio preferences
|
||||
#[serde(default)]
|
||||
pub server_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Queue context for remote transfer - what type of queue is this?
|
||||
@@ -345,16 +322,20 @@ pub enum AudioTrackSwitchResponse {
|
||||
},
|
||||
}
|
||||
|
||||
/// Helper function to create MediaItem from request, checking for local downloads
|
||||
/// Helper function to create MediaItem from video request
|
||||
///
|
||||
/// PlayItemRequest is now video-only, so we create a video MediaItem.
|
||||
/// Audio playback uses player_play_tracks which fetches full metadata from backend.
|
||||
async fn create_media_item(
|
||||
req: PlayItemRequest,
|
||||
db: Option<&DatabaseWrapper>,
|
||||
) -> Result<MediaItem, String> {
|
||||
let jellyfin_id = req.jellyfin_item_id.as_ref().unwrap_or(&req.id);
|
||||
// For video-only requests, we use the item ID as the jellyfin ID
|
||||
let jellyfin_id = req.id.clone();
|
||||
|
||||
// Check if item is downloaded locally
|
||||
let local_path = if let Some(db_wrapper) = db {
|
||||
check_for_local_download(db_wrapper, jellyfin_id).await?
|
||||
check_for_local_download(db_wrapper, &jellyfin_id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -374,27 +355,27 @@ async fn create_media_item(
|
||||
Ok(MediaItem {
|
||||
id: req.id.clone(),
|
||||
title: req.title.clone(),
|
||||
name: Some(req.title), // Frontend compatibility
|
||||
artist: req.artist.clone(),
|
||||
album: req.album.clone(),
|
||||
album_name: req.album, // Frontend compatibility
|
||||
album_id: req.album_id,
|
||||
artist_items: None, // Not available from frontend request
|
||||
artists: req.artist.map(|a| vec![a]), // Convert single artist to array
|
||||
primary_image_tag: None, // Not available from frontend request
|
||||
item_type: None, // Not available from frontend request
|
||||
playlist_id: req.playlist_id,
|
||||
duration: req.duration,
|
||||
artwork_url: req.artwork_url,
|
||||
media_type: req.media_type,
|
||||
name: Some(req.title.clone()),
|
||||
artist: None, // Not available from video-only request
|
||||
album: None, // Not available from video-only request
|
||||
album_name: None, // Not available from video-only request
|
||||
album_id: None, // Not available from video-only request
|
||||
artist_items: None, // Not available from video-only request
|
||||
artists: None, // Not available from video-only request
|
||||
primary_image_tag: None, // Not available from video-only request
|
||||
item_type: None, // Not available from video-only request
|
||||
playlist_id: None, // Not available from video-only request
|
||||
duration: None, // Not available from video-only request
|
||||
artwork_url: None, // Not available from video-only request
|
||||
media_type: crate::player::MediaType::Video, // Video-only request
|
||||
source,
|
||||
video_codec: req.video_codec,
|
||||
video_codec: Some(req.video_codec),
|
||||
needs_transcoding: req.needs_transcoding,
|
||||
video_width: req.video_width,
|
||||
video_height: req.video_height,
|
||||
video_width: None, // Not available from video-only request
|
||||
video_height: None, // Not available from video-only request
|
||||
subtitles: vec![],
|
||||
series_id: req.series_id,
|
||||
server_id: req.server_id,
|
||||
series_id: None, // Not available from video-only request
|
||||
server_id: None, // Not available from video-only request
|
||||
})
|
||||
}
|
||||
|
||||
@@ -433,6 +414,9 @@ async fn check_for_local_download(
|
||||
|
||||
/// Play a single media item (audio or video)
|
||||
///
|
||||
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
|
||||
/// This avoids Tauri's Android serialization issues with complex objects.
|
||||
///
|
||||
/// @req: UR-003 - Play videos
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: UR-005 - Control media playback (play operation)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Tauri commands for database/storage operations
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -1343,6 +1344,25 @@ pub async fn thumbnail_delete_item(
|
||||
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")]
|
||||
@@ -1385,32 +1405,21 @@ pub async fn image_get_url(
|
||||
&request.image_type,
|
||||
tag,
|
||||
).await {
|
||||
// Read file and return as base64
|
||||
let image_data = fs::read(&cached_path)
|
||||
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
|
||||
// Determine MIME type from file extension
|
||||
let mime_type = match cached_path.extension().and_then(|s| s.to_str()) {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/jpeg", // default
|
||||
};
|
||||
|
||||
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
|
||||
debug!("[ImageCache] Cache hit for {}/{}", request.item_id, request.image_type);
|
||||
return Ok(data_url);
|
||||
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 - need to fetch from repository and cache
|
||||
info!("[ImageCache] Cache miss for {}/{}, downloading...", request.item_id, request.image_type);
|
||||
// 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("Repository not found - user may need to log in")?;
|
||||
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
|
||||
|
||||
// Parse image type
|
||||
let image_type_enum = match request.image_type.as_str() {
|
||||
"Primary" => ImageType::Primary,
|
||||
"Backdrop" => ImageType::Backdrop,
|
||||
@@ -1420,7 +1429,6 @@ pub async fn image_get_url(
|
||||
_ => ImageType::Primary,
|
||||
};
|
||||
|
||||
// Build image options
|
||||
let options = ImageOptions {
|
||||
max_width: request.max_width,
|
||||
max_height: request.max_height,
|
||||
@@ -1428,18 +1436,10 @@ pub async fn image_get_url(
|
||||
tag: request.tag.clone(),
|
||||
};
|
||||
|
||||
// Get image URL from repository
|
||||
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||
debug!("[ImageCache] Server URL: {}", server_url);
|
||||
|
||||
// Download image data
|
||||
let worker = ThumbnailWorker::new();
|
||||
let image_data = worker
|
||||
.download_with_retry(&server_url, 2)
|
||||
.await
|
||||
let image_data = repository.download_bytes(&server_url).await
|
||||
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||
|
||||
// Save to cache
|
||||
let cached_path = thumbnail_cache.0.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
@@ -1450,18 +1450,9 @@ pub async fn image_get_url(
|
||||
request.max_height.map(|h| h as i32),
|
||||
).await?;
|
||||
|
||||
// Return as base64 data URL
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
let mime_type = match cached_path.extension().and_then(|s| s.to_str()) {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
let data_url = format!("data:{};base64,{}", mime_type, base64_data);
|
||||
info!("[ImageCache] Cached and returning base64 data URL");
|
||||
Ok(data_url)
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
Ok(format!("data:{};base64,{}", mime_type, base64_data))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user