Skip to main content

jellytau_lib/commands/
sync_drain.rs

1//! Draining the offline mutation queue (`sync_queue`) to the server.
2//!
3//! `sync_queue` had producers but no consumer: `PlaybackReporter::queue_for_sync`
4//! inserts a row whenever a start/stop/mark-played cannot reach the server, and
5//! nothing ever pushed one. `sync_mark_processing`/`_completed`/`_failed` were
6//! registered commands with no callers, so the queue only grew — the offline
7//! banner's "N pending" climbed forever and the watch positions those rows stood
8//! for never reached Jellyfin.
9//!
10//! Same shape as the favourites drain (DR-120), and for the same reason: a drain
11//! started by a component dies with it, so it lives in Rust and hangs off the
12//! `connectivity:reconnected` transition the `ConnectivityMonitor` already emits.
13//!
14//! TRACES: UR-025, UR-002 | DR-131 | UT-122
15
16use 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
26/// How many times a row may fail before it stops being retried.
27///
28/// A row that can never succeed (a deleted item, an operation this build does
29/// not know how to push) must eventually leave the queue, or it re-creates the
30/// bug this module fixes: a count that only ever goes up.
31pub const MAX_SYNC_ATTEMPTS: i32 = 5;
32
33/// Emitted after a drain so open views can re-read the queue instead of waiting
34/// for the frontend's 10s poll.
35pub const SYNC_QUEUE_CHANGED_EVENT: &str = "sync-queue-changed";
36
37/// A queued mutation, resolved from its stored `operation` + JSON `payload`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum QueuedOp {
40    PlaybackStart {
41        item_id: String,
42        position_ticks: i64,
43    },
44    /// Also where `update_progress` lands: replaying a mid-playback progress
45    /// report long after the fact would tell the server we are still playing.
46    /// What the row actually carries is a resume position, and "stopped at N"
47    /// is how that reaches Jellyfin's `UserData`.
48    PlaybackStopped {
49        item_id: String,
50        position_ticks: i64,
51    },
52    MarkPlayed {
53        item_id: String,
54    },
55    /// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
56    /// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
57    /// item un-marked offline does not come back carrying a stale position.
58    MarkUnplayed {
59        item_id: String,
60    },
61    /// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
62    /// (DR-120). Supported so a row written by an older build still lands.
63    Favorite {
64        item_id: String,
65        is_favorite: bool,
66    },
67}
68
69/// Turn a stored row into something pushable.
70///
71/// Payload keys differ by producer: the Rust reporter writes `position_ticks`,
72/// while `syncService.queuePlaybackProgress` writes camelCase `positionMs`.
73/// Both are accepted rather than normalised at the producer, because rows
74/// already in users' databases were written by both.
75///
76/// TRACES: UR-025 | DR-131 | UT-122
77pub 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; // ms → Jellyfin ticks (100ns)
100        }
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/// The slice of the repository the drain needs — narrow so it can be doubled in
128/// a test without forty `unimplemented!()` methods.
129#[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/// What a drain did, for logging and for the frontend's "Sync now" button.
163#[derive(
164    Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type,
165)]
166#[serde(rename_all = "camelCase")]
167pub struct DrainReport {
168    /// Rows that reached the server and are now `completed`.
169    pub pushed: i32,
170    /// Rows that failed and will be retried on the next reconnect.
171    pub deferred: i32,
172    /// Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
173    pub abandoned: i32,
174    /// Rows still waiting afterwards (what the badge counts).
175    pub remaining: i32,
176}
177
178/// Why a push failed, and whether the row should be charged an attempt for it.
179struct PushFailure {
180    reason: String,
181    /// The server could not be reached at all — retry later, free of charge.
182    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
227/// Push every queued mutation for this user, oldest first.
228///
229/// Chronological order matters: a stale start replayed after a later stop would
230/// otherwise move the server's resume position backwards.
231///
232/// A row that fails keeps its place and is retried on the next reconnect, until
233/// `MAX_SYNC_ATTEMPTS` — after which it is abandoned, because a row nothing can
234/// ever push is exactly what turned this queue into a counter that only grew.
235///
236/// TRACES: UR-025, UR-002 | DR-131 | UT-122
237pub 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                // An unreachable server is not the row's fault: burning its
262                // budget would abandon perfectly good rows just because the app
263                // was opened offline a few times.
264                transient: matches!(e, RepoError::Offline | RepoError::Network { .. }),
265                reason: e.to_string(),
266            }),
267            // An unreadable or unsupported row can never succeed, so it does
268            // burn attempts rather than being deleted outright — the panel shows
269            // the reason until it is abandoned.
270            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
330/// Put a row back in the queue untouched apart from its error note — used when
331/// the server was simply unreachable.
332async 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
366/// Queue a watch position that could not be reported to the server.
367///
368/// The stop-report path pushed straight to the server and, on failure, logged
369/// and dropped the position — so closing a video while the server was
370/// unreachable lost the resume point outright, even though the queue and its
371/// drain (DR-131) were built and running. This is the missing producer.
372///
373/// The pending row for an item is *replaced* rather than appended to. Progress
374/// is reported every 10s, so a server that stays down would otherwise grow one
375/// row per tick, all of them superseded by the newest — the unbounded queue
376/// DR-131 exists to prevent. Only `pending`/`failed` rows are superseded:
377/// an `abandoned` row has been given up on and must not be revived, and a
378/// `completed` one is history.
379///
380/// TRACES: UR-025 | DR-154 | UT-151
381pub 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    // Supersede an already-queued position for this item, keeping its place in
390    // the queue order (created_at) so a later item cannot overtake it.
391    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
425/// Drain on every offline→online transition.
426///
427/// TRACES: UR-025 | DR-131
428pub 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
440/// Resolve app state and drain. Shared by the reconnect hook and the manual
441/// "Sync now" command.
442pub 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            // Not signed in — nothing to push on behalf of.
454            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/// Push the queue now, on the user's say-so, instead of waiting for a reconnect.
473///
474/// TRACES: UR-025 | DR-132
475#[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    /// Records what the server was asked to do, and can be told to fail.
489    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        /// The server is there and refuses the operation — the row's own fault.
503        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        /// The server cannot be reached at all — nothing to do with the row.
513        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    /// (user, operation, item_id, payload, status, retry_count, created_at)
559    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    /// UT-122 — the bug itself: rows queued while offline reach the server on
604    /// reconnect and stop counting towards the offline banner's badge.
605    ///
606    /// TRACES: UR-025 | DR-131 | UT-122
607    #[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    /// A push that fails stays queued for the next reconnect rather than being
670    /// dropped.
671    ///
672    /// TRACES: UR-025 | DR-131 | UT-122
673    #[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    /// An unreachable server does not charge the row an attempt — otherwise
700    /// opening the app offline a few times abandons perfectly good rows.
701    ///
702    /// TRACES: UR-025 | DR-131 | UT-122
703    #[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    /// A row that can never succeed must eventually leave the queue, or the
734    /// count climbs forever — which is the bug this module exists to fix.
735    ///
736    /// TRACES: UR-025 | DR-131 | UT-122
737    #[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    /// An operation this build cannot push does not wedge the queue behind it.
764    ///
765    /// TRACES: UR-025 | DR-131 | UT-122
766    #[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    /// Another user's queued changes are not pushed with this user's token.
810    ///
811    /// TRACES: UR-025 | DR-131 | UT-122
812    #[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    /// The bug DR-154 fixes: a stop-report that could not reach the server was
853    /// logged and dropped, so the watch position was lost outright. It must
854    /// land in the queue the drain already knows how to push.
855    ///
856    /// TRACES: UR-025 | DR-154 | UT-151
857    #[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        // The very drain that already exists must be able to push it.
866        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    /// Progress is reported every 10s, and a server that stays unreachable
881    /// would otherwise add a row per tick — an unbounded queue of positions
882    /// that are all superseded by the newest one. The pending row for an item
883    /// is replaced in place, so the queue holds the latest position only.
884    ///
885    /// TRACES: UR-025 | DR-154 | UT-151
886    #[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        // One row, carrying the newest position — not three.
904        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    /// Distinct items must not collide — superseding is per item, not global.
914    ///
915    /// TRACES: UR-025 | DR-154 | UT-151
916    #[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    /// A row already abandoned (DR-131 gave up on it) must not be resurrected
951    /// by a later report — that would restore the queue-that-only-grows this
952    /// whole area exists to prevent. The new report is queued as its own row.
953    ///
954    /// TRACES: UR-025 | DR-154 | UT-151
955    #[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        // Only the fresh row is pushed; the abandoned one stays abandoned.
980        assert_eq!(
981            sink.calls(),
982            vec![QueuedOp::PlaybackStopped {
983                item_id: "ep1".to_string(),
984                position_ticks: 999,
985            }]
986        );
987    }
988
989    /// Nothing queued means no server calls at all — a reconnect must not
990    /// generate traffic just because it happened.
991    ///
992    /// TRACES: UR-025 | DR-131 | UT-122
993    #[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    /// Both payload dialects parse: `position_ticks` from the Rust reporter and
1005    /// camelCase `positionMs` from the frontend's queue helper.
1006    ///
1007    /// TRACES: UR-025 | DR-131 | UT-122
1008    #[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    /// Un-marking watched queues like marking watched does, so the toggle works
1045    /// in both directions while the server is unreachable rather than only one.
1046    ///
1047    /// TRACES: UR-073 | DR-158 | UT-154
1048    #[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    /// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
1061    /// mark-unplayed, which also zeroes the resume position, so a series returns
1062    /// to "never watched" rather than keeping a stale position.
1063    ///
1064    /// TRACES: UR-073 | DR-158 | UT-154
1065    #[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}