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::*;