First working POC
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
// Sync service - processes queued mutations when connectivity is restored
|
||||
//
|
||||
// This service handles:
|
||||
// - Queueing mutations (favorites, playback progress) when offline
|
||||
// - Processing queued mutations when connectivity is restored
|
||||
// - Retry with exponential backoff for failed operations
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { isServerReachable, connectivity } from "$lib/stores/connectivity";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
// Types matching Rust structs
|
||||
export interface SyncQueueItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
operation: string;
|
||||
itemId: string | null;
|
||||
payload: string | null;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
createdAt: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export type SyncOperation =
|
||||
| "mark_played"
|
||||
| "mark_unplayed"
|
||||
| "mark_favorite"
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
| "report_playback_start"
|
||||
| "report_playback_stopped";
|
||||
|
||||
// Maximum retries before giving up on an operation
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// Delay between sync attempts (exponential backoff)
|
||||
const BASE_RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Batch size for processing queue
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
class SyncService {
|
||||
private processing = false;
|
||||
private unsubscribeConnectivity: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Start the sync service - listens for connectivity changes
|
||||
*/
|
||||
start(): void {
|
||||
if (this.unsubscribeConnectivity) {
|
||||
return; // Already started
|
||||
}
|
||||
|
||||
console.log("[SyncService] Starting...");
|
||||
|
||||
// Listen for connectivity changes
|
||||
this.unsubscribeConnectivity = isServerReachable.subscribe((reachable) => {
|
||||
if (reachable && !this.processing) {
|
||||
console.log("[SyncService] Server became reachable, processing queue...");
|
||||
this.processQueue();
|
||||
}
|
||||
});
|
||||
|
||||
// Process queue on startup if online
|
||||
if (get(isServerReachable)) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the sync service
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.unsubscribeConnectivity) {
|
||||
this.unsubscribeConnectivity();
|
||||
this.unsubscribeConnectivity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a mutation for sync to server
|
||||
*/
|
||||
async queueMutation(
|
||||
operation: SyncOperation,
|
||||
itemId: string,
|
||||
payload?: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const id = await invoke<number>("sync_queue_mutation", {
|
||||
userId,
|
||||
operation,
|
||||
itemId,
|
||||
payload: payload ? JSON.stringify(payload) : null,
|
||||
});
|
||||
|
||||
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
|
||||
|
||||
// Try to process immediately if online
|
||||
if (get(isServerReachable) && !this.processing) {
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a favorite toggle
|
||||
*/
|
||||
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
||||
// Also update local state
|
||||
await invoke("storage_toggle_favorite", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
isFavorite,
|
||||
});
|
||||
|
||||
return this.queueMutation(
|
||||
isFavorite ? "mark_favorite" : "unmark_favorite",
|
||||
itemId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue playback progress update
|
||||
*/
|
||||
async queuePlaybackProgress(
|
||||
itemId: string,
|
||||
positionTicks: number
|
||||
): Promise<number> {
|
||||
// Also update local state
|
||||
await invoke("storage_update_playback_progress", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
positionTicks,
|
||||
});
|
||||
|
||||
return this.queueMutation("update_progress", itemId, { positionTicks });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue mark as played
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Also update local state
|
||||
await invoke("storage_mark_played", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
});
|
||||
|
||||
return this.queueMutation("mark_played", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending sync operations
|
||||
*/
|
||||
async getPendingCount(): Promise<number> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return invoke<number>("sync_get_pending_count", { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the sync queue
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
if (this.processing) {
|
||||
console.log("[SyncService] Already processing queue");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
console.log("[SyncService] Not authenticated, skipping queue processing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("[SyncService] Server not reachable, skipping queue processing");
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
console.log("[SyncService] Processing sync queue...");
|
||||
|
||||
try {
|
||||
// Get pending items
|
||||
const items = await invoke<SyncQueueItem[]>("sync_get_pending", {
|
||||
userId,
|
||||
limit: BATCH_SIZE,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log("[SyncService] No pending items in queue");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SyncService] Processing ${items.length} queued items`);
|
||||
|
||||
for (const item of items) {
|
||||
// Check connectivity before each item
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("[SyncService] Lost connectivity, stopping queue processing");
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've exceeded retries
|
||||
if (item.retryCount >= MAX_RETRIES) {
|
||||
console.warn(
|
||||
`[SyncService] Item ${item.id} exceeded max retries, marking as failed`
|
||||
);
|
||||
await invoke("sync_mark_failed", {
|
||||
id: item.id,
|
||||
error: "Exceeded maximum retry attempts",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processItem(item);
|
||||
}
|
||||
|
||||
// Check if there are more items to process
|
||||
const remaining = await this.getPendingCount();
|
||||
if (remaining > 0 && get(isServerReachable)) {
|
||||
// Process next batch after a short delay
|
||||
setTimeout(() => this.processQueue(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SyncService] Error processing queue:", error);
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single sync queue item
|
||||
*/
|
||||
private async processItem(item: SyncQueueItem): Promise<void> {
|
||||
console.log(`[SyncService] Processing item ${item.id}: ${item.operation}`);
|
||||
|
||||
try {
|
||||
// Mark as processing
|
||||
await invoke("sync_mark_processing", { id: item.id });
|
||||
|
||||
// Get repository for API calls
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Execute the operation
|
||||
switch (item.operation) {
|
||||
case "mark_favorite":
|
||||
if (item.itemId) {
|
||||
await repo.markFavorite(item.itemId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "unmark_favorite":
|
||||
if (item.itemId) {
|
||||
await repo.unmarkFavorite(item.itemId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "update_progress":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackProgress(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
case "mark_played":
|
||||
if (item.itemId) {
|
||||
// Jellyfin doesn't have a direct "mark played" endpoint,
|
||||
// we report playback stopped at 100%
|
||||
const itemData = await repo.getItem(item.itemId);
|
||||
if (itemData.runTimeTicks) {
|
||||
await repo.reportPlaybackStopped(item.itemId, itemData.runTimeTicks);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "report_playback_start":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackStart(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
case "report_playback_stopped":
|
||||
if (item.itemId && item.payload) {
|
||||
const payload = JSON.parse(item.payload);
|
||||
await repo.reportPlaybackStopped(item.itemId, payload.positionTicks);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[SyncService] Unknown operation: ${item.operation}`);
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
await invoke("sync_mark_completed", { id: item.id });
|
||||
|
||||
// Also mark local data as synced
|
||||
if (item.itemId) {
|
||||
await invoke("storage_mark_synced", {
|
||||
userId: item.userId,
|
||||
itemId: item.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SyncService] Successfully processed item ${item.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[SyncService] Failed to process item ${item.id}:`, error);
|
||||
|
||||
// Calculate retry delay with exponential backoff
|
||||
const retryDelay = BASE_RETRY_DELAY_MS * Math.pow(2, item.retryCount);
|
||||
|
||||
// Mark as failed
|
||||
await invoke("sync_mark_failed", {
|
||||
id: item.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Wait before continuing (gives server time to recover if overloaded)
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(retryDelay, 10000)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up completed operations older than specified days
|
||||
*/
|
||||
async cleanup(daysOld: number = 7): Promise<number> {
|
||||
const deleted = await invoke<number>("sync_cleanup_completed", { daysOld });
|
||||
console.log(`[SyncService] Cleaned up ${deleted} old completed operations`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync operations for the current user (called during logout)
|
||||
*/
|
||||
async clearUser(): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
if (userId) {
|
||||
await invoke("sync_clear_user", { userId });
|
||||
console.log("[SyncService] Cleared sync queue for user");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const syncService = new SyncService();
|
||||
Reference in New Issue
Block a user