Files
jellytau/src/lib/services/syncService.ts
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

208 lines
6.2 KiB
TypeScript

// Sync service - manages offline mutation queueing
//
// Simplified service that coordinates with the Rust backend.
// The Rust backend handles sync queue persistence and processing logic.
// This service provides a thin TypeScript API for queuing mutations.
//
// TRACES: UR-002, UR-017, UR-025 | DR-014
import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
// The queue row shape comes from the generated bindings rather than a
// hand-written mirror — the mirror had already drifted (it predates `itemName`),
// and a drifted duplicate is how a field silently stops reaching the UI.
import type { SyncQueueItem } from "$lib/api/bindings";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("SyncService");
export type { SyncQueueItem };
export type SyncOperation =
| "mark_played"
| "mark_unplayed"
| "mark_favorite"
| "unmark_favorite"
| "update_progress"
| "report_playback_start"
| "report_playback_stopped"
| "playlist_create"
| "playlist_delete"
| "playlist_rename"
| "playlist_add_items"
| "playlist_remove_items"
| "playlist_reorder_item";
/**
* Simplified sync service - handles offline mutation queueing
*
* The Rust backend maintains the sync queue in SQLite and is responsible
* for processing queued items. This service provides a TypeScript API
* for queueing and managing sync operations.
*/
class SyncService {
/**
* Start the sync service (lifecycle managed by Rust backend)
*/
start(): void {
log.debug("Started");
}
/**
* Stop the sync service (lifecycle managed by Rust backend)
*/
stop(): void {
log.debug("Stopped");
}
/**
* Queue a mutation for sync to server
*
* TRACES: UR-017, UR-025 | DR-014
*/
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 commands.syncQueueMutation(
userId,
operation,
itemId,
payload ? JSON.stringify(payload) : null,
);
log.debug(`Queued ${operation} for item ${itemId}, id: ${id}`);
return id;
}
// NOTE: `queueFavorite` is gone. Favourites are drained by Rust on the
// `connectivity:reconnected` signal (DR-120) — the local write already sets
// `pending_sync`, and a second queue here would push the same change twice.
// See src-tauri/src/commands/favorites.rs.
/**
* Queue playback progress update
* Also updates local state immediately
*/
async queuePlaybackProgress(itemId: string, positionMs: number): Promise<number> {
// Update local state first
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs);
return this.queueMutation("update_progress", itemId, { positionMs });
}
/**
* Queue mark as played
* Also updates local state immediately
*/
async queueMarkPlayed(itemId: string): Promise<number> {
// storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
// it has to cover a season or series' episodes too. storageMarkPlayed stays
// the single-item "this finished playing" path.
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, true);
return this.queueMutation("mark_played", itemId);
}
/**
* Queue mark as unwatched, the inverse of {@link queueMarkPlayed}.
*
* Same shape deliberately: the watched toggle has to work in both directions
* offline, or un-marking would be the one half that needs a connection. The
* drain pushes this as `clear_watch_history` — Jellyfin's mark-unplayed, which
* is recursive over a season or series and also clears resume positions.
*
* TRACES: UR-073 | DR-158
*/
async queueMarkUnplayed(itemId: string): Promise<number> {
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, false);
return this.queueMutation("mark_unplayed", itemId);
}
/**
* Get count of pending sync operations
*/
async getPendingCount(): Promise<number> {
const userId = auth.getUserId();
if (!userId) {
return 0;
}
return commands.syncGetPendingCount(userId);
}
/**
* Get pending sync items (for debugging/monitoring)
*/
async getPending(limit?: number): Promise<SyncQueueItem[]> {
const userId = auth.getUserId();
if (!userId) {
return [];
}
return commands.syncGetPending(userId, limit ?? null);
}
/**
* Clean up completed operations older than specified days
*/
async cleanup(daysOld: number = 7): Promise<number> {
const deleted = await commands.syncCleanupCompleted(daysOld);
log.debug(`Cleaned up ${deleted} old completed operations`);
return deleted;
}
// ===== Playlist sync operations =====
async queuePlaylistCreate(playlistId: string, name: string, itemIds: string[]): Promise<number> {
return this.queueMutation("playlist_create", playlistId, { name, itemIds });
}
async queuePlaylistDelete(playlistId: string): Promise<number> {
return this.queueMutation("playlist_delete", playlistId);
}
async queuePlaylistRename(playlistId: string, name: string): Promise<number> {
return this.queueMutation("playlist_rename", playlistId, { name });
}
async queuePlaylistAddItems(playlistId: string, itemIds: string[]): Promise<number> {
return this.queueMutation("playlist_add_items", playlistId, { itemIds });
}
async queuePlaylistRemoveItems(playlistId: string, entryIds: string[]): Promise<number> {
return this.queueMutation("playlist_remove_items", playlistId, { entryIds });
}
async queuePlaylistReorderItem(
playlistId: string,
itemId: string,
newIndex: number,
): Promise<number> {
return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex });
}
/**
* Clear all sync operations for the current user (called during logout)
*
* TRACES: UR-017 | DR-014
*/
async clearUser(): Promise<void> {
const userId = auth.getUserId();
if (userId) {
await commands.syncClearUser(userId);
log.debug("Cleared sync queue for user");
}
}
}
// Export singleton instance
export const syncService = new SyncService();