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}