Background-audio handoff for video + repository/player refactor
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.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
//! Authentication and session-lifecycle commands.
|
||||
//!
|
||||
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
|
||||
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
|
||||
|
||||
/// Wrapper for AuthManager to manage in Tauri state
|
||||
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Create session object from active session with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||
@@ -56,7 +61,11 @@ pub async fn auth_initialize(
|
||||
// Store in AuthManager
|
||||
auth_manager.0.set_session(Some(session.clone())).await;
|
||||
|
||||
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
|
||||
log::info!(
|
||||
"[AuthManager] Session restored for user: {} with normalized URL: {}",
|
||||
session.username,
|
||||
session.server_url
|
||||
);
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
@@ -80,7 +89,10 @@ pub async fn auth_login(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||
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)?;
|
||||
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<bool, String> {
|
||||
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
|
||||
match auth_manager
|
||||
.0
|
||||
.verify_session(&server_url, &user_id, &access_token, &device_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||
@@ -138,7 +154,10 @@ pub async fn auth_logout(
|
||||
drop(verifier_guard);
|
||||
|
||||
// Call Jellyfin logout endpoint
|
||||
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
|
||||
auth_manager
|
||||
.0
|
||||
.logout(&server_url, &access_token, &device_id)
|
||||
.await?;
|
||||
|
||||
// Clear session
|
||||
auth_manager.0.set_session(None).await;
|
||||
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
// Get current session to extract server_url and username
|
||||
let session = auth_manager.0.get_session().await
|
||||
let session = auth_manager
|
||||
.0
|
||||
.get_session()
|
||||
.await
|
||||
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||
|
||||
// Re-login with stored credentials
|
||||
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(
|
||||
&session.server_url,
|
||||
&session.username,
|
||||
&password,
|
||||
&device_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update session with new token
|
||||
let updated_session = Session {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Tauri commands for the offline "browse & queue" feature.
|
||||
//!
|
||||
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
|
||||
//!
|
||||
//! Two backend pieces support browsing the full server catalog while offline
|
||||
//! and queueing downloads that fire on reconnect:
|
||||
//!
|
||||
@@ -17,8 +19,8 @@ use std::sync::Arc;
|
||||
use log::{info, warn};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
@@ -79,7 +81,10 @@ pub async fn sync_full_catalog(
|
||||
};
|
||||
|
||||
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||
info!("[Catalog] Full sync starting across {} libraries", libraries.len());
|
||||
info!(
|
||||
"[Catalog] Full sync starting across {} libraries",
|
||||
libraries.len()
|
||||
);
|
||||
|
||||
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
@@ -104,7 +109,10 @@ pub async fn sync_full_catalog(
|
||||
items_cached += items.len();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to sync library '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
libraries_failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -208,10 +216,16 @@ where
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(ResumeQueuedResult { resolved: 0, failed: 0 });
|
||||
return Ok(ResumeQueuedResult {
|
||||
resolved: 0,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len());
|
||||
info!(
|
||||
"[Catalog] Resolving {} offline-queued downloads on reconnect",
|
||||
rows.len()
|
||||
);
|
||||
|
||||
let mut resolved = 0usize;
|
||||
let mut failed = 0usize;
|
||||
@@ -240,7 +254,10 @@ where
|
||||
Ok(n) if n > 0 => resolved += 1,
|
||||
Ok(_) => {} // already resolved by someone else; not a failure
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to persist URL for download {}: {}", download_id, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to persist URL for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -309,17 +326,22 @@ pub async fn resume_queued_downloads(
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
))
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to resolve audio URL for {}: {:?}", item_id, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -341,7 +363,10 @@ pub async fn resume_queued_downloads(
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
}
|
||||
|
||||
info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed);
|
||||
info!(
|
||||
"[Catalog] Resume complete: {} resolved, {} failed",
|
||||
resolved, failed
|
||||
);
|
||||
|
||||
Ok(ResumeQueuedResult { resolved, failed })
|
||||
}
|
||||
@@ -384,14 +409,21 @@ mod tests {
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(status.to_string()),
|
||||
stream_url.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
media_type.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
stream_url
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
media_type
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
db.execute(q).await.unwrap();
|
||||
}
|
||||
|
||||
async fn get_row(db: &Arc<RusqliteService>, item_id: &str) -> (String, Option<String>, Option<String>) {
|
||||
async fn get_row(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
) -> (String, Option<String>, Option<String>) {
|
||||
let q = Query::with_params(
|
||||
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
@@ -411,11 +443,12 @@ mod tests {
|
||||
// A completed row: irrelevant.
|
||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||
|
||||
let out = resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
@@ -455,12 +488,13 @@ mod tests {
|
||||
let db = test_db();
|
||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||
|
||||
let out = resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Server-reachability / connectivity commands.
|
||||
//!
|
||||
//! TRACES: UR-043 | IR-027 | DR-055
|
||||
|
||||
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||
|
||||
/// Wrapper for ConnectivityMonitor managed state
|
||||
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Tauri commands for unit conversions and formatting
|
||||
//!
|
||||
//! TRACES: UR-005 | DR-009
|
||||
//!
|
||||
//! These commands expose conversion utilities to the frontend,
|
||||
//! allowing centralized conversion logic in Rust.
|
||||
|
||||
use crate::utils::conversions::{
|
||||
format_time, format_time_long, calculate_progress,
|
||||
ticks_to_seconds, percent_to_volume,
|
||||
calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
|
||||
};
|
||||
|
||||
/// Format time in seconds to MM:SS display string
|
||||
|
||||
@@ -81,7 +81,10 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
|
||||
/// TRACES: UR-009 | DR-011
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
||||
pub async fn device_set_id(
|
||||
device_id: String,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
use log::{debug, error, info, warn};
|
||||
|
||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||
use crate::download::{DownloadInfo, DownloadManager};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||
|
||||
// Cohesive command clusters in their own submodules, re-exported so the command
|
||||
// names remain at `commands::download::*` (invoke_handler unchanged).
|
||||
@@ -111,7 +111,13 @@ pub async fn download_item_and_start(
|
||||
request: DownloadItemAndStartRequest,
|
||||
) -> Result<i64, String> {
|
||||
let DownloadItemAndStartRequest {
|
||||
item_id, user_id, stream_url, target_dir, item_name, artist_name, album_name,
|
||||
item_id,
|
||||
user_id,
|
||||
stream_url,
|
||||
target_dir,
|
||||
item_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
} = request;
|
||||
// Sanitize filename
|
||||
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
|
||||
@@ -132,7 +138,8 @@ pub async fn download_item_and_start(
|
||||
album_name,
|
||||
expected_size: None,
|
||||
},
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Start the download immediately
|
||||
start_download(
|
||||
@@ -142,7 +149,8 @@ pub async fn download_item_and_start(
|
||||
download_id,
|
||||
stream_url,
|
||||
target_dir,
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(download_id)
|
||||
}
|
||||
@@ -156,7 +164,15 @@ pub async fn download_item(
|
||||
request: DownloadItemRequest,
|
||||
) -> Result<i64, String> {
|
||||
let DownloadItemRequest {
|
||||
item_id, user_id, file_path, mime_type, priority, item_name, artist_name, album_name, expected_size,
|
||||
item_id,
|
||||
user_id,
|
||||
file_path,
|
||||
mime_type,
|
||||
priority,
|
||||
item_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
expected_size,
|
||||
} = request;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -172,18 +188,24 @@ pub async fn download_item(
|
||||
};
|
||||
|
||||
// Check if we have space
|
||||
let can_download = cache_arc.can_download_async(&db_service, &user_id, size as u64).await;
|
||||
let can_download = cache_arc
|
||||
.can_download_async(&db_service, &user_id, size as u64)
|
||||
.await;
|
||||
|
||||
if !can_download {
|
||||
warn!("Storage limit reached. Attempting to free space...");
|
||||
|
||||
// Try to evict LRU items to make space
|
||||
match cache_arc.evict_lru_async(&db_service, &user_id, size as u64).await {
|
||||
match cache_arc
|
||||
.evict_lru_async(&db_service, &user_id, size as u64)
|
||||
.await
|
||||
{
|
||||
Ok(freed) if freed > 0 => {
|
||||
info!("Freed {} bytes, proceeding with download", freed);
|
||||
}
|
||||
Ok(_) => {
|
||||
let storage_limit = cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
|
||||
let storage_limit =
|
||||
cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
|
||||
return Err(format!(
|
||||
"Storage limit reached ({} bytes). Unable to free enough space.",
|
||||
storage_limit
|
||||
@@ -220,7 +242,10 @@ pub async fn download_item(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Query for the download ID by unique constraint columns
|
||||
// NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
|
||||
@@ -291,12 +316,18 @@ pub async fn download_album(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![QueryParam::String(track_id), QueryParam::String(user_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(track_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let download_id: i64 = db_service
|
||||
@@ -317,8 +348,17 @@ pub async fn download_video(
|
||||
request: DownloadVideoRequest,
|
||||
) -> Result<i64, String> {
|
||||
let DownloadVideoRequest {
|
||||
item_id, user_id, file_path, mime_type, priority, item_name, quality_preset,
|
||||
series_name, season_name, episode_number, season_number,
|
||||
item_id,
|
||||
user_id,
|
||||
file_path,
|
||||
mime_type,
|
||||
priority,
|
||||
item_name,
|
||||
quality_preset,
|
||||
series_name,
|
||||
season_name,
|
||||
episode_number,
|
||||
season_number,
|
||||
} = request;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -358,7 +398,10 @@ pub async fn download_video(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Query for the download ID by unique constraint columns
|
||||
let id_query = Query::with_params(
|
||||
@@ -403,7 +446,13 @@ pub async fn download_series(
|
||||
|
||||
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service
|
||||
.query_many(episodes_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -413,7 +462,9 @@ pub async fn download_series(
|
||||
// Queue each episode with descending priority (first episodes download first)
|
||||
// Priority starts high and decreases so earlier episodes finish first
|
||||
let total_episodes = episodes.len() as i32;
|
||||
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in episodes.into_iter().enumerate() {
|
||||
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
|
||||
episodes.into_iter().enumerate()
|
||||
{
|
||||
let priority = 1000 - idx as i32; // High priority for first episodes
|
||||
|
||||
// Create path like: videos/SeriesName/S01E01_Title.mp4
|
||||
@@ -425,7 +476,12 @@ pub async fn download_series(
|
||||
episode_num,
|
||||
sanitize_filename(&episode_name)
|
||||
);
|
||||
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
|
||||
let file_path = format!(
|
||||
"{}/{}/{}",
|
||||
base_path,
|
||||
sanitize_filename(&series_name),
|
||||
file_name
|
||||
);
|
||||
|
||||
let insert_query = Query::with_params(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
|
||||
@@ -450,17 +506,29 @@ pub async fn download_series(
|
||||
QueryParam::String(episode_name),
|
||||
QueryParam::String(quality.clone()),
|
||||
QueryParam::String(series_name.clone()),
|
||||
season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
season_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
episode_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
season_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(episode_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let download_id: i64 = db_service
|
||||
@@ -471,7 +539,10 @@ pub async fn download_series(
|
||||
download_ids.push(download_id);
|
||||
}
|
||||
|
||||
info!("[download_series] Queued {} episodes for series '{}'", total_episodes, series_name);
|
||||
info!(
|
||||
"[download_series] Queued {} episodes for series '{}'",
|
||||
total_episodes, series_name
|
||||
);
|
||||
Ok(download_ids)
|
||||
}
|
||||
|
||||
@@ -524,7 +595,12 @@ pub async fn download_season(
|
||||
episode_num,
|
||||
sanitize_filename(&episode_name)
|
||||
);
|
||||
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
|
||||
let file_path = format!(
|
||||
"{}/{}/{}",
|
||||
base_path,
|
||||
sanitize_filename(&series_name),
|
||||
file_name
|
||||
);
|
||||
|
||||
let insert_query = Query::with_params(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
|
||||
@@ -550,11 +626,17 @@ pub async fn download_season(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id_query = Query::with_params(
|
||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
||||
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(episode_id),
|
||||
QueryParam::String(user_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let download_id: i64 = db_service
|
||||
@@ -565,11 +647,15 @@ pub async fn download_season(
|
||||
download_ids.push(download_id);
|
||||
}
|
||||
|
||||
info!("[download_season] Queued {} episodes for {} - {}", download_ids.len(), series_name, season_name);
|
||||
info!(
|
||||
"[download_season] Queued {} episodes for {} - {}",
|
||||
download_ids.len(),
|
||||
series_name,
|
||||
season_name
|
||||
);
|
||||
Ok(download_ids)
|
||||
}
|
||||
|
||||
|
||||
/// Helper to compute download statistics from a list of downloads
|
||||
#[allow(dead_code)]
|
||||
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
|
||||
@@ -674,7 +760,10 @@ pub async fn get_downloads(
|
||||
/// Pause a download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||
pub async fn pause_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -692,7 +781,10 @@ pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) ->
|
||||
/// Resume a paused download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||
pub async fn resume_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -738,13 +830,20 @@ pub async fn cancel_download(
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Unregister from download manager (in case it was active)
|
||||
{
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.unregister_download(download_id);
|
||||
info!("Cancelled download {}. Active downloads: {}", download_id, manager.active_count());
|
||||
info!(
|
||||
"Cancelled download {}. Active downloads: {}",
|
||||
download_id,
|
||||
manager.active_count()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete partial file if exists
|
||||
@@ -800,7 +899,10 @@ pub async fn mark_download_failed(
|
||||
|
||||
let query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||
vec![QueryParam::String(error_message), QueryParam::Int64(download_id)],
|
||||
vec![
|
||||
QueryParam::String(error_message),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
@@ -834,7 +936,10 @@ pub async fn start_download(
|
||||
})?;
|
||||
|
||||
if !manager.can_start_download() {
|
||||
warn!("Cannot start download: maximum concurrent downloads ({}) reached", manager.max_concurrent());
|
||||
warn!(
|
||||
"Cannot start download: maximum concurrent downloads ({}) reached",
|
||||
manager.max_concurrent()
|
||||
);
|
||||
debug!(" Active downloads: {}", manager.active_count());
|
||||
return Err(format!(
|
||||
"Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
|
||||
@@ -845,12 +950,19 @@ pub async fn start_download(
|
||||
// Register this download as active
|
||||
let registered = manager.register_download(download_id);
|
||||
if !registered {
|
||||
warn!("Failed to register download {}: already registered or limit reached", download_id);
|
||||
warn!(
|
||||
"Failed to register download {}: already registered or limit reached",
|
||||
download_id
|
||||
);
|
||||
return Err("Download already in progress or limit reached".to_string());
|
||||
}
|
||||
|
||||
info!("Download {} registered. Active downloads: {}/{}",
|
||||
download_id, manager.active_count(), manager.max_concurrent());
|
||||
info!(
|
||||
"Download {} registered. Active downloads: {}/{}",
|
||||
download_id,
|
||||
manager.active_count(),
|
||||
manager.max_concurrent()
|
||||
);
|
||||
}
|
||||
|
||||
// Get download info from DB
|
||||
@@ -868,21 +980,23 @@ pub async fn start_download(
|
||||
);
|
||||
|
||||
let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
|
||||
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.query_one(info_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to query download info: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
debug!(" Retrieved: item_id={}, file_path={}, file_size={:?}", item_id, file_path, file_size);
|
||||
debug!(
|
||||
" Retrieved: item_id={}, file_path={}, file_size={:?}",
|
||||
item_id, file_path, file_size
|
||||
);
|
||||
|
||||
// Make a HEAD request to get the file size from Content-Length header
|
||||
debug!("Making HEAD request to get file size...");
|
||||
let head_response = reqwest::Client::new()
|
||||
.head(&stream_url)
|
||||
.send()
|
||||
.await;
|
||||
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
||||
|
||||
let file_size_from_server = match head_response {
|
||||
Ok(response) => {
|
||||
@@ -893,7 +1007,11 @@ pub async fn start_download(
|
||||
.and_then(|v| v.parse::<i64>().ok());
|
||||
|
||||
if let Some(size) = size {
|
||||
debug!(" Got file size from server: {} bytes ({} MB)", size, size / 1024 / 1024);
|
||||
debug!(
|
||||
" Got file size from server: {} bytes ({} MB)",
|
||||
size,
|
||||
size / 1024 / 1024
|
||||
);
|
||||
} else {
|
||||
warn!(" Server didn't provide Content-Length header");
|
||||
}
|
||||
@@ -929,7 +1047,10 @@ pub async fn start_download(
|
||||
)
|
||||
};
|
||||
|
||||
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(update_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Emit started event
|
||||
let started_event = DownloadEvent::Started {
|
||||
@@ -937,7 +1058,10 @@ pub async fn start_download(
|
||||
item_id: item_id.clone(),
|
||||
};
|
||||
debug!("Emitting download-event: {:?}", started_event);
|
||||
debug!(" Serialized: {}", serde_json::to_string(&started_event).unwrap_or_default());
|
||||
debug!(
|
||||
" Serialized: {}",
|
||||
serde_json::to_string(&started_event).unwrap_or_default()
|
||||
);
|
||||
match app.emit("download-event", started_event) {
|
||||
Ok(_) => debug!(" Event emitted successfully"),
|
||||
Err(e) => error!(" Event emit failed: {:?}", e),
|
||||
@@ -998,7 +1122,10 @@ pub async fn enqueue_download(
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(update_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Kick the pump: it will start as many pending downloads as there are slots.
|
||||
let active_downloads = {
|
||||
@@ -1056,7 +1183,9 @@ pub async fn enqueue_video_downloads(
|
||||
};
|
||||
|
||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
|
||||
let stream_url = repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, None);
|
||||
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||
@@ -1067,7 +1196,10 @@ pub async fn enqueue_video_downloads(
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update_query).await {
|
||||
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
|
||||
warn!(
|
||||
"[enqueue_video] Failed to persist URL for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,7 +1269,13 @@ pub(crate) async fn pump_download_queue(
|
||||
|
||||
let candidates: Vec<(i64, String, String, String, String)> = match db_service
|
||||
.query_many(next_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -1189,7 +1327,10 @@ pub(crate) async fn pump_download_queue(
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update_query).await {
|
||||
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to mark download {} downloading: {}",
|
||||
download_id, e
|
||||
);
|
||||
if let Ok(mut a) = active_downloads.lock() {
|
||||
a.remove(&download_id);
|
||||
}
|
||||
@@ -1228,8 +1369,8 @@ fn spawn_download_worker(
|
||||
target_path: std::path::PathBuf,
|
||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||
) {
|
||||
use crate::download::{DownloadTask, DownloadWorker};
|
||||
use crate::download::events::DownloadEvent;
|
||||
use crate::download::{DownloadTask, DownloadWorker};
|
||||
use tauri::Emitter;
|
||||
|
||||
let task = DownloadTask {
|
||||
@@ -1265,7 +1406,11 @@ fn spawn_download_worker(
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
active.remove(&download_id);
|
||||
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
||||
debug!(
|
||||
" Unregistered download {}. Active downloads: {}",
|
||||
download_id,
|
||||
active.len()
|
||||
);
|
||||
}
|
||||
|
||||
// The pump runs downloads in the background, so the terminal status MUST
|
||||
@@ -1281,7 +1426,10 @@ fn spawn_download_worker(
|
||||
let database = match db.0.lock() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to lock database after download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1290,7 +1438,10 @@ fn spawn_download_worker(
|
||||
|
||||
match result {
|
||||
Ok(res) => {
|
||||
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
||||
info!(
|
||||
"Download completed successfully: {} bytes",
|
||||
res.bytes_downloaded
|
||||
);
|
||||
let file_path = target_path.to_string_lossy().to_string();
|
||||
|
||||
let update = Query::with_params(
|
||||
@@ -1305,7 +1456,10 @@ fn spawn_download_worker(
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update).await {
|
||||
error!("[pump] Failed to persist completed status for download {}: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to persist completed status for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
}
|
||||
|
||||
let completed_event = DownloadEvent::Completed {
|
||||
@@ -1329,7 +1483,10 @@ fn spawn_download_worker(
|
||||
],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(update).await {
|
||||
error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err);
|
||||
error!(
|
||||
"[pump] Failed to persist failed status for download {}: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
}
|
||||
|
||||
let failed_event = DownloadEvent::Failed {
|
||||
@@ -1352,7 +1509,10 @@ fn spawn_download_worker(
|
||||
/// Delete a completed download
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||
pub async fn delete_download(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_id: i64,
|
||||
) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1376,7 +1536,10 @@ pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -
|
||||
vec![QueryParam::Int64(download_id)],
|
||||
);
|
||||
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete actual file if exists
|
||||
if let Some(path) = file_path {
|
||||
@@ -1413,8 +1576,12 @@ fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
|
||||
episode_number: row.get(20)?,
|
||||
season_number: row.get(21)?,
|
||||
quality_preset: row.get(22)?,
|
||||
media_type: row.get::<_, Option<String>>(23)?.unwrap_or_else(|| "audio".to_string()),
|
||||
download_source: row.get::<_, Option<String>>(24)?.unwrap_or_else(|| "user".to_string()),
|
||||
media_type: row
|
||||
.get::<_, Option<String>>(23)?
|
||||
.unwrap_or_else(|| "audio".to_string()),
|
||||
download_source: row
|
||||
.get::<_, Option<String>>(24)?
|
||||
.unwrap_or_else(|| "user".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1500,7 +1667,10 @@ pub async fn get_download_storage_stats(
|
||||
/// Delete all downloads for a user
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result<i64, String> {
|
||||
pub async fn delete_all_downloads(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1598,7 +1768,10 @@ pub async fn delete_album_downloads(
|
||||
"SELECT d.file_path FROM downloads d
|
||||
JOIN items i ON d.item_id = i.id
|
||||
WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
|
||||
vec![QueryParam::String(user_id.clone()), QueryParam::String(album_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(album_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let file_paths: Vec<String> = db_service
|
||||
@@ -1665,7 +1838,6 @@ pub async fn set_max_concurrent_downloads(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1834,7 +2006,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "pending", "Status should be reset to pending after UPSERT");
|
||||
assert_eq!(
|
||||
status, "pending",
|
||||
"Status should be reset to pending after UPSERT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2069,7 +2244,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let status: String = conn
|
||||
.query_row("SELECT status FROM downloads WHERE id = ?1", params![id], |row| row.get(0))
|
||||
.query_row(
|
||||
"SELECT status FROM downloads WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "downloading");
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
||||
//!
|
||||
//! TRACES: UR-044 | DR-056
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -45,7 +47,10 @@ pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Resu
|
||||
/// Check if an item is pinned
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
||||
pub async fn is_item_pinned(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Smart-cache statistics/config and album recommendation commands.
|
||||
//!
|
||||
//! TRACES: UR-045 | DR-057
|
||||
|
||||
use std::sync::Arc;
|
||||
use log::info;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
||||
|
||||
@@ -25,11 +25,11 @@ pub use device::*;
|
||||
pub use download::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Playback-mode transfer commands (local ↔ remote).
|
||||
//!
|
||||
//! TRACES: UR-010 | DR-059
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -105,21 +109,30 @@ pub async fn playback_mode_get_remote_status(
|
||||
let controller = player.0.lock().await;
|
||||
let client_arc = controller.jellyfin_client();
|
||||
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
||||
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
|
||||
client_opt
|
||||
.as_ref()
|
||||
.ok_or("Jellyfin client not configured")?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Get session info
|
||||
match client.get_session(&session_id).await {
|
||||
Ok(Some(session)) => {
|
||||
let position_ticks = session.play_state.as_ref()
|
||||
let position_ticks = session
|
||||
.play_state
|
||||
.as_ref()
|
||||
.and_then(|ps| ps.position_ticks)
|
||||
.unwrap_or(0);
|
||||
|
||||
let duration_ticks = session.now_playing_item.as_ref()
|
||||
let duration_ticks = session
|
||||
.now_playing_item
|
||||
.as_ref()
|
||||
.and_then(|item| item.run_time_ticks)
|
||||
.unwrap_or(0);
|
||||
|
||||
let is_paused = session.play_state.as_ref()
|
||||
let is_paused = session
|
||||
.play_state
|
||||
.as_ref()
|
||||
.and_then(|ps| ps.is_paused)
|
||||
.unwrap_or(true);
|
||||
|
||||
@@ -224,17 +237,20 @@ mod tests {
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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"),
|
||||
@@ -247,8 +263,8 @@ mod tests {
|
||||
|
||||
// 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");
|
||||
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");
|
||||
@@ -257,11 +273,15 @@ mod tests {
|
||||
}
|
||||
|
||||
// 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");
|
||||
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 } => {
|
||||
PlayTracksContext::Playlist {
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
} => {
|
||||
assert_eq!(playlist_id, "pl-123");
|
||||
assert_eq!(playlist_name, "My Playlist");
|
||||
}
|
||||
@@ -270,8 +290,8 @@ mod tests {
|
||||
|
||||
// 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");
|
||||
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()));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Tauri commands for playback reporting operations
|
||||
//!
|
||||
//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
|
||||
//!
|
||||
//! These commands provide frontend access to the Rust playback reporting system,
|
||||
//! replacing the TypeScript implementation with native Rust reporting.
|
||||
//!
|
||||
@@ -16,7 +18,7 @@ use crate::commands::connectivity::ConnectivityMonitorWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::jellyfin::client::JellyfinClient;
|
||||
use crate::jellyfin::JellyfinConfig;
|
||||
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
||||
use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Tauri state wrapper for PlaybackReporter
|
||||
@@ -61,7 +63,10 @@ pub async fn playback_reporter_init(
|
||||
// Store in wrapper
|
||||
*reporter_wrapper.0.lock().await = Some(reporter);
|
||||
|
||||
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
|
||||
log::info!(
|
||||
"[PlaybackReporter] Initialized successfully for user: {}",
|
||||
user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -205,7 +210,12 @@ mod tests {
|
||||
};
|
||||
|
||||
// Verify enum variant can be created and pattern matched
|
||||
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
|
||||
if let PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
context,
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-123");
|
||||
assert_eq!(position_ticks, 15_000_000);
|
||||
assert!(context.is_some());
|
||||
@@ -225,7 +235,10 @@ mod tests {
|
||||
context: None,
|
||||
};
|
||||
|
||||
if let PlaybackOperation::Start { item_id, context, .. } = operation {
|
||||
if let PlaybackOperation::Start {
|
||||
item_id, context, ..
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-789");
|
||||
assert!(context.is_none());
|
||||
} else {
|
||||
@@ -241,7 +254,12 @@ mod tests {
|
||||
is_paused: true,
|
||||
};
|
||||
|
||||
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
|
||||
if let PlaybackOperation::Progress {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused,
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-999");
|
||||
assert_eq!(position_ticks, 30_000_000);
|
||||
assert!(is_paused);
|
||||
@@ -272,7 +290,11 @@ mod tests {
|
||||
position_ticks: 120_000_000,
|
||||
};
|
||||
|
||||
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
|
||||
if let PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} = operation
|
||||
{
|
||||
assert_eq!(item_id, "item-111");
|
||||
assert_eq!(position_ticks, 120_000_000);
|
||||
} else {
|
||||
@@ -364,7 +386,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let cloned = operation.clone();
|
||||
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
|
||||
if let PlaybackOperation::Progress {
|
||||
item_id, is_paused, ..
|
||||
} = cloned
|
||||
{
|
||||
assert_eq!(item_id, "item-clone");
|
||||
assert!(is_paused);
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
//! Queue manipulation commands (add / remove / move / skip).
|
||||
//!
|
||||
//! TRACES: UR-015 | DR-005, DR-020
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
|
||||
) -> Result<QueueStatus, String> {
|
||||
use crate::player::queue::AddPosition;
|
||||
|
||||
info!("player_add_track_by_id called: track_id={}, position={}",
|
||||
request.track_id, request.position);
|
||||
info!(
|
||||
"player_add_track_by_id called: track_id={}, position={}",
|
||||
request.track_id, request.position
|
||||
);
|
||||
|
||||
// Get repository (hybrid - supports offline/online)
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
// Fetch track metadata via repository
|
||||
info!("Fetching metadata for track {} via repository", request.track_id);
|
||||
let track = repository.get_item(&request.track_id).await
|
||||
info!(
|
||||
"Fetching metadata for track {} via repository",
|
||||
request.track_id
|
||||
);
|
||||
let track = repository
|
||||
.get_item(&request.track_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
||||
|
||||
// Check for local download first
|
||||
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
|
||||
}
|
||||
} else {
|
||||
// Get stream URL from repository (works online/offline)
|
||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||
let stream_url = repository
|
||||
.get_audio_stream_url(&track.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||
|
||||
MediaSource::Remote {
|
||||
@@ -188,23 +201,30 @@ pub async fn player_add_track_by_id(
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
artist: track
|
||||
.album_artist
|
||||
.clone()
|
||||
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
album: track.album_name.clone(),
|
||||
album_name: track.album_name.clone(), // Frontend compatibility
|
||||
album_id: track.album_id.clone(),
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||
track.album_id.as_ref().map(|album_id| {
|
||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}))
|
||||
repository.get_image_url(
|
||||
album_id,
|
||||
ImageType::Primary,
|
||||
Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -250,18 +270,25 @@ pub async fn player_add_tracks_by_ids(
|
||||
) -> Result<QueueStatus, String> {
|
||||
use crate::player::queue::AddPosition;
|
||||
|
||||
info!("player_add_tracks_by_ids called: {} tracks, position={}",
|
||||
request.track_ids.len(), request.position);
|
||||
info!(
|
||||
"player_add_tracks_by_ids called: {} tracks, position={}",
|
||||
request.track_ids.len(),
|
||||
request.position
|
||||
);
|
||||
|
||||
// Get repository (hybrid - supports offline/online)
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
// Fetch metadata and build MediaItems for all tracks
|
||||
let mut media_items = Vec::new();
|
||||
for track_id in &request.track_ids {
|
||||
info!("Fetching metadata for track {} via repository", track_id);
|
||||
let track = repository.get_item(track_id).await
|
||||
let track = repository
|
||||
.get_item(track_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
||||
|
||||
// Check for local download first
|
||||
@@ -275,7 +302,9 @@ pub async fn player_add_tracks_by_ids(
|
||||
}
|
||||
} else {
|
||||
// Get stream URL from repository (works online/offline)
|
||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||
let stream_url = repository
|
||||
.get_audio_stream_url(&track.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||
|
||||
MediaSource::Remote {
|
||||
@@ -290,23 +319,30 @@ pub async fn player_add_tracks_by_ids(
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
artist: track
|
||||
.album_artist
|
||||
.clone()
|
||||
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
album: track.album_name.clone(),
|
||||
album_name: track.album_name.clone(), // Frontend compatibility
|
||||
album_id: track.album_id.clone(),
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||
track.album_id.as_ref().map(|album_id| {
|
||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}))
|
||||
repository.get_image_url(
|
||||
album_id,
|
||||
ImageType::Primary,
|
||||
Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -339,7 +375,10 @@ pub async fn player_add_tracks_by_ids(
|
||||
drop(queue_lock);
|
||||
controller.emit_queue_changed();
|
||||
|
||||
info!("Successfully added {} tracks to queue", request.track_ids.len());
|
||||
info!(
|
||||
"Successfully added {} tracks to queue",
|
||||
request.track_ids.len()
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Remote Jellyfin session control commands (casting to another device).
|
||||
//!
|
||||
//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
|
||||
//!
|
||||
//! These thin command adapters forward control actions to the active Jellyfin
|
||||
//! session via the player's configured `JellyfinClient`.
|
||||
|
||||
@@ -17,22 +19,36 @@ pub async fn remote_play_on_session(
|
||||
item_ids: Vec<String>,
|
||||
start_index: usize,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
|
||||
log::info!(
|
||||
"[RemoteSession] Playing {} items on session {} (start index: {})",
|
||||
item_ids.len(),
|
||||
session_id,
|
||||
start_index
|
||||
);
|
||||
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
||||
client.play_on_session(session_id, item_ids, start_index, None).await?;
|
||||
client
|
||||
.play_on_session(session_id, item_ids, start_index, None)
|
||||
.await?;
|
||||
log::info!("[RemoteSession] Successfully started playback on remote session");
|
||||
Ok(())
|
||||
} else {
|
||||
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
||||
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
|
||||
Err(
|
||||
"Jellyfin client not configured - please restart the app or log out and log back in"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +60,19 @@ pub async fn remote_send_command(
|
||||
session_id: String,
|
||||
command: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
|
||||
log::info!(
|
||||
"[RemoteSession] Sending command '{}' to session {}",
|
||||
command,
|
||||
session_id
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -68,11 +92,19 @@ pub async fn remote_session_seek(
|
||||
session_id: String,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
|
||||
log::info!(
|
||||
"[RemoteSession] Seeking to {} ticks on session {}",
|
||||
position_ticks,
|
||||
session_id
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -92,11 +124,19 @@ pub async fn remote_session_set_volume(
|
||||
session_id: String,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
|
||||
log::info!(
|
||||
"[RemoteSession] Setting volume to {} on session {}",
|
||||
volume,
|
||||
session_id
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -119,7 +159,11 @@ pub async fn remote_session_toggle_mute(
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -145,7 +189,11 @@ pub async fn lms_get_sync_groups(
|
||||
) -> Result<Vec<LmsSyncGroup>, String> {
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -164,11 +212,19 @@ pub async fn lms_create_sync_group(
|
||||
master_mac: String,
|
||||
slave_macs: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
|
||||
log::info!(
|
||||
"[LmsSync] Fusing zones: master={}, slaves={:?}",
|
||||
master_mac,
|
||||
slave_macs
|
||||
);
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -189,7 +245,11 @@ pub async fn lms_unsync_player(
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
@@ -210,7 +270,11 @@ pub async fn lms_dissolve_sync_group(
|
||||
|
||||
let client_opt = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||
controller
|
||||
.jellyfin_client()
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if let Some(client) = client_opt {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Media session state commands.
|
||||
//!
|
||||
//! TRACES: UR-005 | DR-009
|
||||
//!
|
||||
//! Read and dismiss the current media session (the Now Playing surface backing
|
||||
//! lockscreen/notification controls).
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
|
||||
|
||||
use tauri::State;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Sleep-timer and autoplay commands.
|
||||
//!
|
||||
//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
|
||||
//!
|
||||
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
|
||||
//! logic, plus persistence of autoplay settings to the database.
|
||||
|
||||
@@ -7,8 +9,8 @@ use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::{
|
||||
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
|
||||
PlayerStateWrapper,
|
||||
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
|
||||
PlayerStatus,
|
||||
};
|
||||
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
@@ -127,7 +129,9 @@ pub async fn player_play_next_episode(
|
||||
let media_item = create_media_item(item, Some(&db)).await?;
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
controller.play_item(media_item).map_err(|e| e.to_string())?;
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(get_player_status(&controller))
|
||||
}
|
||||
@@ -164,7 +168,10 @@ pub async fn player_on_playback_ended(
|
||||
if let Some(repo) = repo {
|
||||
controller.on_video_playback_ended(id, repo).await?
|
||||
} else {
|
||||
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
|
||||
log::warn!(
|
||||
"[Autoplay] No repository available for video autoplay (itemId: {})",
|
||||
id
|
||||
);
|
||||
AutoplayDecision::Stop
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use log::debug;
|
||||
use tauri::State;
|
||||
|
||||
use crate::repository::{MediaRepository, types::*};
|
||||
use super::repository::RepositoryManagerWrapper;
|
||||
use crate::repository::{types::*, MediaRepository};
|
||||
|
||||
/// Create a new playlist
|
||||
#[tauri::command]
|
||||
@@ -21,7 +21,8 @@ pub async fn playlist_create(
|
||||
debug!("[PLAYLIST] create called: name={}", name);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
let ids = item_ids.unwrap_or_default();
|
||||
repo.as_ref().create_playlist(&name, &ids)
|
||||
repo.as_ref()
|
||||
.create_playlist(&name, &ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -36,7 +37,8 @@ pub async fn playlist_delete(
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] delete called: id={}", playlist_id);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().delete_playlist(&playlist_id)
|
||||
repo.as_ref()
|
||||
.delete_playlist(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -50,9 +52,13 @@ pub async fn playlist_rename(
|
||||
playlist_id: String,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
|
||||
debug!(
|
||||
"[PLAYLIST] rename called: id={}, name={}",
|
||||
playlist_id, name
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().rename_playlist(&playlist_id, &name)
|
||||
repo.as_ref()
|
||||
.rename_playlist(&playlist_id, &name)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -67,7 +73,8 @@ pub async fn playlist_get_items(
|
||||
) -> Result<Vec<PlaylistEntry>, String> {
|
||||
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_playlist_items(&playlist_id)
|
||||
repo.as_ref()
|
||||
.get_playlist_items(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -81,9 +88,14 @@ pub async fn playlist_add_items(
|
||||
playlist_id: String,
|
||||
item_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
|
||||
debug!(
|
||||
"[PLAYLIST] add_items called: id={}, count={}",
|
||||
playlist_id,
|
||||
item_ids.len()
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
|
||||
repo.as_ref()
|
||||
.add_to_playlist(&playlist_id, &item_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -97,9 +109,14 @@ pub async fn playlist_remove_items(
|
||||
playlist_id: String,
|
||||
entry_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
|
||||
debug!(
|
||||
"[PLAYLIST] remove_items called: id={}, count={}",
|
||||
playlist_id,
|
||||
entry_ids.len()
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
|
||||
repo.as_ref()
|
||||
.remove_from_playlist(&playlist_id, &entry_ids)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -114,9 +131,13 @@ pub async fn playlist_move_item(
|
||||
item_id: String,
|
||||
new_index: u32,
|
||||
) -> Result<(), String> {
|
||||
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
|
||||
debug!(
|
||||
"[PLAYLIST] move_item called: playlist={}, item={}, index={}",
|
||||
playlist_id, item_id, new_index
|
||||
);
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
|
||||
repo.as_ref()
|
||||
.move_playlist_item(&playlist_id, &item_id, new_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
|
||||
use crate::repository::{
|
||||
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||
};
|
||||
|
||||
/// Repository handle manager
|
||||
pub struct RepositoryManager {
|
||||
@@ -81,8 +83,13 @@ pub async fn repository_create(
|
||||
|
||||
// Create online repository wired to connectivity reporting
|
||||
debug!("[REPO] Creating online repository...");
|
||||
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
let online = OnlineRepository::new(
|
||||
Arc::new(http_client),
|
||||
server_url,
|
||||
user_id.clone(),
|
||||
access_token,
|
||||
)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
debug!("[REPO] Online repository created");
|
||||
|
||||
// Create offline repository with async-safe database service
|
||||
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
|
||||
"Repository not found".to_string()
|
||||
})?;
|
||||
debug!("[REPO] Repository found, fetching libraries...");
|
||||
repo.as_ref().get_libraries()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
repo.as_ref().get_libraries().await.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get items in a container (library, folder, album, etc.)
|
||||
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_items(&parent_id, options)
|
||||
repo.as_ref()
|
||||
.get_items(&parent_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -183,7 +189,8 @@ pub async fn repository_get_item(
|
||||
item_id: String,
|
||||
) -> Result<MediaItem, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_item(&item_id)
|
||||
repo.as_ref()
|
||||
.get_item(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -200,7 +207,8 @@ pub async fn repository_jray_actors_at(
|
||||
t: f64,
|
||||
) -> Result<Vec<crate::repository::JRayActor>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_jray_actors(&item_id, t)
|
||||
repo.as_ref()
|
||||
.get_jray_actors(&item_id, t)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -215,7 +223,8 @@ pub async fn repository_get_latest_items(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_latest_items(&parent_id, limit)
|
||||
repo.as_ref()
|
||||
.get_latest_items(&parent_id, limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -235,7 +244,8 @@ pub async fn repository_get_resume_items(
|
||||
"Repository not found".to_string()
|
||||
})?;
|
||||
debug!("[REPO] Repository found, fetching resume items...");
|
||||
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
|
||||
repo.as_ref()
|
||||
.get_resume_items(parent_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[REPO] Error fetching resume items: {:?}", e);
|
||||
@@ -253,7 +263,8 @@ pub async fn repository_get_next_up_episodes(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
|
||||
repo.as_ref()
|
||||
.get_next_up_episodes(series_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -267,7 +278,8 @@ pub async fn repository_get_recently_played_audio(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_recently_played_audio(limit)
|
||||
repo.as_ref()
|
||||
.get_recently_played_audio(limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -281,7 +293,8 @@ pub async fn repository_get_resume_movies(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_resume_movies(limit)
|
||||
repo.as_ref()
|
||||
.get_resume_movies(limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -296,7 +309,8 @@ pub async fn repository_get_rediscover_albums(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
|
||||
repo.as_ref()
|
||||
.get_rediscover_albums(parent_id.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -310,7 +324,8 @@ pub async fn repository_get_genres(
|
||||
parent_id: Option<String>,
|
||||
) -> Result<Vec<Genre>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_genres(parent_id.as_deref())
|
||||
repo.as_ref()
|
||||
.get_genres(parent_id.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -363,8 +378,7 @@ pub async fn repository_search(
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match repo_bg.search_server_only(&query, options).await {
|
||||
Ok(server_result) => {
|
||||
let merged =
|
||||
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||
let event = SearchUpdateEvent {
|
||||
request_id,
|
||||
result: merged,
|
||||
@@ -376,7 +390,10 @@ pub async fn repository_search(
|
||||
Err(e) => {
|
||||
// Server failed — the cache results are already on screen, so
|
||||
// just log. (Offline / unreachable server falls here.)
|
||||
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
|
||||
warn!(
|
||||
"[Search] Server search failed, keeping cache results: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -393,7 +410,8 @@ pub async fn repository_get_playback_info(
|
||||
item_id: String,
|
||||
) -> Result<PlaybackInfo, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_playback_info(&item_id)
|
||||
repo.as_ref()
|
||||
.get_playback_info(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -421,6 +439,31 @@ pub async fn repository_get_video_stream_url(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032 | UT-061
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_audio_only_stream_url_for_video(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
media_source_id: Option<String>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.get_audio_only_stream_url_for_video(
|
||||
&item_id,
|
||||
media_source_id.as_deref(),
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get audio stream URL for a track
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -489,7 +532,8 @@ pub async fn repository_report_playback_start(
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().report_playback_start(&item_id, position_ticks)
|
||||
repo.as_ref()
|
||||
.report_playback_start(&item_id, position_ticks)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -504,7 +548,8 @@ pub async fn repository_report_playback_progress(
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().report_playback_progress(&item_id, position_ticks)
|
||||
repo.as_ref()
|
||||
.report_playback_progress(&item_id, position_ticks)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -519,7 +564,8 @@ pub async fn repository_report_playback_stopped(
|
||||
position_ticks: i64,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
|
||||
repo.as_ref()
|
||||
.report_playback_stopped(&item_id, position_ticks)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -551,7 +597,9 @@ pub fn repository_get_subtitle_url(
|
||||
format: String,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
||||
}
|
||||
|
||||
/// Get video download URL with quality preset
|
||||
@@ -566,7 +614,9 @@ pub fn repository_get_video_download_url(
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
Ok(repo
|
||||
.as_ref()
|
||||
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
}
|
||||
|
||||
/// Mark an item as favorite
|
||||
@@ -578,7 +628,8 @@ pub async fn repository_mark_favorite(
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().mark_favorite(&item_id)
|
||||
repo.as_ref()
|
||||
.mark_favorite(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -592,7 +643,8 @@ pub async fn repository_unmark_favorite(
|
||||
item_id: String,
|
||||
) -> Result<(), String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().unmark_favorite(&item_id)
|
||||
repo.as_ref()
|
||||
.unmark_favorite(&item_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -606,7 +658,8 @@ pub async fn repository_get_person(
|
||||
person_id: String,
|
||||
) -> Result<MediaItem, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_person(&person_id)
|
||||
repo.as_ref()
|
||||
.get_person(&person_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -621,7 +674,8 @@ pub async fn repository_get_items_by_person(
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_items_by_person(&person_id, options)
|
||||
repo.as_ref()
|
||||
.get_items_by_person(&person_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -636,7 +690,8 @@ pub async fn repository_get_similar_items(
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_similar_items(&item_id, limit)
|
||||
repo.as_ref()
|
||||
.get_similar_items(&item_id, limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! TRACES: UR-010 | JA-021 | DR-037
|
||||
|
||||
use crate::jellyfin::client::SessionInfo;
|
||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||
use crate::jellyfin::client::SessionInfo;
|
||||
|
||||
/// Tauri state wrapper for SessionPollerManager
|
||||
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Tauri commands for database/storage operations
|
||||
//!
|
||||
//! TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -7,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::credentials::CredentialStore;
|
||||
use crate::storage::Database;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::storage::Database;
|
||||
use crate::thumbnail::ThumbnailCache;
|
||||
|
||||
use super::SmartCacheWrapper;
|
||||
@@ -86,7 +88,8 @@ pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
|
||||
let db_path = database.path();
|
||||
|
||||
// Return the parent directory instead of the database file path
|
||||
let storage_dir = db_path.parent()
|
||||
let storage_dir = db_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())?;
|
||||
|
||||
Ok(storage_dir.to_string_lossy().to_string())
|
||||
@@ -160,13 +163,16 @@ pub async fn storage_save_server(
|
||||
/// Get all saved servers
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<ServerInfo>, String> {
|
||||
pub async fn storage_get_servers(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
) -> Result<Vec<ServerInfo>, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
|
||||
let query =
|
||||
Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
|
||||
|
||||
let servers = db_service
|
||||
.query_many(query, |row| {
|
||||
@@ -221,7 +227,10 @@ pub async fn storage_delete_server(
|
||||
vec![QueryParam::String(server_id)],
|
||||
);
|
||||
|
||||
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -237,7 +246,10 @@ pub async fn storage_save_user(
|
||||
username: String,
|
||||
access_token: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
info!("storage_save_user called: id={}, server_id={}, username={}", id, server_id, username);
|
||||
info!(
|
||||
"storage_save_user called: id={}, server_id={}, username={}",
|
||||
id, server_id, username
|
||||
);
|
||||
|
||||
let (db_service, db_path) = {
|
||||
let database = db.0.lock().map_err(|e| {
|
||||
@@ -277,7 +289,10 @@ pub async fn storage_save_user(
|
||||
"SELECT COUNT(*) FROM users WHERE id = ?",
|
||||
vec![QueryParam::String(id.clone())],
|
||||
);
|
||||
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let verify_count: i32 = db_service
|
||||
.query_one(verify_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
debug!("VERIFY: {} users with id={} after insert", verify_count, id);
|
||||
debug!("Database path: {:?}", db_path);
|
||||
|
||||
@@ -355,14 +370,20 @@ pub async fn storage_set_active_user(
|
||||
|
||||
// Deactivate ALL users globally (since we only connect to one server at a time)
|
||||
let deactivate_query = Query::new("UPDATE users SET is_active = 0");
|
||||
db_service.execute(deactivate_query).await.map_err(|e| e.to_string())?;
|
||||
db_service
|
||||
.execute(deactivate_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Activate the specified user and update last_login_at
|
||||
let activate_query = Query::with_params(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.clone())],
|
||||
);
|
||||
let rows_affected = db_service.execute(activate_query).await.map_err(|e| e.to_string())?;
|
||||
let rows_affected = db_service
|
||||
.execute(activate_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
debug!("storage_set_active_user: {} rows affected", rows_affected);
|
||||
|
||||
@@ -372,7 +393,10 @@ pub async fn storage_set_active_user(
|
||||
|
||||
// Verify the user is now active
|
||||
let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
|
||||
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let verify_count: i32 = db_service
|
||||
.query_one(verify_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
debug!("VERIFY: {} active users after set_active", verify_count);
|
||||
debug!("Database path: {:?}", db_path);
|
||||
|
||||
@@ -434,12 +458,21 @@ pub async fn storage_get_active_session(
|
||||
|
||||
// Debug: count total users and active users
|
||||
let total_query = Query::new("SELECT COUNT(*) FROM users");
|
||||
let total_users: i32 = db_service.query_one(total_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let total_users: i32 = db_service
|
||||
.query_one(total_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
|
||||
let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
|
||||
let active_users: i32 = db_service.query_one(active_query, |row| row.get(0)).await.unwrap_or(-1);
|
||||
let active_users: i32 = db_service
|
||||
.query_one(active_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
|
||||
debug!("Database state: {} total users, {} active users", total_users, active_users);
|
||||
debug!(
|
||||
"Database state: {} total users, {} active users",
|
||||
total_users, active_users
|
||||
);
|
||||
debug!("Database path: {:?}", db_path);
|
||||
|
||||
// Find active user with their server info, ordered by most recently logged in
|
||||
@@ -449,18 +482,21 @@ pub async fn storage_get_active_session(
|
||||
JOIN servers s ON u.server_id = s.id
|
||||
WHERE u.is_active = 1
|
||||
ORDER BY u.last_login_at DESC
|
||||
LIMIT 1"
|
||||
LIMIT 1",
|
||||
);
|
||||
|
||||
let result = db_service.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
}).await.map_err(|e| e.to_string())?;
|
||||
let result = db_service
|
||||
.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match result {
|
||||
Some((user_id, username, server_id, server_url, server_name)) => {
|
||||
@@ -478,7 +514,7 @@ pub async fn storage_get_active_session(
|
||||
server_name,
|
||||
access_token,
|
||||
}))
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Token not found or error - session is invalid
|
||||
warn!("Failed to get token from secure storage: {:?}", e);
|
||||
@@ -638,8 +674,12 @@ pub async fn storage_update_playback_context(
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::Int64(position_ticks),
|
||||
context_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
context_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
context_type
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
context_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -721,14 +761,23 @@ pub async fn storage_mark_played(
|
||||
});
|
||||
|
||||
if !tracks.is_empty() {
|
||||
info!("Auto-queueing {} tracks from album for download", tracks.len());
|
||||
info!(
|
||||
"Auto-queueing {} tracks from album for download",
|
||||
tracks.len()
|
||||
);
|
||||
|
||||
// Queue each track with high priority (50) and mark as auto-downloaded
|
||||
for (track_id, track_name, artist_name, album_name) in tracks {
|
||||
// Generate a sanitized file path (simplified version)
|
||||
let sanitized_name = track_name
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' })
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
|
||||
|
||||
@@ -1123,7 +1172,10 @@ pub async fn storage_search_items(
|
||||
limit_clause
|
||||
);
|
||||
|
||||
let query_obj = Query::with_params(sql, vec![QueryParam::String(server_id), QueryParam::String(fts_query)]);
|
||||
let query_obj = Query::with_params(
|
||||
sql,
|
||||
vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
|
||||
);
|
||||
|
||||
let items = db_service
|
||||
.query_many(query_obj, row_to_cached_item)
|
||||
@@ -1181,7 +1233,8 @@ pub async fn storage_save_item(
|
||||
};
|
||||
|
||||
// Generate sort_name from name (remove leading "The ", "A ", etc.)
|
||||
let sort_name = item.name
|
||||
let sort_name = item
|
||||
.name
|
||||
.strip_prefix("The ")
|
||||
.or_else(|| item.name.strip_prefix("A "))
|
||||
.or_else(|| item.name.strip_prefix("An "))
|
||||
@@ -1202,28 +1255,66 @@ pub async fn storage_save_item(
|
||||
vec![
|
||||
QueryParam::String(item.id),
|
||||
QueryParam::String(server_id),
|
||||
item.library_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.parent_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.library_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.parent_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(item.name),
|
||||
QueryParam::String(sort_name),
|
||||
QueryParam::String(item.item_type),
|
||||
item.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.genres.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.runtime_ticks.map(QueryParam::Int64).unwrap_or(QueryParam::Null),
|
||||
item.production_year.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
item.community_rating.map(QueryParam::Float).unwrap_or(QueryParam::Null),
|
||||
item.official_rating.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.album_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.album_artist.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.artists.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
item.series_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.season_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
item.parent_index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
item.overview
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.genres
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.runtime_ticks
|
||||
.map(QueryParam::Int64)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.production_year
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.community_rating
|
||||
.map(QueryParam::Float)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.official_rating
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.primary_image_tag
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.album_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.album_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.album_artist
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.artists
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.index_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.series_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.series_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.season_id
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.season_name
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
item.parent_index_number
|
||||
.map(QueryParam::Int)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1256,7 +1347,6 @@ pub async fn storage_get_pending_sync_count(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Person/cast metadata cache commands.
|
||||
//!
|
||||
//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
|
||||
/// Cached person info returned to frontend
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -54,10 +55,22 @@ pub async fn storage_save_person(
|
||||
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),
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -117,25 +130,32 @@ pub async fn storage_save_item_people(
|
||||
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 (
|
||||
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())?;
|
||||
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(())
|
||||
}
|
||||
@@ -176,4 +196,3 @@ pub async fn storage_get_item_people(
|
||||
|
||||
Ok(people)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Per-series preferred audio track commands.
|
||||
//!
|
||||
//! TRACES: UR-021 | DR-024
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
|
||||
/// Audio track preference for a series
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Thumbnail cache and image-URL commands.
|
||||
//!
|
||||
//! TRACES: UR-007 | JA-028 | DR-016
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::Semaphore;
|
||||
use serde::Deserialize;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tauri::State;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
@@ -11,7 +13,6 @@ 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]
|
||||
@@ -28,7 +29,8 @@ pub async fn thumbnail_get_cached(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let result = thumbnail_cache.0
|
||||
let result = thumbnail_cache
|
||||
.0
|
||||
.get_cached_path(db_service, &item_id, &image_type, &tag)
|
||||
.await
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
@@ -61,7 +63,10 @@ pub async fn thumbnail_save(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -179,7 +184,7 @@ pub async fn image_get_url(
|
||||
repository_handle: String,
|
||||
request: GetImageRequest,
|
||||
) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use std::fs;
|
||||
|
||||
let tag = request.tag.as_deref().unwrap_or("default");
|
||||
@@ -191,14 +196,18 @@ pub async fn image_get_url(
|
||||
};
|
||||
|
||||
// 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))?;
|
||||
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));
|
||||
@@ -206,10 +215,14 @@ pub async fn image_get_url(
|
||||
|
||||
// Not cached — fetch from server and cache.
|
||||
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
|
||||
let _permit = image_semaphore().acquire().await
|
||||
let _permit = image_semaphore()
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| "Image download semaphore closed".to_string())?;
|
||||
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
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() {
|
||||
@@ -229,18 +242,23 @@ pub async fn image_get_url(
|
||||
};
|
||||
|
||||
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||
let image_data = repository.download_bytes(&server_url).await
|
||||
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 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()));
|
||||
|
||||
@@ -53,7 +53,10 @@ pub async fn sync_queue_mutation(
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
|
||||
let id = db_service
|
||||
.last_insert_rowid()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
@@ -110,10 +113,7 @@ pub async fn sync_get_pending(
|
||||
/// Mark a sync operation as in progress
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_mark_processing(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -131,10 +131,7 @@ pub async fn sync_mark_processing(
|
||||
/// Mark a sync operation as completed
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_mark_completed(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
|
||||
Reference in New Issue
Block a user