1#![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#[derive(Debug, Clone)]
16pub struct PlaybackContext {
17 pub context_type: String, pub context_id: Option<String>,
19}
20
21#[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
43pub struct PlaybackReporter {
45 db_service: Arc<RusqliteService>,
46 jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
47 user_id: String,
48}
49
50impl PlaybackReporter {
51 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 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 self.update_local_db(&operation).await?;
77
78 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 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 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 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, )
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, )
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, )
246 .await?;
247 log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
248 }
249
250 PlaybackOperation::MarkPlayed { item_id } => {
251 let max_ticks = i64::MAX; 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 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 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 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 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 #[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}