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}