many changes
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Device ID service tests
|
||||
*
|
||||
* Tests the service layer that integrates with the Rust backend.
|
||||
* The Rust backend handles UUID generation and database storage.
|
||||
*
|
||||
* TRACES: UR-009 | DR-011
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -18,7 +23,7 @@ describe("Device ID Service", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should retrieve existing device ID from backend", async () => {
|
||||
it("should retrieve device ID from backend", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
@@ -26,20 +31,10 @@ describe("Device ID Service", () => {
|
||||
|
||||
expect(deviceId).toBe(mockDeviceId);
|
||||
expect(invoke).toHaveBeenCalledWith("device_get_id");
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should generate and store new device ID if none exists", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(null); // No existing ID
|
||||
(invoke as any).mockResolvedValueOnce(undefined); // Store succeeds
|
||||
|
||||
const deviceId = await getDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[a-f0-9\-]{36}$/); // UUID format
|
||||
expect(invoke).toHaveBeenCalledWith("device_get_id");
|
||||
expect(invoke).toHaveBeenCalledWith("device_set_id", { deviceId: expect.any(String) });
|
||||
});
|
||||
|
||||
it("should cache device ID in memory", async () => {
|
||||
it("should cache device ID in memory after first call", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
@@ -47,11 +42,11 @@ describe("Device ID Service", () => {
|
||||
const id2 = await getDeviceId();
|
||||
|
||||
expect(id1).toBe(id2);
|
||||
// Should only call invoke once due to caching
|
||||
// Should only invoke backend once due to caching
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return cached device ID synchronously", async () => {
|
||||
it("should return cached device ID synchronously after initialization", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
@@ -61,27 +56,15 @@ describe("Device ID Service", () => {
|
||||
expect(cachedId).toBe(mockDeviceId);
|
||||
});
|
||||
|
||||
it("should return empty string from sync if cache is empty", () => {
|
||||
it("should return empty string from sync if not yet initialized", () => {
|
||||
const syncId = getDeviceIdSync();
|
||||
|
||||
expect(syncId).toBe("");
|
||||
});
|
||||
|
||||
it("should fallback to generated ID on backend error", async () => {
|
||||
(invoke as any).mockRejectedValue(new Error("Backend unavailable"));
|
||||
it("should throw error when backend fails", async () => {
|
||||
(invoke as any).mockRejectedValue(new Error("Backend error"));
|
||||
|
||||
const deviceId = await getDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[a-f0-9\-]{36}$/); // UUID format
|
||||
});
|
||||
|
||||
it("should continue with in-memory ID if persistent storage fails", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(null); // No existing ID
|
||||
(invoke as any).mockRejectedValueOnce(new Error("Storage unavailable")); // Store fails
|
||||
|
||||
const deviceId = await getDeviceId();
|
||||
|
||||
expect(deviceId).toMatch(/^[a-f0-9\-]{36}$/); // UUID format
|
||||
await expect(getDeviceId()).rejects.toThrow("Failed to initialize device ID");
|
||||
});
|
||||
|
||||
it("should clear cache on logout", async () => {
|
||||
@@ -89,18 +72,21 @@ describe("Device ID Service", () => {
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
await getDeviceId();
|
||||
clearCache();
|
||||
expect(getDeviceIdSync()).toBe(mockDeviceId);
|
||||
|
||||
clearCache();
|
||||
expect(getDeviceIdSync()).toBe("");
|
||||
});
|
||||
|
||||
it("should generate unique device IDs", async () => {
|
||||
(invoke as any).mockResolvedValue(null);
|
||||
it("should call backend again after cache is cleared", async () => {
|
||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
||||
|
||||
const id1 = await getDeviceId();
|
||||
await getDeviceId();
|
||||
clearCache();
|
||||
const id2 = await getDeviceId();
|
||||
await getDeviceId();
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
// Should call backend twice (once per getDeviceId call)
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
/**
|
||||
* Device ID Management Service
|
||||
*
|
||||
* Manages device identification securely for Jellyfin server communication.
|
||||
* Uses Tauri's secure storage when available, falls back to in-memory for testing.
|
||||
* Manages device identification for Jellyfin server communication.
|
||||
* The Rust backend handles UUID generation and persistent storage in the database.
|
||||
* This service provides a simple interface with in-memory caching.
|
||||
*
|
||||
* TRACES: UR-009 | DR-011
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
let cachedDeviceId: string | null = null;
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 for device identification
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the device ID.
|
||||
* Device ID should be persistent across app restarts for proper server communication.
|
||||
* Device ID is a UUID v4 that persists across app restarts.
|
||||
* On first call, the Rust backend generates and stores a new UUID.
|
||||
* On subsequent calls, the stored UUID is retrieved.
|
||||
*
|
||||
* @returns The device ID string
|
||||
* @returns The device ID string (UUID v4)
|
||||
*
|
||||
* TRACES: UR-009 | DR-011
|
||||
*/
|
||||
export async function getDeviceId(): Promise<string> {
|
||||
// Return cached value if available
|
||||
@@ -33,40 +29,21 @@ export async function getDeviceId(): Promise<string> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from Tauri secure storage (Rust backend manages this)
|
||||
const deviceId = await invoke<string | null>("device_get_id");
|
||||
|
||||
if (deviceId) {
|
||||
cachedDeviceId = deviceId;
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// If no device ID exists, generate and store a new one
|
||||
const newDeviceId = generateUUID();
|
||||
try {
|
||||
await invoke("device_set_id", { deviceId: newDeviceId });
|
||||
} catch (e) {
|
||||
console.warn("[deviceId] Failed to persist device ID to secure storage:", e);
|
||||
// Continue with in-memory ID if storage fails
|
||||
}
|
||||
|
||||
cachedDeviceId = newDeviceId;
|
||||
return newDeviceId;
|
||||
// Rust backend handles generation and storage atomically
|
||||
const deviceId = await invoke<string>("device_get_id");
|
||||
cachedDeviceId = deviceId;
|
||||
return deviceId;
|
||||
} catch (e) {
|
||||
console.error("[deviceId] Failed to get device ID from backend:", e);
|
||||
|
||||
// Fallback: generate a temporary in-memory ID
|
||||
// This is not ideal but allows the app to continue functioning
|
||||
if (!cachedDeviceId) {
|
||||
cachedDeviceId = generateUUID();
|
||||
}
|
||||
return cachedDeviceId;
|
||||
throw new Error("Failed to initialize device ID: " + String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached device ID synchronously (if available)
|
||||
* This should be used after initial getDeviceId() call
|
||||
* This should only be used after initial getDeviceId() call
|
||||
*
|
||||
* @returns The cached device ID, or empty string if not yet initialized
|
||||
*/
|
||||
export function getDeviceIdSync(): string {
|
||||
return cachedDeviceId || "";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Favorites service - Handles toggling favorite status with optimistic updates
|
||||
// TRACES: UR-017 | DR-021
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
||||
// TRACES: UR-007 | DR-016
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
@@ -75,52 +76,6 @@ export async function getCachedImageUrl(
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version that returns server URL immediately
|
||||
* and triggers background caching. Useful for initial render.
|
||||
*
|
||||
* @param serverUrl - The Jellyfin server base URL
|
||||
* @param itemId - The Jellyfin item ID
|
||||
* @param imageType - The image type (Primary, Backdrop, etc.)
|
||||
* @param options - Image options
|
||||
* @returns The server image URL
|
||||
*/
|
||||
export function getImageUrlSync(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
imageType: string = "Primary",
|
||||
options: {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
tag?: string;
|
||||
} = {}
|
||||
): string {
|
||||
const tag = options.tag || "default";
|
||||
|
||||
// Build server URL
|
||||
const params = new URLSearchParams();
|
||||
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
||||
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
||||
if (options.quality) params.set("quality", options.quality.toString());
|
||||
if (options.tag) params.set("tag", options.tag);
|
||||
|
||||
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
||||
|
||||
// Trigger background caching (fire and forget, non-critical)
|
||||
invoke("thumbnail_save", {
|
||||
itemId,
|
||||
imageType,
|
||||
tag,
|
||||
url: serverImageUrl,
|
||||
}).catch((e) => {
|
||||
// Background caching failure is non-critical, will use server URL instead
|
||||
console.debug(`[imageCache] Failed to save thumbnail for ${itemId}:`, e);
|
||||
});
|
||||
|
||||
return serverImageUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail cache statistics
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* Handles user interactions with the next episode popup.
|
||||
* Backend manages countdown logic and autoplay decisions.
|
||||
*
|
||||
* TRACES: UR-023 | DR-047, DR-048
|
||||
*/
|
||||
|
||||
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay";
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
// Playback reporting service - syncs to both Jellyfin server and local DB
|
||||
// Playback reporting service
|
||||
//
|
||||
// This service handles:
|
||||
// - Updating local DB (always works, even offline)
|
||||
// - Reporting to Jellyfin server when online
|
||||
// - Queueing operations for sync when offline
|
||||
// Simplified service that delegates all logic to the Rust backend.
|
||||
// The backend handles:
|
||||
// - Local DB updates
|
||||
// - Jellyfin server reporting
|
||||
// - Offline queueing (via sync queue)
|
||||
// - Connectivity checks
|
||||
//
|
||||
// TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
import { syncService } from "./syncService";
|
||||
import { secondsToTicks } from "$lib/utils/playbackUnits";
|
||||
|
||||
/**
|
||||
* Report playback start to Jellyfin and local DB
|
||||
* Report playback start to Jellyfin (or queue if offline)
|
||||
*
|
||||
* The Rust backend handles both local DB updates and server reporting,
|
||||
* automatically queueing for sync if the server is unreachable.
|
||||
*
|
||||
* TRACES: UR-005, UR-025 | DR-028
|
||||
*/
|
||||
export async function reportPlaybackStart(
|
||||
itemId: string,
|
||||
@@ -21,10 +26,18 @@ export async function reportPlaybackStart(
|
||||
contextType: "container" | "single" = "single",
|
||||
contextId: string | null = null
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("reportPlaybackStart - itemId:", itemId, "positionSeconds:", positionSeconds, "context:", contextType, contextId, "userId:", userId);
|
||||
console.log(
|
||||
"[PlaybackReporting] reportPlaybackStart - itemId:",
|
||||
itemId,
|
||||
"positionSeconds:",
|
||||
positionSeconds,
|
||||
"context:",
|
||||
contextType,
|
||||
contextId
|
||||
);
|
||||
|
||||
// Update local DB with context (always works, even offline)
|
||||
if (userId) {
|
||||
@@ -36,64 +49,34 @@ export async function reportPlaybackStart(
|
||||
contextType,
|
||||
contextId,
|
||||
});
|
||||
console.log("reportPlaybackStart - Local DB updated with context successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to update playback context:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("reportPlaybackStart - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStart(itemId, positionTicks);
|
||||
console.log("reportPlaybackStart - Reported to server successfully");
|
||||
|
||||
// Mark as synced (non-critical, will be retried on next sync)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_synced", { userId, itemId });
|
||||
} catch (e) {
|
||||
console.debug("Failed to mark sync status (will retry):", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to report playback start to server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||
console.error("[PlaybackReporting] Failed to update playback context:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report playback progress to Jellyfin and local DB
|
||||
* Report playback progress to Jellyfin (or queue if offline)
|
||||
*
|
||||
* Note: Progress reports are frequent, so we don't queue them for sync.
|
||||
* Note: Progress reports are frequent and are not queued for sync.
|
||||
* The final position is captured by reportPlaybackStopped.
|
||||
*
|
||||
* TRACES: UR-005 | DR-028
|
||||
*/
|
||||
export async function reportPlaybackProgress(
|
||||
itemId: string,
|
||||
positionSeconds: number,
|
||||
isPaused = false
|
||||
_isPaused = false
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
// Reduce logging for frequent progress updates
|
||||
if (Math.floor(positionSeconds) % 30 === 0) {
|
||||
console.log("reportPlaybackProgress - itemId:", itemId, "positionSeconds:", positionSeconds, "isPaused:", isPaused);
|
||||
console.log("[PlaybackReporting] reportPlaybackProgress - itemId:", itemId, "position:", positionSeconds);
|
||||
}
|
||||
|
||||
// Update local DB first (always works, even offline)
|
||||
// Update local DB only (progress updates are frequent, don't report to server)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_update_playback_progress", {
|
||||
@@ -102,37 +85,24 @@ export async function reportPlaybackProgress(
|
||||
positionTicks,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update local playback progress:", e);
|
||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
// Don't queue progress updates - too frequent. Just store locally.
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server (silent failure - progress reports are non-critical)
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackProgress(itemId, positionTicks);
|
||||
} catch {
|
||||
// Silent failure for progress reports - they're frequent and non-critical
|
||||
// The final position is captured by reportPlaybackStopped
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report playback stopped to Jellyfin and local DB
|
||||
* Report playback stopped to Jellyfin (or queue if offline)
|
||||
*
|
||||
* The Rust backend handles both local DB updates and server reporting,
|
||||
* automatically queuing for sync if the server is unreachable.
|
||||
*
|
||||
* TRACES: UR-005, UR-025 | DR-028
|
||||
*/
|
||||
export async function reportPlaybackStopped(
|
||||
itemId: string,
|
||||
positionSeconds: number
|
||||
): Promise<void> {
|
||||
const positionTicks = secondsToTicks(positionSeconds);
|
||||
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
|
||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds, "userId:", userId);
|
||||
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
|
||||
|
||||
// Update local DB first (always works, even offline)
|
||||
if (userId) {
|
||||
@@ -142,90 +112,52 @@ export async function reportPlaybackStopped(
|
||||
itemId,
|
||||
positionTicks,
|
||||
});
|
||||
console.log("reportPlaybackStopped - Local DB updated successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to update local playback progress:", e);
|
||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("reportPlaybackStopped - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Report to Jellyfin server
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStopped(itemId, positionTicks);
|
||||
console.log("reportPlaybackStopped - Reported to server successfully");
|
||||
|
||||
// Mark as synced (non-critical, will be retried on next sync)
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_synced", { userId, itemId });
|
||||
} catch (e) {
|
||||
console.debug("Failed to mark sync status (will retry):", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to report playback stopped to server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||
// Queue for sync to server (the sync service will handle retry logic)
|
||||
if (userId && positionSeconds > 0) {
|
||||
try {
|
||||
// Get the repository to check if we should queue
|
||||
const repo = auth.getRepository();
|
||||
await repo.reportPlaybackStopped(itemId, positionTicks);
|
||||
} catch (e) {
|
||||
console.error("[PlaybackReporting] Failed to report to server:", e);
|
||||
// Server error - could queue, but for now just log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as played (100% progress)
|
||||
*
|
||||
* TRACES: UR-025 | DR-028
|
||||
*/
|
||||
export async function markAsPlayed(itemId: string): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
|
||||
console.log("markAsPlayed - itemId:", itemId, "userId:", userId);
|
||||
console.log("[PlaybackReporting] markAsPlayed - itemId:", itemId);
|
||||
|
||||
// Update local DB first
|
||||
if (userId) {
|
||||
try {
|
||||
await invoke("storage_mark_played", { userId, itemId });
|
||||
console.log("markAsPlayed - Local DB updated successfully");
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as played in local DB:", e);
|
||||
console.error("[PlaybackReporting] Failed to mark as played in local DB:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check connectivity before trying server
|
||||
if (!get(isServerReachable)) {
|
||||
console.log("markAsPlayed - Server not reachable, queueing for sync");
|
||||
if (userId) {
|
||||
await syncService.queueMutation("mark_played", itemId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For Jellyfin, we need to get the item's runtime and report stopped at 100%
|
||||
// Try to report to server via repository (handles queuing internally)
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const item = await repo.getItem(itemId);
|
||||
|
||||
if (item.runTimeTicks) {
|
||||
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
|
||||
console.log("markAsPlayed - Reported to server successfully");
|
||||
|
||||
// Mark as synced
|
||||
if (userId) {
|
||||
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to mark as played on server:", e);
|
||||
// Queue for sync later
|
||||
if (userId) {
|
||||
await syncService.queueMutation("mark_played", itemId);
|
||||
}
|
||||
console.error("[PlaybackReporting] Failed to report as played:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Player Events Service tests
|
||||
*
|
||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Smart preloading service for upcoming tracks
|
||||
* Automatically queues downloads for the next few tracks in the queue
|
||||
*
|
||||
* TRACES: UR-004, UR-011 | DR-006, DR-015
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
+30
-219
@@ -1,13 +1,12 @@
|
||||
// Sync service - processes queued mutations when connectivity is restored
|
||||
// Sync service - manages offline mutation queueing
|
||||
//
|
||||
// This service handles:
|
||||
// - Queueing mutations (favorites, playback progress) when offline
|
||||
// - Processing queued mutations when connectivity is restored
|
||||
// - Retry with exponential backoff for failed operations
|
||||
// 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 { 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
|
||||
@@ -25,62 +24,24 @@ export interface SyncQueueItem {
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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
|
||||
*
|
||||
* TRACES: UR-017, UR-025 | DR-014
|
||||
*/
|
||||
async queueMutation(
|
||||
operation: SyncOperation,
|
||||
@@ -100,20 +61,15 @@ class SyncService {
|
||||
});
|
||||
|
||||
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
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
||||
// Also update local state
|
||||
// Update local state first
|
||||
await invoke("storage_toggle_favorite", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
@@ -128,12 +84,13 @@ class SyncService {
|
||||
|
||||
/**
|
||||
* Queue playback progress update
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queuePlaybackProgress(
|
||||
itemId: string,
|
||||
positionTicks: number
|
||||
): Promise<number> {
|
||||
// Also update local state
|
||||
// Update local state first
|
||||
await invoke("storage_update_playback_progress", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
@@ -145,9 +102,10 @@ class SyncService {
|
||||
|
||||
/**
|
||||
* Queue mark as played
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Also update local state
|
||||
// Update local state first
|
||||
await invoke("storage_mark_played", {
|
||||
userId: auth.getUserId(),
|
||||
itemId,
|
||||
@@ -169,167 +127,18 @@ class SyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the sync queue
|
||||
* Get pending sync items (for debugging/monitoring)
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
if (this.processing) {
|
||||
console.log("[SyncService] Already processing queue");
|
||||
return;
|
||||
}
|
||||
|
||||
async getPending(limit?: number): Promise<SyncQueueItem[]> {
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
console.log("[SyncService] Not authenticated, skipping queue processing");
|
||||
return;
|
||||
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)));
|
||||
}
|
||||
return invoke<SyncQueueItem[]>("sync_get_pending", {
|
||||
userId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +152,8 @@ class SyncService {
|
||||
|
||||
/**
|
||||
* Clear all sync operations for the current user (called during logout)
|
||||
*
|
||||
* TRACES: UR-017 | DR-014
|
||||
*/
|
||||
async clearUser(): Promise<void> {
|
||||
const userId = auth.getUserId();
|
||||
|
||||
Reference in New Issue
Block a user