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}