Files
jellytau/src-tauri/src/commands/sync.rs
T
dtourolle 1b70926c36
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00

391 lines
12 KiB
Rust

//! Tauri commands for sync queue operations
//!
//! The sync queue stores mutations (favorites, playback progress, etc.)
//! that need to be synced to the Jellyfin server when connectivity is restored.
//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
//! read side the UI lists from (DR-132).
//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Sync queue item returned to frontend
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncQueueItem {
pub id: i64,
pub user_id: String,
pub operation: String,
pub item_id: Option<String>,
pub payload: Option<String>,
pub status: String,
pub retry_count: i32,
pub created_at: Option<String>,
pub error_message: Option<String>,
/// Cached title of the item the operation is about, when the catalog knows
/// it. Resolved here rather than by a per-row frontend fetch — the queue
/// list is otherwise a wall of opaque ids.
///
/// TRACES: UR-025 | DR-132
pub item_name: Option<String>,
}
/// Queue a mutation for sync to server
#[tauri::command]
#[specta::specta]
pub async fn sync_queue_mutation(
db: State<'_, DatabaseWrapper>,
user_id: String,
operation: String,
item_id: Option<String>,
payload: Option<String>,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
vec![
QueryParam::String(user_id),
QueryParam::String(operation),
item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
let id = db_service
.last_insert_rowid()
.await
.map_err(|e| e.to_string())?;
Ok(id)
}
/// Get all pending sync operations for a user
#[tauri::command]
#[specta::specta]
pub async fn sync_get_pending(
db: State<'_, DatabaseWrapper>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<SyncQueueItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// The `items` join names the queued item where the catalog has it; a row for
// an item that was never cached still lists, with a null name.
// `abandoned` rows (DR-131 gave up on them) are excluded here for the same
// reason they are excluded from the count — they are no longer waiting.
const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
FROM sync_queue q
LEFT JOIN items i ON i.id = q.item_id
WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
ORDER BY q.created_at ASC, q.id ASC";
let sql = match limit {
Some(l) => format!("{} LIMIT {}", SELECT, l),
None => SELECT.to_string(),
};
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
db_service
.query_many(query, |row| {
Ok(SyncQueueItem {
id: row.get(0)?,
user_id: row.get(1)?,
operation: row.get(2)?,
item_id: row.get(3)?,
payload: row.get(4)?,
status: row.get(5)?,
retry_count: row.get(6)?,
created_at: row.get(7)?,
error_message: row.get(8)?,
item_name: row.get(9)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Mark a sync operation as in progress
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'processing' WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as completed
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as failed with error message
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_failed(
db: State<'_, DatabaseWrapper>,
id: i64,
error: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = ?,
processed_at = CURRENT_TIMESTAMP
WHERE id = ?",
vec![QueryParam::String(error), QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get count of pending sync operations for a user
#[tauri::command]
#[specta::specta]
pub async fn sync_get_pending_count(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
vec![QueryParam::String(user_id)],
);
db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())
}
/// Delete completed sync operations older than specified days
#[tauri::command]
#[specta::specta]
pub async fn sync_cleanup_completed(
db: State<'_, DatabaseWrapper>,
days_old: i32,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue
WHERE status = 'completed'
AND processed_at < datetime('now', ?)",
vec![QueryParam::String(format!("-{} days", days_old))],
);
let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(deleted as i32)
}
/// Delete all sync operations for a user (used during logout)
#[tauri::command]
#[specta::specta]
pub async fn sync_clear_user(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue WHERE user_id = ?",
vec![QueryParam::String(user_id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_queue_item_serialization() {
let item = SyncQueueItem {
id: 1,
user_id: "user-123".to_string(),
operation: "favorite".to_string(),
item_id: Some("item-456".to_string()),
payload: Some(r#"{"isFavorite": true}"#.to_string()),
status: "pending".to_string(),
retry_count: 0,
created_at: Some("2024-02-14T08:00:00Z".to_string()),
error_message: None,
item_name: None,
};
// Should serialize successfully
let json = serde_json::to_string(&item);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("user-123"));
assert!(serialized.contains("favorite"));
assert!(serialized.contains("pending"));
}
#[test]
fn test_sync_queue_item_with_error() {
let item = SyncQueueItem {
id: 2,
user_id: "user-789".to_string(),
operation: "update_progress".to_string(),
item_id: Some("item-999".to_string()),
payload: None,
status: "failed".to_string(),
retry_count: 3,
created_at: Some("2024-02-14T07:00:00Z".to_string()),
error_message: Some("Connection timeout".to_string()),
item_name: None,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("failed"));
assert!(json.contains("Connection timeout"));
assert!(json.contains("3")); // retry_count
}
#[test]
fn test_sync_queue_item_without_optional_fields() {
let item = SyncQueueItem {
id: 3,
user_id: "user-000".to_string(),
operation: "clear_progress".to_string(),
item_id: None,
payload: None,
status: "completed".to_string(),
retry_count: 0,
created_at: None,
error_message: None,
item_name: None,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("completed"));
assert!(json.contains("null") || json.contains("\"itemId\":null"));
}
#[test]
fn test_sync_status_values() {
// Verify all expected status values
let valid_statuses = vec!["pending", "processing", "completed", "failed"];
for status in valid_statuses {
let item = SyncQueueItem {
id: 1,
user_id: "test".to_string(),
operation: "test".to_string(),
item_id: None,
payload: None,
status: status.to_string(),
retry_count: 0,
created_at: None,
error_message: None,
item_name: None,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains(status));
}
}
#[test]
fn test_query_param_generation() {
// Test QueryParam generation for sync operations
let user_id = "user-123".to_string();
let operation = "favorite".to_string();
let params: Vec<QueryParam> = vec![
QueryParam::String(user_id.clone()),
QueryParam::String(operation.clone()),
QueryParam::Null,
QueryParam::Null,
];
assert_eq!(params.len(), 4);
assert!(matches!(params[0], QueryParam::String(_)));
assert!(matches!(params[1], QueryParam::String(_)));
assert!(matches!(params[2], QueryParam::Null));
assert!(matches!(params[3], QueryParam::Null));
}
#[test]
fn test_retry_count_increment() {
// Verify retry count management
let mut item = SyncQueueItem {
id: 1,
user_id: "user-123".to_string(),
operation: "favorite".to_string(),
item_id: None,
payload: None,
status: "pending".to_string(),
retry_count: 0,
created_at: None,
error_message: None,
item_name: None,
};
// Simulate retries
for i in 1..=5 {
item.retry_count = i;
item.status = if i < 3 { "pending" } else { "failed" }.to_string();
assert!(item.retry_count == i);
}
}
}