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
+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()));