Files
jellytau/src-tauri/src/commands/playback_reporting.rs
T
dtourolle 3fbf6afdbc 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.
2026-07-22 21:52:07 +02:00

429 lines
13 KiB
Rust

//! 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.
//!
//! Commands are registered but not yet called from the frontend.
//! Dead code warnings are suppressed until frontend migration is complete.
#![allow(dead_code)]
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex as TokioMutex;
use crate::commands::connectivity::ConnectivityMonitorWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::jellyfin::client::JellyfinClient;
use crate::jellyfin::JellyfinConfig;
use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
use crate::utils::conversions::seconds_to_ticks;
/// Tauri state wrapper for PlaybackReporter
pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>);
/// Initialize playback reporter (called after login)
#[tauri::command]
#[specta::specta]
pub async fn playback_reporter_init(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String,
user_id: String,
access_token: String,
device_id: String,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Initializing for user: {}", user_id);
// Get database service
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Create JellyfinClient
let jellyfin_config = JellyfinConfig {
server_url,
access_token,
device_id,
};
let jellyfin_client = JellyfinClient::new(jellyfin_config)
.map_err(|e| format!("Failed to create JellyfinClient: {}", e))?;
// Create PlaybackReporter
let reporter = PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(jellyfin_client))),
user_id.clone(),
);
// Store in wrapper
*reporter_wrapper.0.lock().await = Some(reporter);
log::info!(
"[PlaybackReporter] Initialized successfully for user: {}",
user_id
);
Ok(())
}
/// Destroy playback reporter (called on logout)
#[tauri::command]
#[specta::specta]
pub async fn playback_reporter_destroy(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Destroying reporter");
*reporter_wrapper.0.lock().await = None;
Ok(())
}
/// Report playback start
#[tauri::command]
#[specta::specta]
pub async fn playback_report_start(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
context_type: Option<String>,
context_id: Option<String>,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let context = context_type.map(|ct| PlaybackContext {
context_type: ct,
context_id,
});
let operation = PlaybackOperation::Start {
item_id,
position_ticks,
context,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Report playback progress
#[tauri::command]
#[specta::specta]
pub async fn playback_report_progress(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
is_paused: bool,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let operation = PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Report playback stopped
#[tauri::command]
#[specta::specta]
pub async fn playback_report_stopped(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let operation = PlaybackOperation::Stopped {
item_id,
position_ticks,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Mark item as played
#[tauri::command]
#[specta::specta]
pub async fn playback_mark_played(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let operation = PlaybackOperation::MarkPlayed { item_id };
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_playback_operation_start_creation() {
let operation = PlaybackOperation::Start {
item_id: "item-123".to_string(),
position_ticks: 15_000_000,
context: Some(PlaybackContext {
context_type: "series".to_string(),
context_id: Some("series-456".to_string()),
}),
};
// Verify enum variant can be created and pattern matched
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());
let ctx = context.unwrap();
assert_eq!(ctx.context_type, "series");
assert_eq!(ctx.context_id, Some("series-456".to_string()));
} else {
panic!("Expected Start variant");
}
}
#[test]
fn test_playback_operation_start_without_context() {
let operation = PlaybackOperation::Start {
item_id: "item-789".to_string(),
position_ticks: 5_000_000,
context: None,
};
if let PlaybackOperation::Start {
item_id, context, ..
} = operation
{
assert_eq!(item_id, "item-789");
assert!(context.is_none());
} else {
panic!("Expected Start variant");
}
}
#[test]
fn test_playback_operation_progress_creation() {
let operation = PlaybackOperation::Progress {
item_id: "item-999".to_string(),
position_ticks: 30_000_000,
is_paused: true,
};
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);
} else {
panic!("Expected Progress variant");
}
}
#[test]
fn test_playback_operation_progress_playing() {
let operation = PlaybackOperation::Progress {
item_id: "item-555".to_string(),
position_ticks: 45_000_000,
is_paused: false,
};
if let PlaybackOperation::Progress { is_paused, .. } = operation {
assert!(!is_paused);
} else {
panic!("Expected Progress variant");
}
}
#[test]
fn test_playback_operation_stopped_creation() {
let operation = PlaybackOperation::Stopped {
item_id: "item-111".to_string(),
position_ticks: 120_000_000,
};
if let PlaybackOperation::Stopped {
item_id,
position_ticks,
} = operation
{
assert_eq!(item_id, "item-111");
assert_eq!(position_ticks, 120_000_000);
} else {
panic!("Expected Stopped variant");
}
}
#[test]
fn test_playback_operation_mark_played_creation() {
let operation = PlaybackOperation::MarkPlayed {
item_id: "item-222".to_string(),
};
if let PlaybackOperation::MarkPlayed { item_id } = operation {
assert_eq!(item_id, "item-222");
} else {
panic!("Expected MarkPlayed variant");
}
}
#[test]
fn test_playback_context_with_series() {
let context = PlaybackContext {
context_type: "series".to_string(),
context_id: Some("series-789".to_string()),
};
assert_eq!(context.context_type, "series");
assert_eq!(context.context_id, Some("series-789".to_string()));
}
#[test]
fn test_playback_context_without_id() {
let context = PlaybackContext {
context_type: "folder".to_string(),
context_id: None,
};
assert_eq!(context.context_type, "folder");
assert!(context.context_id.is_none());
}
#[test]
fn test_playback_context_clone() {
let context = PlaybackContext {
context_type: "container".to_string(),
context_id: Some("container-123".to_string()),
};
let cloned = context.clone();
assert_eq!(cloned.context_type, "container");
assert_eq!(cloned.context_id, Some("container-123".to_string()));
}
#[test]
fn test_seconds_to_ticks_conversion() {
assert_eq!(seconds_to_ticks(0.0), 0);
assert_eq!(seconds_to_ticks(1.0), 10_000_000);
assert_eq!(seconds_to_ticks(1.5), 15_000_000);
assert_eq!(seconds_to_ticks(120.0), 1_200_000_000);
}
#[test]
fn test_playback_reporter_wrapper_structure() {
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
assert_eq!(std::mem::size_of::<PlaybackReporterWrapper>() > 0, true);
}
#[test]
fn test_playback_operation_debug_trait() {
// Verify Debug trait is implemented for operations
let operation = PlaybackOperation::Start {
item_id: "item-1".to_string(),
position_ticks: 0,
context: None,
};
let debug_str = format!("{:?}", operation);
assert!(debug_str.contains("Start"));
assert!(debug_str.contains("item-1"));
}
#[test]
fn test_playback_operation_clone() {
let operation = PlaybackOperation::Progress {
item_id: "item-clone".to_string(),
position_ticks: 50_000_000,
is_paused: true,
};
let cloned = operation.clone();
if let PlaybackOperation::Progress {
item_id, is_paused, ..
} = cloned
{
assert_eq!(item_id, "item-clone");
assert!(is_paused);
} else {
panic!("Clone failed to preserve variant");
}
}
#[test]
fn test_playback_operation_all_variants() {
// Test that all operation variants can be created and matched
let start_op = PlaybackOperation::Start {
item_id: "i1".to_string(),
position_ticks: 0,
context: None,
};
assert!(matches!(start_op, PlaybackOperation::Start { .. }));
let progress_op = PlaybackOperation::Progress {
item_id: "i2".to_string(),
position_ticks: 100,
is_paused: false,
};
assert!(matches!(progress_op, PlaybackOperation::Progress { .. }));
let stopped_op = PlaybackOperation::Stopped {
item_id: "i3".to_string(),
position_ticks: 200,
};
assert!(matches!(stopped_op, PlaybackOperation::Stopped { .. }));
let played_op = PlaybackOperation::MarkPlayed {
item_id: "i4".to_string(),
};
assert!(matches!(played_op, PlaybackOperation::MarkPlayed { .. }));
}
}