`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero. Most were mechanical — needless borrows, `assert_eq!` against a bool literal, `vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only to index — and were applied with `clippy --fix`, then reviewed line by line. That review caught one auto-fix that was *not* semantically neutral: dropping the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned directly above `SERVICE_NAME`, which would have silently cfg'd the constant out of every non-Linux build. Removed the stray attribute with the import. Where a lint asked for a risky change rather than a better one, it is suppressed with a comment saying why: - `too_many_arguments` on five `#[tauri::command]` handlers and `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>` injection, and a parameter struct would change the IPC contract and the generated TypeScript for no readability gain. - `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are serde + specta wire types emitted a handful of times a second, never bulk allocated; boxing would have to stay invisible to the generated bindings while every match arm gained a deref. - `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE` flag, and the await it spans *is* the critical section. Each `#[tokio::test]` gets its own single-threaded runtime, so this is not the production deadlock class the lint targets; restructuring would reintroduce the flag race. Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it is now `into_media_item`; the five-tuple episode row in the download commands has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches `name: "pause"` instead of guarding on it. Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`, completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be in test modules — production code was already clean — so this is consistency rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose: those tests deliberately poison a mutex to prove the helpers recover from it. Pure refactoring: all 698 tests still pass.
429 lines
12 KiB
Rust
429 lines
12 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!(std::mem::size_of::<PlaybackReporterWrapper>() > 0);
|
|
}
|
|
|
|
#[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 { .. }));
|
|
}
|
|
}
|