// Image cache service - Handles lazy caching of thumbnails with LRU eviction // TRACES: UR-007 | DR-016 import { convertFileSrc } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("ImageCache"); /** * Statistics about the thumbnail cache */ export interface ImageCacheStats { totalSizeBytes: number; itemCount: number; limitBytes: number; } /** * Get an image URL, checking cache first then falling back to server. * Triggers background caching if not cached. * * @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 (maxWidth, maxHeight, quality, tag) * @returns The image URL (local asset URL if cached, server URL otherwise) */ export async function getCachedImageUrl( serverUrl: string, itemId: string, imageType: string = "Primary", options: { maxWidth?: number; maxHeight?: number; quality?: number; tag?: string; } = {} ): Promise { const tag = options.tag || "default"; // Try to get cached version try { const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag); if (cachedPath) { // Convert file path to asset URL for Tauri. This is the only remaining // convertFileSrc caller, which is why the asset-protocol scope is narrowed // to $APPDATA/thumbnails/** — a path outside it resolves to nothing. // TRACES: UR-012 | DR-134, DR-198 return convertFileSrc(cachedPath); } } catch (e) { log.debug("Failed to check thumbnail cache:", e); } // 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) commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => { // Silently fail - caching is best-effort log.debug("Background thumbnail cache failed:", e); }); // Return server URL for immediate display return serverImageUrl; } /** * Get thumbnail cache statistics */ export async function getCacheStats(): Promise { return commands.thumbnailGetStats(); } /** * Set cache storage limit in bytes * * @param limitBytes - The maximum cache size in bytes (0 = unlimited) */ export async function setCacheLimit(limitBytes: number): Promise { await commands.thumbnailSetLimit(limitBytes); } /** * Clear all cached thumbnails */ export async function clearCache(): Promise { await commands.thumbnailClearCache(); } /** * Delete cached thumbnails for a specific item * * @param itemId - The Jellyfin item ID */ export async function deleteItemCache(itemId: string): Promise { await commands.thumbnailDeleteItem(itemId); } /** * Format bytes to human-readable string * * @param bytes - Number of bytes * @returns Human-readable string (e.g., "1.5 GB") */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } /** * Convert gigabytes to bytes */ export function gbToBytes(gb: number): number { return gb * 1024 * 1024 * 1024; } /** * Convert bytes to gigabytes */ export function bytesToGb(bytes: number): number { return bytes / (1024 * 1024 * 1024); }