1use std::sync::Arc;
17
18use async_trait::async_trait;
19use log::{debug, info, warn};
20use tauri::{Emitter, Listener, Manager};
21
22use crate::repository::types::RepoError;
23use crate::repository::MediaRepository;
24use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
25
26pub const MAX_SYNC_ATTEMPTS: i32 = 5;
32
33pub const SYNC_QUEUE_CHANGED_EVENT: &str = "sync-queue-changed";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum QueuedOp {
40 PlaybackStart {
41 item_id: String,
42 position_ticks: i64,
43 },
44 PlaybackStopped {
49 item_id: String,
50 position_ticks: i64,
51 },
52 MarkPlayed {
53 item_id: String,
54 },
55 MarkUnplayed {
59 item_id: String,
60 },
61 Favorite {
64 item_id: String,
65 is_favorite: bool,
66 },
67}
68
69pub fn parse_queued_op(
78 operation: &str,
79 item_id: Option<&str>,
80 payload: Option<&str>,
81) -> Result<QueuedOp, String> {
82 let json: serde_json::Value = match payload {
83 Some(raw) if !raw.trim().is_empty() => {
84 serde_json::from_str(raw).map_err(|e| format!("Unreadable payload: {}", e))?
85 }
86 _ => serde_json::Value::Null,
87 };
88
89 let item_id = item_id
90 .filter(|id| !id.is_empty())
91 .ok_or_else(|| format!("Operation '{}' has no item id", operation))?
92 .to_string();
93
94 let ticks = || -> i64 {
95 if let Some(t) = json.get("position_ticks").and_then(|v| v.as_i64()) {
96 return t;
97 }
98 if let Some(ms) = json.get("positionMs").and_then(|v| v.as_i64()) {
99 return ms * 10_000; }
101 0
102 };
103
104 match operation {
105 "report_playback_start" => Ok(QueuedOp::PlaybackStart {
106 item_id,
107 position_ticks: ticks(),
108 }),
109 "report_playback_stopped" | "update_progress" => Ok(QueuedOp::PlaybackStopped {
110 item_id,
111 position_ticks: ticks(),
112 }),
113 "mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
114 "mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
115 "mark_favorite" => Ok(QueuedOp::Favorite {
116 item_id,
117 is_favorite: true,
118 }),
119 "unmark_favorite" => Ok(QueuedOp::Favorite {
120 item_id,
121 is_favorite: false,
122 }),
123 other => Err(format!("Unsupported operation '{}'", other)),
124 }
125}
126
127#[async_trait]
130pub trait SyncSink: Send + Sync {
131 async fn push(&self, op: &QueuedOp) -> Result<(), RepoError>;
132}
133
134#[async_trait]
135impl<T: MediaRepository + ?Sized> SyncSink for T {
136 async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
137 match op {
138 QueuedOp::PlaybackStart {
139 item_id,
140 position_ticks,
141 } => self.report_playback_start(item_id, *position_ticks).await,
142 QueuedOp::PlaybackStopped {
143 item_id,
144 position_ticks,
145 } => self.report_playback_stopped(item_id, *position_ticks).await,
146 QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
147 QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
148 QueuedOp::Favorite {
149 item_id,
150 is_favorite,
151 } => {
152 if *is_favorite {
153 self.mark_favorite(item_id).await
154 } else {
155 self.unmark_favorite(item_id).await
156 }
157 }
158 }
159 }
160}
161
162#[derive(
164 Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type,
165)]
166#[serde(rename_all = "camelCase")]
167pub struct DrainReport {
168 pub pushed: i32,
170 pub deferred: i32,
172 pub abandoned: i32,
174 pub remaining: i32,
176}
177
178struct PushFailure {
180 reason: String,
181 transient: bool,
183}
184
185#[derive(Debug, Clone)]
186struct QueuedRow {
187 id: i64,
188 operation: String,
189 item_id: Option<String>,
190 payload: Option<String>,
191 retry_count: i32,
192}
193
194async fn read_queue(db: &Arc<RusqliteService>, user_id: &str) -> Result<Vec<QueuedRow>, String> {
195 db.query_many(
196 Query::with_params(
197 "SELECT id, operation, item_id, payload, COALESCE(retry_count, 0) \
198 FROM sync_queue \
199 WHERE user_id = ? AND status IN ('pending', 'failed') \
200 ORDER BY created_at ASC, id ASC",
201 vec![QueryParam::String(user_id.to_string())],
202 ),
203 |row| {
204 Ok(QueuedRow {
205 id: row.get(0)?,
206 operation: row.get(1)?,
207 item_id: row.get(2)?,
208 payload: row.get(3)?,
209 retry_count: row.get(4)?,
210 })
211 },
212 )
213 .await
214}
215
216async fn remaining_count(db: &Arc<RusqliteService>, user_id: &str) -> Result<i32, String> {
217 db.query_one(
218 Query::with_params(
219 "SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
220 vec![QueryParam::String(user_id.to_string())],
221 ),
222 |row| row.get(0),
223 )
224 .await
225}
226
227pub async fn drain_sync_queue(
238 db: &Arc<RusqliteService>,
239 sink: &dyn SyncSink,
240 user_id: &str,
241) -> Result<DrainReport, String> {
242 let queued = read_queue(db, user_id).await?;
243 if queued.is_empty() {
244 return Ok(DrainReport::default());
245 }
246
247 info!(
248 "[SyncQueue] Pushing {} operation(s) queued while offline",
249 queued.len()
250 );
251
252 let mut report = DrainReport::default();
253
254 for row in queued {
255 let outcome = match parse_queued_op(
256 &row.operation,
257 row.item_id.as_deref(),
258 row.payload.as_deref(),
259 ) {
260 Ok(op) => sink.push(&op).await.map_err(|e| PushFailure {
261 transient: matches!(e, RepoError::Offline | RepoError::Network { .. }),
265 reason: e.to_string(),
266 }),
267 Err(reason) => Err(PushFailure {
271 reason,
272 transient: false,
273 }),
274 };
275
276 match outcome {
277 Ok(()) => {
278 mark_completed(db, row.id).await?;
279 report.pushed += 1;
280 }
281 Err(failure) if failure.transient => {
282 mark_deferred(db, row.id, &failure.reason).await?;
283 debug!(
284 "[SyncQueue] Server unreachable, {} stays queued: {}",
285 row.operation, failure.reason
286 );
287 report.deferred += 1;
288 }
289 Err(failure) => {
290 let attempts = row.retry_count + 1;
291 let give_up = attempts >= MAX_SYNC_ATTEMPTS;
292 mark_failed(db, row.id, attempts, give_up, &failure.reason).await?;
293 if give_up {
294 warn!(
295 "[SyncQueue] Giving up on {} after {} attempts: {}",
296 row.operation, attempts, failure.reason
297 );
298 report.abandoned += 1;
299 } else {
300 debug!(
301 "[SyncQueue] Deferring {} (attempt {}): {}",
302 row.operation, attempts, failure.reason
303 );
304 report.deferred += 1;
305 }
306 }
307 }
308 }
309
310 report.remaining = remaining_count(db, user_id).await?;
311 info!(
312 "[SyncQueue] Drain finished: {} pushed, {} deferred, {} abandoned, {} remaining",
313 report.pushed, report.deferred, report.abandoned, report.remaining
314 );
315
316 Ok(report)
317}
318
319async fn mark_completed(db: &Arc<RusqliteService>, id: i64) -> Result<(), String> {
320 db.execute(Query::with_params(
321 "UPDATE sync_queue \
322 SET status = 'completed', processed_at = CURRENT_TIMESTAMP, error_message = NULL \
323 WHERE id = ?",
324 vec![QueryParam::Int64(id)],
325 ))
326 .await?;
327 Ok(())
328}
329
330async fn mark_deferred(db: &Arc<RusqliteService>, id: i64, reason: &str) -> Result<(), String> {
333 db.execute(Query::with_params(
334 "UPDATE sync_queue SET status = 'pending', error_message = ? WHERE id = ?",
335 vec![
336 QueryParam::String(reason.to_string()),
337 QueryParam::Int64(id),
338 ],
339 ))
340 .await?;
341 Ok(())
342}
343
344async fn mark_failed(
345 db: &Arc<RusqliteService>,
346 id: i64,
347 attempts: i32,
348 give_up: bool,
349 reason: &str,
350) -> Result<(), String> {
351 db.execute(Query::with_params(
352 "UPDATE sync_queue \
353 SET status = ?, retry_count = ?, error_message = ?, processed_at = CURRENT_TIMESTAMP \
354 WHERE id = ?",
355 vec![
356 QueryParam::String(if give_up { "abandoned" } else { "failed" }.to_string()),
357 QueryParam::Int(attempts),
358 QueryParam::String(reason.to_string()),
359 QueryParam::Int64(id),
360 ],
361 ))
362 .await?;
363 Ok(())
364}
365
366pub async fn enqueue_playback_stopped(
382 db: &Arc<RusqliteService>,
383 user_id: &str,
384 item_id: &str,
385 position_ticks: i64,
386) -> Result<(), String> {
387 let payload = format!(r#"{{"position_ticks": {}}}"#, position_ticks);
388
389 let updated = db
392 .execute(Query::with_params(
393 "UPDATE sync_queue \
394 SET payload = ?, status = 'pending', error_message = NULL \
395 WHERE user_id = ? AND item_id = ? AND operation = 'report_playback_stopped' \
396 AND status IN ('pending', 'failed')",
397 vec![
398 QueryParam::String(payload.clone()),
399 QueryParam::String(user_id.to_string()),
400 QueryParam::String(item_id.to_string()),
401 ],
402 ))
403 .await?;
404
405 if updated == 0 {
406 db.execute(Query::with_params(
407 "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at) \
408 VALUES (?, 'report_playback_stopped', ?, ?, 'pending', CURRENT_TIMESTAMP)",
409 vec![
410 QueryParam::String(user_id.to_string()),
411 QueryParam::String(item_id.to_string()),
412 QueryParam::String(payload),
413 ],
414 ))
415 .await?;
416 }
417
418 debug!(
419 "[SyncQueue] Queued unreported stop for {} at {} ticks",
420 item_id, position_ticks
421 );
422 Ok(())
423}
424
425pub fn spawn_sync_queue_drain(app: tauri::AppHandle) {
429 let handle = app.clone();
430 app.listen("connectivity:reconnected", move |_event| {
431 let app = handle.clone();
432 tauri::async_runtime::spawn(async move {
433 if let Err(e) = run_drain(&app).await {
434 warn!("[SyncQueue] Drain skipped: {}", e);
435 }
436 });
437 });
438}
439
440pub async fn run_drain(app: &tauri::AppHandle) -> Result<DrainReport, String> {
443 let db_service: Arc<RusqliteService> = {
444 let db = app.state::<crate::commands::storage::DatabaseWrapper>();
445 let database = db.0.lock().map_err(|e| e.to_string())?;
446 Arc::new(database.service())
447 };
448
449 let (repo, user_id) = {
450 let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
451 let handles = manager.0.handles();
452 let Some(handle) = handles.first() else {
453 return Ok(DrainReport::default());
455 };
456 let repo = manager.0.get(handle).ok_or("Repository not found")?;
457 let user_id = repo.user_id().to_string();
458 (repo, user_id)
459 };
460
461 let report = drain_sync_queue(&db_service, repo.as_ref(), &user_id).await?;
462
463 if report.pushed > 0 || report.abandoned > 0 {
464 if let Err(e) = app.emit(SYNC_QUEUE_CHANGED_EVENT, &report) {
465 warn!("[SyncQueue] Failed to emit change event: {}", e);
466 }
467 }
468
469 Ok(report)
470}
471
472#[tauri::command]
476#[specta::specta]
477pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport, String> {
478 run_drain(&app).await
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::utils::lock::MutexSafe;
485 use rusqlite::Connection;
486 use std::sync::Mutex;
487
488 struct RecordingSink {
490 calls: Mutex<Vec<QueuedOp>>,
491 fail_with: Option<RepoError>,
492 }
493
494 impl RecordingSink {
495 fn new() -> Self {
496 Self {
497 calls: Mutex::new(Vec::new()),
498 fail_with: None,
499 }
500 }
501
502 fn always_rejecting() -> Self {
504 Self {
505 calls: Mutex::new(Vec::new()),
506 fail_with: Some(RepoError::Server {
507 message: "HTTP 400".to_string(),
508 }),
509 }
510 }
511
512 fn unreachable() -> Self {
514 Self {
515 calls: Mutex::new(Vec::new()),
516 fail_with: Some(RepoError::Offline),
517 }
518 }
519
520 fn calls(&self) -> Vec<QueuedOp> {
521 self.calls.lock_safe().clone()
522 }
523 }
524
525 #[async_trait]
526 impl SyncSink for RecordingSink {
527 async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
528 if let Some(err) = &self.fail_with {
529 return Err(err.clone());
530 }
531 self.calls.lock_safe().push(op.clone());
532 Ok(())
533 }
534 }
535
536 fn test_db() -> Arc<RusqliteService> {
537 let conn = Connection::open_in_memory().unwrap();
538 conn.execute_batch(
539 r#"
540 CREATE TABLE sync_queue (
541 id INTEGER PRIMARY KEY AUTOINCREMENT,
542 user_id TEXT NOT NULL,
543 operation TEXT NOT NULL,
544 item_id TEXT,
545 payload TEXT,
546 status TEXT DEFAULT 'pending',
547 retry_count INTEGER DEFAULT 0,
548 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
549 processed_at TEXT,
550 error_message TEXT
551 );
552 "#,
553 )
554 .unwrap();
555 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
556 }
557
558 type Seed<'a> = (
560 &'a str,
561 &'a str,
562 &'a str,
563 Option<&'a str>,
564 &'a str,
565 i32,
566 &'a str,
567 );
568
569 async fn seed(db: &Arc<RusqliteService>, rows: &[Seed<'_>]) {
570 for (user, op, item, payload, status, retries, created) in rows {
571 db.execute(Query::with_params(
572 "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, retry_count, created_at) \
573 VALUES (?, ?, ?, ?, ?, ?, ?)",
574 vec![
575 QueryParam::String(user.to_string()),
576 QueryParam::String(op.to_string()),
577 QueryParam::String(item.to_string()),
578 payload
579 .map(|p| QueryParam::String(p.to_string()))
580 .unwrap_or(QueryParam::Null),
581 QueryParam::String(status.to_string()),
582 QueryParam::Int(*retries),
583 QueryParam::String(created.to_string()),
584 ],
585 ))
586 .await
587 .unwrap();
588 }
589 }
590
591 async fn row_state(db: &Arc<RusqliteService>, item_id: &str) -> (String, i32) {
592 db.query_one(
593 Query::with_params(
594 "SELECT status, COALESCE(retry_count, 0) FROM sync_queue WHERE item_id = ?",
595 vec![QueryParam::String(item_id.to_string())],
596 ),
597 |row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)),
598 )
599 .await
600 .unwrap()
601 }
602
603 #[tokio::test]
608 async fn test_drain_pushes_queued_operations_and_clears_the_queue() {
609 let db = test_db();
610 seed(
611 &db,
612 &[
613 (
614 "u1",
615 "report_playback_start",
616 "ep1",
617 Some(r#"{"position_ticks": 100}"#),
618 "pending",
619 0,
620 "2026-08-01T10:00:00Z",
621 ),
622 (
623 "u1",
624 "report_playback_stopped",
625 "ep2",
626 Some(r#"{"position_ticks": 5000}"#),
627 "pending",
628 0,
629 "2026-08-01T10:01:00Z",
630 ),
631 (
632 "u1",
633 "mark_played",
634 "ep3",
635 None,
636 "pending",
637 0,
638 "2026-08-01T10:02:00Z",
639 ),
640 ],
641 )
642 .await;
643
644 let sink = RecordingSink::new();
645 let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
646
647 assert_eq!(
648 sink.calls(),
649 vec![
650 QueuedOp::PlaybackStart {
651 item_id: "ep1".to_string(),
652 position_ticks: 100
653 },
654 QueuedOp::PlaybackStopped {
655 item_id: "ep2".to_string(),
656 position_ticks: 5000
657 },
658 QueuedOp::MarkPlayed {
659 item_id: "ep3".to_string()
660 },
661 ],
662 "every queued operation pushes, oldest first"
663 );
664 assert_eq!(report.pushed, 3);
665 assert_eq!(report.remaining, 0, "the badge must reach zero");
666 assert_eq!(row_state(&db, "ep1").await.0, "completed");
667 }
668
669 #[tokio::test]
674 async fn test_drain_defers_failed_pushes() {
675 let db = test_db();
676 seed(
677 &db,
678 &[(
679 "u1",
680 "report_playback_stopped",
681 "ep1",
682 Some(r#"{"position_ticks": 42}"#),
683 "pending",
684 0,
685 "2026-08-01T10:00:00Z",
686 )],
687 )
688 .await;
689
690 let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
691 .await
692 .unwrap();
693
694 assert_eq!(report.deferred, 1);
695 assert_eq!(report.remaining, 1);
696 assert_eq!(row_state(&db, "ep1").await, ("failed".to_string(), 1));
697 }
698
699 #[tokio::test]
704 async fn test_unreachable_server_does_not_burn_the_retry_budget() {
705 let db = test_db();
706 seed(
707 &db,
708 &[(
709 "u1",
710 "mark_played",
711 "ep1",
712 None,
713 "pending",
714 MAX_SYNC_ATTEMPTS - 1,
715 "2026-08-01T10:00:00Z",
716 )],
717 )
718 .await;
719
720 let report = drain_sync_queue(&db, &RecordingSink::unreachable(), "u1")
721 .await
722 .unwrap();
723
724 assert_eq!(report.deferred, 1);
725 assert_eq!(report.abandoned, 0);
726 assert_eq!(
727 row_state(&db, "ep1").await,
728 ("pending".to_string(), MAX_SYNC_ATTEMPTS - 1),
729 "still queued, with its budget intact"
730 );
731 }
732
733 #[tokio::test]
738 async fn test_drain_abandons_a_row_after_max_attempts() {
739 let db = test_db();
740 seed(
741 &db,
742 &[(
743 "u1",
744 "report_playback_stopped",
745 "doomed",
746 Some(r#"{"position_ticks": 1}"#),
747 "failed",
748 MAX_SYNC_ATTEMPTS - 1,
749 "2026-08-01T10:00:00Z",
750 )],
751 )
752 .await;
753
754 let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
755 .await
756 .unwrap();
757
758 assert_eq!(report.abandoned, 1);
759 assert_eq!(report.remaining, 0, "an abandoned row stops being counted");
760 assert_eq!(row_state(&db, "doomed").await.0, "abandoned");
761 }
762
763 #[tokio::test]
767 async fn test_unsupported_operation_records_a_reason_and_lets_others_through() {
768 let db = test_db();
769 seed(
770 &db,
771 &[
772 (
773 "u1",
774 "playlist_reorder_item",
775 "pl1",
776 None,
777 "pending",
778 0,
779 "2026-08-01T10:00:00Z",
780 ),
781 (
782 "u1",
783 "mark_played",
784 "ep1",
785 None,
786 "pending",
787 0,
788 "2026-08-01T10:01:00Z",
789 ),
790 ],
791 )
792 .await;
793
794 let sink = RecordingSink::new();
795 let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
796
797 assert_eq!(
798 sink.calls(),
799 vec![QueuedOp::MarkPlayed {
800 item_id: "ep1".to_string()
801 }],
802 "the unsupported row must not block the ones behind it"
803 );
804 assert_eq!(report.pushed, 1);
805 assert_eq!(report.deferred, 1);
806 assert_eq!(row_state(&db, "pl1").await, ("failed".to_string(), 1));
807 }
808
809 #[tokio::test]
813 async fn test_drain_only_touches_the_given_user() {
814 let db = test_db();
815 seed(
816 &db,
817 &[
818 (
819 "u1",
820 "mark_played",
821 "mine",
822 None,
823 "pending",
824 0,
825 "2026-08-01T10:00:00Z",
826 ),
827 (
828 "u2",
829 "mark_played",
830 "theirs",
831 None,
832 "pending",
833 0,
834 "2026-08-01T10:00:00Z",
835 ),
836 ],
837 )
838 .await;
839
840 let sink = RecordingSink::new();
841 drain_sync_queue(&db, &sink, "u1").await.unwrap();
842
843 assert_eq!(
844 sink.calls(),
845 vec![QueuedOp::MarkPlayed {
846 item_id: "mine".to_string()
847 }]
848 );
849 assert_eq!(row_state(&db, "theirs").await.0, "pending");
850 }
851
852 #[tokio::test]
858 async fn test_failed_stop_report_is_queued_rather_than_dropped() {
859 let db = test_db();
860
861 enqueue_playback_stopped(&db, "u1", "ep1", 5_000_000_000)
862 .await
863 .unwrap();
864
865 let sink = RecordingSink::new();
867 let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
868
869 assert_eq!(
870 sink.calls(),
871 vec![QueuedOp::PlaybackStopped {
872 item_id: "ep1".to_string(),
873 position_ticks: 5_000_000_000,
874 }]
875 );
876 assert_eq!(report.pushed, 1);
877 assert_eq!(report.remaining, 0);
878 }
879
880 #[tokio::test]
887 async fn test_requeueing_the_same_item_supersedes_the_earlier_position() {
888 let db = test_db();
889
890 enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
891 .await
892 .unwrap();
893 enqueue_playback_stopped(&db, "u1", "ep1", 2_000)
894 .await
895 .unwrap();
896 enqueue_playback_stopped(&db, "u1", "ep1", 3_000)
897 .await
898 .unwrap();
899
900 let sink = RecordingSink::new();
901 drain_sync_queue(&db, &sink, "u1").await.unwrap();
902
903 assert_eq!(
905 sink.calls(),
906 vec![QueuedOp::PlaybackStopped {
907 item_id: "ep1".to_string(),
908 position_ticks: 3_000,
909 }]
910 );
911 }
912
913 #[tokio::test]
917 async fn test_requeueing_keeps_positions_for_different_items_apart() {
918 let db = test_db();
919
920 enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
921 .await
922 .unwrap();
923 enqueue_playback_stopped(&db, "u1", "ep2", 2_000)
924 .await
925 .unwrap();
926
927 let sink = RecordingSink::new();
928 drain_sync_queue(&db, &sink, "u1").await.unwrap();
929
930 let mut calls = sink.calls();
931 calls.sort_by_key(|op| match op {
932 QueuedOp::PlaybackStopped { item_id, .. } => item_id.clone(),
933 _ => String::new(),
934 });
935 assert_eq!(
936 calls,
937 vec![
938 QueuedOp::PlaybackStopped {
939 item_id: "ep1".to_string(),
940 position_ticks: 1_000,
941 },
942 QueuedOp::PlaybackStopped {
943 item_id: "ep2".to_string(),
944 position_ticks: 2_000,
945 },
946 ]
947 );
948 }
949
950 #[tokio::test]
956 async fn test_requeueing_does_not_revive_an_abandoned_row() {
957 let db = test_db();
958 seed(
959 &db,
960 &[(
961 "u1",
962 "report_playback_stopped",
963 "ep1",
964 Some(r#"{"position_ticks": 111}"#),
965 "abandoned",
966 MAX_SYNC_ATTEMPTS,
967 "2026-08-01T10:00:00Z",
968 )],
969 )
970 .await;
971
972 enqueue_playback_stopped(&db, "u1", "ep1", 999)
973 .await
974 .unwrap();
975
976 let sink = RecordingSink::new();
977 drain_sync_queue(&db, &sink, "u1").await.unwrap();
978
979 assert_eq!(
981 sink.calls(),
982 vec![QueuedOp::PlaybackStopped {
983 item_id: "ep1".to_string(),
984 position_ticks: 999,
985 }]
986 );
987 }
988
989 #[tokio::test]
994 async fn test_drain_is_a_noop_when_the_queue_is_empty() {
995 let db = test_db();
996 let sink = RecordingSink::new();
997
998 let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
999
1000 assert_eq!(report, DrainReport::default());
1001 assert!(sink.calls().is_empty());
1002 }
1003
1004 #[test]
1009 fn test_parse_accepts_both_payload_dialects() {
1010 assert_eq!(
1011 parse_queued_op(
1012 "report_playback_stopped",
1013 Some("ep1"),
1014 Some(r#"{"position_ticks": 1234}"#)
1015 )
1016 .unwrap(),
1017 QueuedOp::PlaybackStopped {
1018 item_id: "ep1".to_string(),
1019 position_ticks: 1234
1020 }
1021 );
1022
1023 assert_eq!(
1024 parse_queued_op("update_progress", Some("ep1"), Some(r#"{"positionMs": 5}"#)).unwrap(),
1025 QueuedOp::PlaybackStopped {
1026 item_id: "ep1".to_string(),
1027 position_ticks: 50_000
1028 },
1029 "milliseconds convert to ticks"
1030 );
1031
1032 assert_eq!(
1033 parse_queued_op("mark_played", Some("ep1"), None).unwrap(),
1034 QueuedOp::MarkPlayed {
1035 item_id: "ep1".to_string()
1036 },
1037 "a payload-less operation is not an error"
1038 );
1039
1040 assert!(parse_queued_op("mark_played", None, None).is_err());
1041 assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
1042 }
1043
1044 #[test]
1049 fn test_parse_accepts_mark_unplayed() {
1050 assert_eq!(
1051 parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
1052 QueuedOp::MarkUnplayed {
1053 item_id: "ep1".to_string()
1054 },
1055 );
1056
1057 assert!(parse_queued_op("mark_unplayed", None, None).is_err());
1058 }
1059
1060 #[tokio::test]
1066 async fn test_drain_pushes_mark_unplayed() {
1067 let db = test_db();
1068 seed(
1069 &db,
1070 &[(
1071 "u1",
1072 "mark_unplayed",
1073 "ep9",
1074 None,
1075 "pending",
1076 0,
1077 "2026-08-01T10:00:00Z",
1078 )],
1079 )
1080 .await;
1081
1082 let sink = RecordingSink::new();
1083 let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
1084
1085 assert_eq!(
1086 sink.calls(),
1087 vec![QueuedOp::MarkUnplayed {
1088 item_id: "ep9".to_string()
1089 }],
1090 );
1091 assert_eq!(report.pushed, 1);
1092 assert_eq!(report.remaining, 0);
1093 }
1094}