Skip to main content

#
jellytau_lib/commands/
playback_reporting.rs

    1//! Tauri commands for playback reporting operations
    2//!
    3//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
    4//!
    5//! These commands provide frontend access to the Rust playback reporting system,
    6//! replacing the TypeScript implementation with native Rust reporting.
    7//!
    8//! Commands are registered but not yet called from the frontend.
    9//! Dead code warnings are suppressed until frontend migration is complete.
    10
    11#![allow(dead_code)]
    12
    13use std::sync::Arc;
    14use tauri::State;
    15use tokio::sync::Mutex as TokioMutex;
    16
    17use crate::commands::connectivity::ConnectivityMonitorWrapper;
    18use crate::commands::storage::DatabaseWrapper;
    19use crate::jellyfin::client::JellyfinClient;
    20use crate::jellyfin::JellyfinConfig;
    21use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
    22use crate::utils::conversions::seconds_to_ticks;
    23
    24/// Tauri state wrapper for PlaybackReporter
    25pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>);
    26
    27/// Initialize playback reporter (called after login)
    28#[tauri::command]
    29#[specta::specta]
    30pub async fn playback_reporter_init(
    31    reporter_wrapper: State<'_, PlaybackReporterWrapper>,
    32    db: State<'_, DatabaseWrapper>,
    33    server_url: String,
    34    user_id: String,
    35    access_token: String,
    36    device_id: String,
    37) -> Result<(), String> {
    38    log::info!("[PlaybackReporter] Initializing for user: {}", user_id);
    39
    40    // Get database service
    41    let db_service = {
    42        let database = db.0.lock().map_err(|e| e.to_string())?;
    43        Arc::new(database.service())
    44    };
    45
    46    // Create JellyfinClient
    47    let jellyfin_config = JellyfinConfig {
    48        server_url,
    49        access_token,
    50        device_id,
    51    };
    52
    53    let jellyfin_client = JellyfinClient::new(jellyfin_config)
    54        .map_err(|e| format!("Failed to create JellyfinClient: {}", e))?;
    55
    56    // Create PlaybackReporter
    57    let reporter = PlaybackReporter::new(
    58        db_service,
    59        Arc::new(TokioMutex::new(Some(jellyfin_client))),
    60        user_id.clone(),
    61    );
    62
    63    // Store in wrapper
    64    *reporter_wrapper.0.lock().await = Some(reporter);
    65
    66    log::info!(
    67        "[PlaybackReporter] Initialized successfully for user: {}",
    68        user_id
    69    );
    70    Ok(())
    71}
    72
    73/// Destroy playback reporter (called on logout)
    74#[tauri::command]
    75#[specta::specta]
    76pub async fn playback_reporter_destroy(
    77    reporter_wrapper: State<'_, PlaybackReporterWrapper>,
    78) -> Result<(), String> {
    79    log::info!("[PlaybackReporter] Destroying reporter");
    80    *reporter_wrapper.0.lock().await = None;
    81    Ok(())
    82}
    83
    84/// Report playback start
    85#[tauri::command]
    86#[specta::specta]
    87pub async fn playback_report_start(
    88    reporter: State<'_, PlaybackReporterWrapper>,
    89    connectivity: State<'_, ConnectivityMonitorWrapper>,
    90    item_id: String,
    91    position_seconds: f64,
    92    context_type: Option<String>,
    93    context_id: Option<String>,
    94) -> Result<(), String> {
    95    let reporter_guard = reporter.0.lock().await;
    96    let reporter_instance = reporter_guard
    97        .as_ref()
    98        .ok_or("PlaybackReporter not initialized")?;
    99
    100    let position_ticks = seconds_to_ticks(position_seconds);
    101    let context = context_type.map(|ct| PlaybackContext {
    102        context_type: ct,
    103        context_id,
    104    });
    105
    106    let operation = PlaybackOperation::Start {
    107        item_id,
    108        position_ticks,
    109        context,
    110    };
    111
    112    let monitor = connectivity.0.lock().await;
    113    let is_online = monitor.get_status().await.is_server_reachable;
    114    drop(monitor);
    115
    116    reporter_instance.report(operation, is_online).await
    117}
    118
    119/// Report playback progress
    120#[tauri::command]
    121#[specta::specta]
    122pub async fn playback_report_progress(
    123    reporter: State<'_, PlaybackReporterWrapper>,
    124    connectivity: State<'_, ConnectivityMonitorWrapper>,
    125    item_id: String,
    126    position_seconds: f64,
    127    is_paused: bool,
    128) -> Result<(), String> {
    129    let reporter_guard = reporter.0.lock().await;
    130    let reporter_instance = reporter_guard
    131        .as_ref()
    132        .ok_or("PlaybackReporter not initialized")?;
    133
    134    let position_ticks = seconds_to_ticks(position_seconds);
    135    let operation = PlaybackOperation::Progress {
    136        item_id,
    137        position_ticks,
    138        is_paused,
    139    };
    140
    141    let monitor = connectivity.0.lock().await;
    142    let is_online = monitor.get_status().await.is_server_reachable;
    143    drop(monitor);
    144
    145    reporter_instance.report(operation, is_online).await
    146}
    147
    148/// Report playback stopped
    149#[tauri::command]
    150#[specta::specta]
    151pub async fn playback_report_stopped(
    152    reporter: State<'_, PlaybackReporterWrapper>,
    153    connectivity: State<'_, ConnectivityMonitorWrapper>,
    154    item_id: String,
    155    position_seconds: f64,
    156) -> Result<(), String> {
    157    let reporter_guard = reporter.0.lock().await;
    158    let reporter_instance = reporter_guard
    159        .as_ref()
    160        .ok_or("PlaybackReporter not initialized")?;
    161
    162    let position_ticks = seconds_to_ticks(position_seconds);
    163    let operation = PlaybackOperation::Stopped {
    164        item_id,
    165        position_ticks,
    166    };
    167
    168    let monitor = connectivity.0.lock().await;
    169    let is_online = monitor.get_status().await.is_server_reachable;
    170    drop(monitor);
    171
    172    reporter_instance.report(operation, is_online).await
    173}
    174
    175/// Mark item as played
    176#[tauri::command]
    177#[specta::specta]
    178pub async fn playback_mark_played(
    179    reporter: State<'_, PlaybackReporterWrapper>,
    180    connectivity: State<'_, ConnectivityMonitorWrapper>,
    181    item_id: String,
    182) -> Result<(), String> {
    183    let reporter_guard = reporter.0.lock().await;
    184    let reporter_instance = reporter_guard
    185        .as_ref()
    186        .ok_or("PlaybackReporter not initialized")?;
    187
    188    let operation = PlaybackOperation::MarkPlayed { item_id };
    189
    190    let monitor = connectivity.0.lock().await;
    191    let is_online = monitor.get_status().await.is_server_reachable;
    192    drop(monitor);
    193
    194    reporter_instance.report(operation, is_online).await
    195}
    196
    197#[cfg(test)]
    198mod tests {
    199    use super::*;
    200
    201    #[test]
    202    fn test_playback_operation_start_creation() {
    203        let operation = PlaybackOperation::Start {
    204            item_id: "item-123".to_string(),
    205            position_ticks: 15_000_000,
    206            context: Some(PlaybackContext {
    207                context_type: "series".to_string(),
    208                context_id: Some("series-456".to_string()),
    209            }),
    210        };
    211
    212        // Verify enum variant can be created and pattern matched
    213        if let PlaybackOperation::Start {
    214            item_id,
    215            position_ticks,
    216            context,
    217        } = operation
    218        {
    219            assert_eq!(item_id, "item-123");
    220            assert_eq!(position_ticks, 15_000_000);
    221            assert!(context.is_some());
    222            let ctx = context.unwrap();
    223            assert_eq!(ctx.context_type, "series");
    224            assert_eq!(ctx.context_id, Some("series-456".to_string()));
    225        } else {
    226            panic!("Expected Start variant");
    227        }
    228    }
    229
    230    #[test]
    231    fn test_playback_operation_start_without_context() {
    232        let operation = PlaybackOperation::Start {
    233            item_id: "item-789".to_string(),
    234            position_ticks: 5_000_000,
    235            context: None,
    236        };
    237
    238        if let PlaybackOperation::Start {
    239            item_id, context, ..
    240        } = operation
    241        {
    242            assert_eq!(item_id, "item-789");
    243            assert!(context.is_none());
    244        } else {
    245            panic!("Expected Start variant");
    246        }
    247    }
    248
    249    #[test]
    250    fn test_playback_operation_progress_creation() {
    251        let operation = PlaybackOperation::Progress {
    252            item_id: "item-999".to_string(),
    253            position_ticks: 30_000_000,
    254            is_paused: true,
    255        };
    256
    257        if let PlaybackOperation::Progress {
    258            item_id,
    259            position_ticks,
    260            is_paused,
    261        } = operation
    262        {
    263            assert_eq!(item_id, "item-999");
    264            assert_eq!(position_ticks, 30_000_000);
    265            assert!(is_paused);
    266        } else {
    267            panic!("Expected Progress variant");
    268        }
    269    }
    270
    271    #[test]
    272    fn test_playback_operation_progress_playing() {
    273        let operation = PlaybackOperation::Progress {
    274            item_id: "item-555".to_string(),
    275            position_ticks: 45_000_000,
    276            is_paused: false,
    277        };
    278
    279        if let PlaybackOperation::Progress { is_paused, .. } = operation {
    280            assert!(!is_paused);
    281        } else {
    282            panic!("Expected Progress variant");
    283        }
    284    }
    285
    286    #[test]
    287    fn test_playback_operation_stopped_creation() {
    288        let operation = PlaybackOperation::Stopped {
    289            item_id: "item-111".to_string(),
    290            position_ticks: 120_000_000,
    291        };
    292
    293        if let PlaybackOperation::Stopped {
    294            item_id,
    295            position_ticks,
    296        } = operation
    297        {
    298            assert_eq!(item_id, "item-111");
    299            assert_eq!(position_ticks, 120_000_000);
    300        } else {
    301            panic!("Expected Stopped variant");
    302        }
    303    }
    304
    305    #[test]
    306    fn test_playback_operation_mark_played_creation() {
    307        let operation = PlaybackOperation::MarkPlayed {
    308            item_id: "item-222".to_string(),
    309        };
    310
    311        if let PlaybackOperation::MarkPlayed { item_id } = operation {
    312            assert_eq!(item_id, "item-222");
    313        } else {
    314            panic!("Expected MarkPlayed variant");
    315        }
    316    }
    317
    318    #[test]
    319    fn test_playback_context_with_series() {
    320        let context = PlaybackContext {
    321            context_type: "series".to_string(),
    322            context_id: Some("series-789".to_string()),
    323        };
    324
    325        assert_eq!(context.context_type, "series");
    326        assert_eq!(context.context_id, Some("series-789".to_string()));
    327    }
    328
    329    #[test]
    330    fn test_playback_context_without_id() {
    331        let context = PlaybackContext {
    332            context_type: "folder".to_string(),
    333            context_id: None,
    334        };
    335
    336        assert_eq!(context.context_type, "folder");
    337        assert!(context.context_id.is_none());
    338    }
    339
    340    #[test]
    341    fn test_playback_context_clone() {
    342        let context = PlaybackContext {
    343            context_type: "container".to_string(),
    344            context_id: Some("container-123".to_string()),
    345        };
    346
    347        let cloned = context.clone();
    348        assert_eq!(cloned.context_type, "container");
    349        assert_eq!(cloned.context_id, Some("container-123".to_string()));
    350    }
    351
    352    #[test]
    353    fn test_seconds_to_ticks_conversion() {
    354        assert_eq!(seconds_to_ticks(0.0), 0);
    355        assert_eq!(seconds_to_ticks(1.0), 10_000_000);
    356        assert_eq!(seconds_to_ticks(1.5), 15_000_000);
    357        assert_eq!(seconds_to_ticks(120.0), 1_200_000_000);
    358    }
    359
    360    #[test]
    361    fn test_playback_reporter_wrapper_structure() {
    362        // Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
    363        assert!(std::mem::size_of::<PlaybackReporterWrapper>() > 0);
    364    }
    365
    366    #[test]
    367    fn test_playback_operation_debug_trait() {
    368        // Verify Debug trait is implemented for operations
    369        let operation = PlaybackOperation::Start {
    370            item_id: "item-1".to_string(),
    371            position_ticks: 0,
    372            context: None,
    373        };
    374
    375        let debug_str = format!("{:?}", operation);
    376        assert!(debug_str.contains("Start"));
    377        assert!(debug_str.contains("item-1"));
    378    }
    379
    380    #[test]
    381    fn test_playback_operation_clone() {
    382        let operation = PlaybackOperation::Progress {
    383            item_id: "item-clone".to_string(),
    384            position_ticks: 50_000_000,
    385            is_paused: true,
    386        };
    387
    388        let cloned = operation.clone();
    389        if let PlaybackOperation::Progress {
    390            item_id, is_paused, ..
    391        } = cloned
    392        {
    393            assert_eq!(item_id, "item-clone");
    394            assert!(is_paused);
    395        } else {
    396            panic!("Clone failed to preserve variant");
    397        }
    398    }
    399
    400    #[test]
    401    fn test_playback_operation_all_variants() {
    402        // Test that all operation variants can be created and matched
    403        let start_op = PlaybackOperation::Start {
    404            item_id: "i1".to_string(),
    405            position_ticks: 0,
    406            context: None,
    407        };
    408        assert!(matches!(start_op, PlaybackOperation::Start { .. }));
    409
    410        let progress_op = PlaybackOperation::Progress {
    411            item_id: "i2".to_string(),
    412            position_ticks: 100,
    413            is_paused: false,
    414        };
    415        assert!(matches!(progress_op, PlaybackOperation::Progress { .. }));
    416
    417        let stopped_op = PlaybackOperation::Stopped {
    418            item_id: "i3".to_string(),
    419            position_ticks: 200,
    420        };
    421        assert!(matches!(stopped_op, PlaybackOperation::Stopped { .. }));
    422
    423        let played_op = PlaybackOperation::MarkPlayed {
    424            item_id: "i4".to_string(),
    425        };
    426        assert!(matches!(played_op, PlaybackOperation::MarkPlayed { .. }));
    427    }
    428}