Files
jellytau/src-tauri/src/commands/playback_reporting.rs
T
dtourolle 8500da1a42 chore(rust): clear the clippy backlog and finish the poison-tolerant lock sweep
`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.
2026-08-16 23:05:13 +02:00

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 { .. }));
}
}