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:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+48 -18
View File
@@ -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 {
+62 -28
View File
@@ -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;
+5 -1
View File
@@ -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>>);
+3 -2
View File
@@ -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
+4 -1
View File
@@ -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())
+246 -67
View File
@@ -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");
+6 -1
View File
@@ -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};
+2 -2
View File
@@ -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::*;
+35 -15
View File
@@ -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()));
+32 -7
View File
@@ -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
+65 -26
View File
@@ -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)
}
+80 -16
View File
@@ -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 {
+2
View File
@@ -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;
+11 -4
View File
@@ -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 {
+33 -12
View File
@@ -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))
}
+88 -33
View File
@@ -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))
}
+2 -2
View File
@@ -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>);
+140 -50
View File
@@ -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::*;
+43 -24
View File
@@ -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")]
+44 -26
View File
@@ -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()));
+6 -9
View File
@@ -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())