Skip to main content

jellytau_lib/playback_reporting/
reporter.rs

1//! Playback reporter implementation
2//!
3//! This module is fully implemented but not yet integrated with the player.
4//! Dead code warnings are suppressed until integration is complete.
5
6#![allow(dead_code)]
7
8use std::sync::Arc;
9use tokio::sync::Mutex as TokioMutex;
10
11use crate::jellyfin::client::JellyfinClient;
12use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
13
14/// Playback context information
15#[derive(Debug, Clone)]
16pub struct PlaybackContext {
17    pub context_type: String, // "container" or "single"
18    pub context_id: Option<String>,
19}
20
21/// Playback operation types
22#[derive(Debug, Clone)]
23pub enum PlaybackOperation {
24    Start {
25        item_id: String,
26        position_ticks: i64,
27        context: Option<PlaybackContext>,
28    },
29    Progress {
30        item_id: String,
31        position_ticks: i64,
32        is_paused: bool,
33    },
34    Stopped {
35        item_id: String,
36        position_ticks: i64,
37    },
38    MarkPlayed {
39        item_id: String,
40    },
41}
42
43/// Main playback reporter that handles dual sync (local DB + server)
44pub struct PlaybackReporter {
45    db_service: Arc<RusqliteService>,
46    jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
47    user_id: String,
48}
49
50impl PlaybackReporter {
51    /// Creates a new PlaybackReporter
52    pub fn new(
53        db_service: Arc<RusqliteService>,
54        jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
55        user_id: String,
56    ) -> Self {
57        Self {
58            db_service,
59            jellyfin_client,
60            user_id,
61        }
62    }
63
64    /// Reports a playback operation (dual sync: local DB + server)
65    ///
66    /// Always updates local DB first, then attempts server sync if online.
67    /// If server sync fails, operation is queued for retry.
68    pub async fn report(
69        &self,
70        operation: PlaybackOperation,
71        is_online: bool,
72    ) -> Result<(), String> {
73        log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
74
75        // Always update local DB first (works offline)
76        self.update_local_db(&operation).await?;
77
78        // If online, attempt server sync
79        if is_online {
80            if let Err(e) = self.sync_to_server(&operation).await {
81                log::warn!("[PlaybackReporter] Server sync failed, queueing: {}", e);
82                self.queue_for_sync(&operation).await?;
83            } else {
84                // Mark as synced on success
85                if let Some(item_id) = self.get_item_id(&operation) {
86                    self.mark_synced(&item_id).await?;
87                }
88            }
89        } else {
90            log::debug!("[PlaybackReporter] Offline - queueing operation");
91            self.queue_for_sync(&operation).await?;
92        }
93
94        Ok(())
95    }
96
97    /// Updates local database with playback info
98    async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
99        match operation {
100            PlaybackOperation::Start {
101                item_id,
102                position_ticks,
103                context,
104            } => {
105                let query = Query::with_params(
106                    "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
107                                            playback_context_type, playback_context_id, pending_sync)
108                     VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, 1)
109                     ON CONFLICT(user_id, item_id) DO UPDATE SET
110                        playback_position_ticks = excluded.playback_position_ticks,
111                        last_played_at = excluded.last_played_at,
112                        playback_context_type = excluded.playback_context_type,
113                        playback_context_id = excluded.playback_context_id,
114                        pending_sync = 1",
115                    vec![
116                        QueryParam::String(self.user_id.clone()),
117                        QueryParam::String(item_id.clone()),
118                        QueryParam::Int64(*position_ticks),
119                        context.as_ref().map(|c| QueryParam::String(c.context_type.clone())).unwrap_or(QueryParam::Null),
120                        context.as_ref().and_then(|c| c.context_id.as_ref()).map(|id| QueryParam::String(id.clone())).unwrap_or(QueryParam::Null),
121                    ],
122                );
123
124                self.db_service
125                    .execute(query)
126                    .await
127                    .map_err(|e| e.to_string())?;
128                log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
129            }
130
131            PlaybackOperation::Progress {
132                item_id,
133                position_ticks,
134                is_paused: _,
135            }
136            | PlaybackOperation::Stopped {
137                item_id,
138                position_ticks,
139            } => {
140                let query = Query::with_params(
141                    "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
142                     VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
143                     ON CONFLICT(user_id, item_id) DO UPDATE SET
144                        playback_position_ticks = excluded.playback_position_ticks,
145                        last_played_at = excluded.last_played_at,
146                        pending_sync = 1",
147                    vec![
148                        QueryParam::String(self.user_id.clone()),
149                        QueryParam::String(item_id.clone()),
150                        QueryParam::Int64(*position_ticks),
151                    ],
152                );
153
154                self.db_service
155                    .execute(query)
156                    .await
157                    .map_err(|e| e.to_string())?;
158                log::debug!(
159                    "[PlaybackReporter] Updated local DB for progress/stop: {}",
160                    item_id
161                );
162            }
163
164            PlaybackOperation::MarkPlayed { item_id } => {
165                let query = Query::with_params(
166                    "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
167                     VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, 1)
168                     ON CONFLICT(user_id, item_id) DO UPDATE SET
169                        is_played = 1,
170                        play_count = COALESCE(play_count, 0) + 1,
171                        last_played_at = CURRENT_TIMESTAMP,
172                        pending_sync = 1",
173                    vec![
174                        QueryParam::String(self.user_id.clone()),
175                        QueryParam::String(item_id.clone()),
176                    ],
177                );
178
179                self.db_service
180                    .execute(query)
181                    .await
182                    .map_err(|e| e.to_string())?;
183                log::debug!(
184                    "[PlaybackReporter] Updated local DB for mark played: {}",
185                    item_id
186                );
187            }
188        }
189
190        Ok(())
191    }
192
193    /// Syncs to Jellyfin server
194    async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
195        let client_guard = self.jellyfin_client.lock().await;
196        let client = client_guard
197            .as_ref()
198            .ok_or("JellyfinClient not initialized")?;
199
200        match operation {
201            PlaybackOperation::Start {
202                item_id,
203                position_ticks,
204                ..
205            } => {
206                client
207                    .report_playback_start(
208                        item_id.clone(),
209                        *position_ticks,
210                        None, // play_session_id
211                    )
212                    .await?;
213                log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
214            }
215
216            PlaybackOperation::Progress {
217                item_id,
218                position_ticks,
219                is_paused,
220            } => {
221                client
222                    .report_playback_progress(
223                        item_id.clone(),
224                        *position_ticks,
225                        *is_paused,
226                        None, // play_session_id
227                    )
228                    .await?;
229                log::debug!(
230                    "[PlaybackReporter] Reported progress to server: {} (paused: {})",
231                    item_id,
232                    is_paused
233                );
234            }
235
236            PlaybackOperation::Stopped {
237                item_id,
238                position_ticks,
239            } => {
240                client
241                    .report_playback_stopped(
242                        item_id.clone(),
243                        *position_ticks,
244                        None, // play_session_id
245                    )
246                    .await?;
247                log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
248            }
249
250            PlaybackOperation::MarkPlayed { item_id } => {
251                // For mark as played, we need to get the item's runtime
252                // For now, report as stopped at max position
253                // TODO: Fetch item runtime from DB or assume 100% completion
254                let max_ticks = i64::MAX; // Temporary - should be actual runtime
255                client
256                    .report_playback_stopped(item_id.clone(), max_ticks, None)
257                    .await?;
258                log::info!(
259                    "[PlaybackReporter] Reported mark played to server: {}",
260                    item_id
261                );
262            }
263        }
264
265        Ok(())
266    }
267
268    /// Queues operation for later sync
269    async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
270        let (op_name, item_id, payload) = match operation {
271            PlaybackOperation::Start {
272                item_id,
273                position_ticks,
274                context,
275            } => {
276                let payload_data = serde_json::json!({
277                    "position_ticks": position_ticks,
278                    "context_type": context.as_ref().map(|c| &c.context_type),
279                    "context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
280                });
281                (
282                    "report_playback_start",
283                    Some(item_id.clone()),
284                    Some(payload_data.to_string()),
285                )
286            }
287
288            PlaybackOperation::Progress { .. } => {
289                // Don't queue progress reports - too frequent
290                // Progress is captured by final stop report
291                log::debug!("[PlaybackReporter] Skipping queue for progress report (too frequent)");
292                return Ok(());
293            }
294
295            PlaybackOperation::Stopped {
296                item_id,
297                position_ticks,
298            } => {
299                let payload_data = serde_json::json!({
300                    "position_ticks": position_ticks,
301                });
302                (
303                    "report_playback_stopped",
304                    Some(item_id.clone()),
305                    Some(payload_data.to_string()),
306                )
307            }
308
309            PlaybackOperation::MarkPlayed { item_id } => {
310                ("mark_played", Some(item_id.clone()), None)
311            }
312        };
313
314        let query = Query::with_params(
315            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
316             VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
317            vec![
318                QueryParam::String(self.user_id.clone()),
319                QueryParam::String(op_name.to_string()),
320                item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
321                payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
322            ],
323        );
324
325        self.db_service
326            .execute(query)
327            .await
328            .map_err(|e| e.to_string())?;
329        log::info!("[PlaybackReporter] Queued operation: {}", op_name);
330
331        Ok(())
332    }
333
334    /// Marks an item as synced in the local database
335    async fn mark_synced(&self, item_id: &str) -> Result<(), String> {
336        let query = Query::with_params(
337            "UPDATE user_data SET pending_sync = 0 WHERE user_id = ? AND item_id = ?",
338            vec![
339                QueryParam::String(self.user_id.clone()),
340                QueryParam::String(item_id.to_string()),
341            ],
342        );
343
344        self.db_service
345            .execute(query)
346            .await
347            .map_err(|e| e.to_string())?;
348        log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
349
350        Ok(())
351    }
352
353    /// Extracts item_id from operation
354    fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
355        match operation {
356            PlaybackOperation::Start { item_id, .. }
357            | PlaybackOperation::Progress { item_id, .. }
358            | PlaybackOperation::Stopped { item_id, .. }
359            | PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
360        }
361    }
362}
363
364impl Clone for PlaybackReporter {
365    fn clone(&self) -> Self {
366        Self {
367            db_service: Arc::clone(&self.db_service),
368            jellyfin_client: Arc::clone(&self.jellyfin_client),
369            user_id: self.user_id.clone(),
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    // Unit tests will be added incrementally as dependencies are mocked
379
380    #[test]
381    fn test_playback_operation_debug() {
382        let op = PlaybackOperation::Start {
383            item_id: "item123".to_string(),
384            position_ticks: 1000,
385            context: Some(PlaybackContext {
386                context_type: "container".to_string(),
387                context_id: Some("album456".to_string()),
388            }),
389        };
390
391        let debug_str = format!("{:?}", op);
392        assert!(debug_str.contains("Start"));
393        assert!(debug_str.contains("item123"));
394    }
395
396    #[test]
397    fn test_playback_context_clone() {
398        let context = PlaybackContext {
399            context_type: "single".to_string(),
400            context_id: None,
401        };
402
403        let cloned = context.clone();
404        assert_eq!(cloned.context_type, "single");
405        assert_eq!(cloned.context_id, None);
406    }
407}