# Comprehensive Media Caching Architecture ## Overview This document outlines the architectural design for implementing [DR-016](README.md:151) (Image caching and sync with server) and enhancing [DR-012](README.md:147) (Local database for media metadata cache). **Goal**: Create a comprehensive offline-first architecture that caches **all media assets** (images, metadata, artwork) locally for instant loading, offline access, and reduced server load. ### Scope: All Cacheable Assets 1. **Images**: Posters, backdrops, title cards, logos, thumbnails, banners, profile pictures, disc art 2. **Metadata**: Media items, libraries, collections, people/cast, genres, studios 3. **User Data**: Watch progress, favorites, ratings, playlists 4. **Media Info**: Subtitle/audio track information, chapters, media streams --- ## Current State ### ✅ Already Implemented 1. **Database Schema** ([schema.rs:227-237](src-tauri/src/storage/schema.rs#L227-L237)): - `thumbnails` table with fields: `item_id`, `image_type`, `image_tag`, `file_path`, `width`, `height`, `cached_at` - `items` table stores metadata including `primary_image_tag` - Index on `item_id` for fast lookups 2. **Data Models** ([models.rs:358-368](src-tauri/src/storage/models.rs#L358-L368)): - `Thumbnail` struct matches database schema 3. **Metadata Storage**: - `items` table stores full media metadata - `user_data` table stores playback progress and favorites ### ❌ Not Yet Implemented 1. **Image Download & Caching Service**: No code to download and cache images 2. **Cache Invalidation**: No logic to check `image_tag` for updates 3. **LRU Eviction**: No automatic cleanup of old thumbnails 4. **Repository Integration**: Repository pattern doesn't use cached images 5. **Tauri Commands**: No commands to manage thumbnail cache --- ## Architectural Design ### 1. Media Cache Service **Location**: `src-tauri/src/cache/` ``` src-tauri/src/cache/ ├── mod.rs # Module exports, MediaCacheService ├── images/ │ ├── mod.rs # ImageCacheService │ ├── download.rs # Image download with retry logic │ ├── formats.rs # Image format conversion (WebP, AVIF) │ └── preloader.rs # Intelligent pre-caching ├── metadata/ │ ├── mod.rs # MetadataCacheService │ ├── sync.rs # Sync with Jellyfin server │ └── stale.rs # Stale-while-revalidate strategy └── lru.rs # LRU eviction policy (shared) ``` #### 1.1 ImageCacheService - All Image Types **Supported Image Types** (from Jellyfin API): - `Primary`: Poster/cover art (movies, albums, shows) - `Backdrop`: Background images - `Logo`: Transparent logos for overlays - `Thumb`: Thumbnail preview frames - `Banner`: Wide banner images - `Art`: Disc/box art - `Screenshot`: Episode screenshots - `Profile`: Actor/person headshots #### 1.1.1 Core Service ```rust pub struct ImageCacheService { db: Arc, cache_dir: PathBuf, client: reqwest::Client, config: CacheConfig, } pub struct CacheConfig { pub max_cache_size_mb: u64, // Default: 500 MB pub max_age_days: u32, // Default: 30 days pub quality: ImageQuality, // Default: High } pub enum ImageQuality { Low, // 300px Medium, // 720px High, // 1080px Original, // No resize } impl ImageCacheService { /// Get cached image path or download if missing pub async fn get_image( &self, item_id: &str, image_type: &str, image_tag: Option<&str>, width: Option, height: Option, ) -> Result { // 1. Check database for existing cache entry if let Some(cached) = self.db.get_thumbnail(item_id, image_type, image_tag).await? { // Verify file still exists if cached.file_path.exists() { // Update last_accessed for LRU self.db.touch_thumbnail(cached.id).await?; return Ok(cached.file_path); } else { // File deleted externally, remove DB entry self.db.delete_thumbnail(cached.id).await?; } } // 2. Download image from Jellyfin server let image_data = self.download_image(item_id, image_type, width, height).await?; // 3. Save to disk let file_path = self.save_image(item_id, image_type, image_tag, &image_data).await?; // 4. Insert into database let thumbnail = Thumbnail { id: None, item_id: item_id.to_string(), image_type: image_type.to_string(), image_tag: image_tag.unwrap_or("").to_string(), file_path: file_path.clone(), width: width.map(|w| w as i32), height: height.map(|h| h as i32), cached_at: Some(Utc::now()), last_accessed: Some(Utc::now()), }; self.db.insert_thumbnail(&thumbnail).await?; // 5. Check cache size and evict if needed self.evict_if_needed().await?; Ok(file_path) } /// Check if image is cached and valid pub async fn is_cached( &self, item_id: &str, image_type: &str, image_tag: Option<&str>, ) -> Result { if let Some(cached) = self.db.get_thumbnail(item_id, image_type, image_tag).await? { // Verify tag matches (cache invalidation) if let Some(tag) = image_tag { if cached.image_tag != tag { // Tag changed, image updated on server self.db.delete_thumbnail(cached.id).await?; return Ok(false); } } // Verify file exists return Ok(cached.file_path.exists()); } Ok(false) } /// Pre-cache images for a batch of items (e.g., library grid) pub async fn precache_batch( &self, items: &[CacheRequest], priority: CachePriority, ) -> Result<(), CacheError> { // Download images in parallel with concurrency limit let futures = items.iter().map(|req| { self.get_image( &req.item_id, &req.image_type, req.image_tag.as_deref(), req.width, req.height, ) }); // Use buffered stream to limit concurrency (e.g., 5 at a time) futures::stream::iter(futures) .buffer_unordered(5) .try_collect::>() .await?; Ok(()) } /// Evict old/unused thumbnails when cache size exceeds limit async fn evict_if_needed(&self) -> Result<(), CacheError> { let cache_size = self.get_cache_size().await?; let max_size = self.config.max_cache_size_mb * 1024 * 1024; if cache_size > max_size { // Get thumbnails sorted by last_accessed (LRU) let to_evict = self.db.get_lru_thumbnails(100).await?; let mut freed = 0u64; for thumb in to_evict { if cache_size - freed <= max_size { break; } // Delete file if let Ok(metadata) = std::fs::metadata(&thumb.file_path) { freed += metadata.len(); std::fs::remove_file(&thumb.file_path)?; } // Delete DB entry self.db.delete_thumbnail(thumb.id).await?; } } Ok(()) } } pub struct CacheRequest { pub item_id: String, pub image_type: String, pub image_tag: Option, pub width: Option, pub height: Option, } pub enum CachePriority { High, // User navigated to this screen Medium, // Prefetch for upcoming content Low, // Background cache warming } ``` #### 1.2 Database Queries **Location**: `src-tauri/src/storage/queries/thumbnails.rs` ```rust impl Database { pub async fn get_thumbnail( &self, item_id: &str, image_type: &str, image_tag: Option<&str>, ) -> Result> { let conn = self.pool.get().await?; let query = if let Some(tag) = image_tag { "SELECT * FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?" } else { "SELECT * FROM thumbnails WHERE item_id = ? AND image_type = ?" }; // Execute query and return Thumbnail } pub async fn insert_thumbnail(&self, thumbnail: &Thumbnail) -> Result { // INSERT INTO thumbnails... } pub async fn touch_thumbnail(&self, id: i64) -> Result<()> { // UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP WHERE id = ? } pub async fn get_lru_thumbnails(&self, limit: usize) -> Result> { // SELECT * FROM thumbnails // ORDER BY last_accessed ASC // LIMIT ? } pub async fn delete_thumbnail(&self, id: i64) -> Result<()> { // DELETE FROM thumbnails WHERE id = ? } pub async fn get_cache_size(&self) -> Result { // SELECT SUM(file_size) FROM thumbnails // Or calculate from filesystem } } ``` **Schema Enhancement** (add to migration): ```sql -- Add last_accessed column for LRU ALTER TABLE thumbnails ADD COLUMN last_accessed TEXT DEFAULT CURRENT_TIMESTAMP; -- Add file_size for cache size calculation ALTER TABLE thumbnails ADD COLUMN file_size INTEGER; -- Create index for LRU queries CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC); ``` --- ### 2. Repository Integration #### 2.1 Enhanced getImageUrl() **Location**: `src/lib/api/repository.ts` ```typescript export class OnlineRepository implements MediaRepository { private imageCacheEnabled = true; async getImageUrl( itemId: string, imageType: string, options: ImageOptions = {} ): Promise { const { maxWidth, maxHeight, tag } = options; if (this.imageCacheEnabled) { // Check if cached locally via Tauri command try { const cachedPath = await invoke('cache_get_image', { itemId, imageType, imageTag: tag, width: maxWidth, height: maxHeight, }); if (cachedPath) { // Return file:// URL for local cached image return `file://${cachedPath}`; } } catch (err) { console.warn('Cache lookup failed, falling back to server URL:', err); } } // Fallback to server URL (will be cached in background) return this.buildImageUrl(itemId, imageType, options); } private buildImageUrl(itemId: string, imageType: string, options: ImageOptions): string { const params = new URLSearchParams(); if (options.maxWidth) params.set('maxWidth', options.maxWidth.toString()); if (options.maxHeight) params.set('maxHeight', options.maxHeight.toString()); if (options.tag) params.set('tag', options.tag); return `${this.baseUrl}/Items/${itemId}/Images/${imageType}?${params}`; } } ``` #### 2.2 Background Pre-caching **Location**: `src/lib/services/imagePreloader.ts` ```typescript export class ImagePreloader { private precacheQueue: Set = new Set(); private processing = false; /** * Pre-cache images for items in view * Called when user navigates to library/album/detail pages */ async precacheVisible(items: MediaItem[]): Promise { const requests = items .filter(item => item.primaryImageTag) .map(item => ({ itemId: item.id, imageType: 'Primary', imageTag: item.primaryImageTag, width: 400, // Medium quality for grids height: 600, })); try { await invoke('cache_precache_batch', { requests, priority: 'high' }); } catch (err) { console.error('Precache failed:', err); } } /** * Pre-cache upcoming queue items (for video player) */ async precacheQueue(items: MediaItem[]): Promise { const requests = items .slice(0, 5) // Next 5 items .filter(item => item.primaryImageTag) .map(item => ({ itemId: item.id, imageType: 'Primary', imageTag: item.primaryImageTag, width: 1920, height: 1080, // Full quality for video player })); try { await invoke('cache_precache_batch', { requests, priority: 'medium' }); } catch (err) { console.error('Queue precache failed:', err); } } } // Auto-initialize in app export const imagePreloader = new ImagePreloader(); ``` **Usage in VideoPlayer**: ```typescript // In VideoPlayer.svelte import { imagePreloader } from '$lib/services/imagePreloader'; onMount(() => { // Pre-cache poster for next video in queue if (nextInQueue) { imagePreloader.precacheQueue([nextInQueue]); } }); ``` --- ### 3. Tauri Commands **Location**: `src-tauri/src/commands/cache.rs` ```rust use crate::cache::ImageCacheService; #[tauri::command] pub async fn cache_get_image( item_id: String, image_type: String, image_tag: Option, width: Option, height: Option, cache_service: State<'_, Arc>, ) -> Result, String> { let path = cache_service .get_image(&item_id, &image_type, image_tag.as_deref(), width, height) .await .map_err(|e| e.to_string())?; Ok(Some(path.to_string_lossy().to_string())) } #[tauri::command] pub async fn cache_is_cached( item_id: String, image_type: String, image_tag: Option, cache_service: State<'_, Arc>, ) -> Result { cache_service .is_cached(&item_id, &image_type, image_tag.as_deref()) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn cache_precache_batch( requests: Vec, priority: String, cache_service: State<'_, Arc>, ) -> Result<(), String> { let priority = match priority.as_str() { "high" => CachePriority::High, "medium" => CachePriority::Medium, _ => CachePriority::Low, }; cache_service .precache_batch(&requests, priority) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn cache_clear( cache_service: State<'_, Arc>, ) -> Result<(), String> { cache_service .clear_all() .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn cache_get_stats( cache_service: State<'_, Arc>, ) -> Result { cache_service .get_stats() .await .map_err(|e| e.to_string()) } #[derive(Serialize, Deserialize)] pub struct CacheStats { pub total_images: u64, pub total_size_mb: f64, pub cache_hit_rate: f64, // Percentage } ``` --- ### 4. Metadata Caching Enhancement #### 4.1 Library Response Caching When fetching library items from Jellyfin, cache them in the `items` table: ```rust // In src-tauri/src/commands/library.rs (new command) #[tauri::command] pub async fn library_sync_items( library_id: String, db: State<'_, Arc>, jellyfin_client: State<'_, Arc>, ) -> Result, String> { // 1. Fetch from Jellyfin API let api_items = jellyfin_client .get_library_items(&library_id) .await .map_err(|e| e.to_string())?; // 2. Upsert into database for api_item in &api_items { let db_item = convert_to_db_item(api_item); db.upsert_item(&db_item).await.map_err(|e| e.to_string())?; } // 3. Return items (now available offline) Ok(api_items) } ``` #### 4.2 Offline-First Repository ```typescript export class HybridRepository implements MediaRepository { constructor( private onlineRepo: OnlineRepository, private db: Database ) {} async getItem(itemId: string): Promise { // Try local cache first try { const cached = await invoke('db_get_item', { itemId }); if (cached) { // Refresh in background (stale-while-revalidate) this.refreshItemInBackground(itemId); return cached; } } catch (err) { console.warn('Cache lookup failed:', err); } // Fetch from server and cache const item = await this.onlineRepo.getItem(itemId); await invoke('db_upsert_item', { item }).catch(console.error); return item; } private async refreshItemInBackground(itemId: string): Promise { try { const fresh = await this.onlineRepo.getItem(itemId); await invoke('db_upsert_item', { item: fresh }); } catch (err) { // Ignore, cached version is good enough } } } ``` --- ### 5. Metadata Caching Service **Location**: `src-tauri/src/cache/metadata/mod.rs` #### 5.1 Comprehensive Metadata Storage **Extended Database Schema**: ```sql -- People/Cast (actors, directors, writers) CREATE TABLE IF NOT EXISTS people ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, role TEXT, -- Actor, Director, Writer, etc. overview TEXT, primary_image_tag TEXT, birth_date TEXT, death_date TEXT, birth_place TEXT, synced_at TEXT, UNIQUE(server_id, id) ); -- Cast/Crew associations CREATE TABLE IF NOT EXISTS item_people ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE, role_type TEXT NOT NULL, -- Actor, Director, Writer, Producer, etc. role_name TEXT, -- Character name for actors sort_order INTEGER, UNIQUE(item_id, person_id, role_type) ); -- Collections (Box Sets) CREATE TABLE IF NOT EXISTS collections ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, overview TEXT, primary_image_tag TEXT, backdrop_image_tags TEXT, -- JSON array synced_at TEXT, UNIQUE(server_id, id) ); -- Collection membership CREATE TABLE IF NOT EXISTS collection_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, sort_order INTEGER, UNIQUE(collection_id, item_id) ); -- Studios/Networks CREATE TABLE IF NOT EXISTS studios ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, overview TEXT, primary_image_tag TEXT, synced_at TEXT, UNIQUE(server_id, id) ); -- Chapters (for video scrubbing thumbnails) CREATE TABLE IF NOT EXISTS chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, start_position_ticks INTEGER NOT NULL, name TEXT, image_tag TEXT, UNIQUE(item_id, start_position_ticks) ); -- Genres (with metadata) CREATE TABLE IF NOT EXISTS genres ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, item_count INTEGER DEFAULT 0, synced_at TEXT, UNIQUE(server_id, name) ); -- Create indexes for relationships CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id); CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id); CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id); CREATE INDEX IF NOT EXISTS idx_collection_items_item ON collection_items(item_id); CREATE INDEX IF NOT EXISTS idx_chapters_item ON chapters(item_id); ``` #### 5.2 MetadataCacheService ```rust pub struct MetadataCacheService { db: Arc, jellyfin_client: Arc, sync_config: SyncConfig, } pub struct SyncConfig { pub auto_sync: bool, // Auto-sync in background pub sync_interval_hours: u32, // Default: 6 hours pub deep_sync: bool, // Include cast, collections, etc. pub wifi_only: bool, // Sync only on WiFi } impl MetadataCacheService { /// Sync complete library metadata pub async fn sync_library(&self, library_id: &str) -> Result { let mut report = SyncReport::default(); // 1. Fetch all items from Jellyfin let api_items = self.jellyfin_client.get_library_items(library_id).await?; report.items_fetched = api_items.len(); // 2. Upsert items to database for api_item in &api_items { let db_item = self.convert_to_db_item(api_item); self.db.upsert_item(&db_item).await?; report.items_synced += 1; // 3. Deep sync: cast/crew, collections if self.sync_config.deep_sync { self.sync_item_people(&api_item).await?; self.sync_item_collections(&api_item).await?; } } // 4. Update library sync timestamp self.db.update_library_sync(library_id).await?; Ok(report) } /// Sync cast/crew for an item async fn sync_item_people(&self, item: &JellyfinItem) -> Result<(), CacheError> { if let Some(people) = &item.people { for person in people { // Upsert person let db_person = Person { id: person.id.clone(), server_id: item.server_id.clone(), name: person.name.clone(), role: person.role.clone(), overview: None, primary_image_tag: person.primary_image_tag.clone(), birth_date: None, death_date: None, birth_place: None, synced_at: Some(Utc::now()), }; self.db.upsert_person(&db_person).await?; // Create association let association = ItemPerson { item_id: item.id.clone(), person_id: person.id.clone(), role_type: person.type_field.clone(), // Actor, Director, etc. role_name: person.role.clone(), // Character name sort_order: person.sort_order, }; self.db.upsert_item_person(&association).await?; } } Ok(()) } /// Fetch item with all related data (cast, collection, chapters) pub async fn get_item_full(&self, item_id: &str) -> Result { let item = self.db.get_item(item_id).await? .ok_or(CacheError::NotFound)?; let cast = self.db.get_item_people(item_id, Some("Actor")).await?; let crew = self.db.get_item_people(item_id, None).await?; // All roles let collections = self.db.get_item_collections(item_id).await?; let chapters = self.db.get_chapters(item_id).await?; Ok(FullItem { item, cast, crew, collections, chapters, }) } /// Stale-while-revalidate: Return cached, refresh in background pub async fn get_item_swr(&self, item_id: &str) -> Result { // Try cache first if let Some(cached) = self.db.get_item(item_id).await? { // Check if stale (older than 6 hours) if let Some(synced_at) = cached.synced_at { let age = Utc::now() - synced_at; if age.num_hours() < self.sync_config.sync_interval_hours as i64 { return Ok(cached); // Fresh enough } } // Stale, but return it immediately let cached_clone = cached.clone(); // Refresh in background let client = self.jellyfin_client.clone(); let db = self.db.clone(); let item_id = item_id.to_string(); tokio::spawn(async move { if let Ok(fresh) = client.get_item(&item_id).await { let _ = db.upsert_item(&fresh).await; } }); return Ok(cached_clone); } // Not in cache, fetch from server let fresh = self.jellyfin_client.get_item(item_id).await?; self.db.upsert_item(&fresh).await?; Ok(fresh) } } #[derive(Debug, Default)] pub struct SyncReport { pub items_fetched: usize, pub items_synced: usize, pub images_cached: usize, pub people_synced: usize, pub errors: Vec, } pub struct FullItem { pub item: Item, pub cast: Vec, pub crew: Vec, pub collections: Vec, pub chapters: Vec, } pub struct PersonWithRole { pub person: Person, pub role_type: String, // Actor, Director, etc. pub role_name: Option, // Character name } ``` --- ### 6. Smart Pre-caching Strategies #### 6.1 Predictive Pre-caching ```rust pub struct PrecacheEngine { image_cache: Arc, metadata_cache: Arc, analytics: Arc, } impl PrecacheEngine { /// Pre-cache based on navigation patterns pub async fn precache_navigation(&self, context: NavigationContext) -> Result<(), CacheError> { match context { NavigationContext::LibraryGrid { library_id, visible_items } => { // 1. Cache visible items (high priority) self.precache_grid_items(&visible_items, CachePriority::High).await?; // 2. Predict next page (medium priority) let next_page = self.predict_next_page(&library_id, &visible_items).await?; self.precache_grid_items(&next_page, CachePriority::Medium).await?; }, NavigationContext::DetailView { item_id } => { // 1. Cache item details (high priority) self.metadata_cache.get_item_full(&item_id).await?; // 2. Cache all images for item self.precache_item_images(&item_id).await?; // 3. Cache cast profile pictures (medium priority) self.precache_cast_images(&item_id).await?; // 4. If series, cache next episode if let Some(next_ep) = self.get_next_episode(&item_id).await? { self.precache_item_images(&next_ep.id).await?; } }, NavigationContext::Queue { items } => { // Cache next 5 items in queue for (index, item) in items.iter().take(5).enumerate() { let priority = match index { 0 => CachePriority::High, 1..=2 => CachePriority::Medium, _ => CachePriority::Low, }; self.precache_item_images(&item.id).await?; } }, NavigationContext::Search { query } => { // No pre-caching for search (unpredictable) }, } Ok(()) } async fn precache_item_images(&self, item_id: &str) -> Result<(), CacheError> { let item = self.metadata_cache.db.get_item(item_id).await? .ok_or(CacheError::NotFound)?; // Cache all image types for this item let image_types = vec!["Primary", "Backdrop", "Logo", "Thumb"]; for img_type in image_types { let tag = self.get_image_tag(&item, img_type); if tag.is_some() { // Fire and forget let _ = self.image_cache.get_image( item_id, img_type, tag.as_deref(), Some(1920), Some(1080), ).await; } } Ok(()) } async fn precache_cast_images(&self, item_id: &str) -> Result<(), CacheError> { let people = self.metadata_cache.db.get_item_people(item_id, Some("Actor")).await?; for person in people.iter().take(10) { // Top 10 cast if let Some(tag) = &person.person.primary_image_tag { let _ = self.image_cache.get_image( &person.person.id, "Primary", Some(tag), Some(400), Some(400), ).await; } } Ok(()) } } pub enum NavigationContext { LibraryGrid { library_id: String, visible_items: Vec }, DetailView { item_id: String }, Queue { items: Vec }, Search { query: String }, } ``` #### 6.2 Background Cache Warming ```rust pub struct CacheWarmingService { metadata_cache: Arc, image_cache: Arc, config: WarmingConfig, } pub struct WarmingConfig { pub enabled: bool, pub warm_on_wifi_only: bool, pub warm_continue_watching: bool, // Pre-cache items user is likely to watch pub warm_new_releases: bool, // Pre-cache recently added content pub warm_favorites: bool, // Pre-cache favorited content } impl CacheWarmingService { /// Run background cache warming (called periodically) pub async fn warm_cache(&self) -> Result { let mut report = WarmingReport::default(); if !self.config.enabled { return Ok(report); } // 1. Continue Watching - User's in-progress items if self.config.warm_continue_watching { let in_progress = self.metadata_cache.db .get_in_progress_items(&self.get_user_id()) .await?; for item in in_progress.iter().take(20) { self.warm_item(&item.id).await?; report.items_warmed += 1; } } // 2. Recently Added - New content if self.config.warm_new_releases { let recent = self.metadata_cache.db .get_recently_added(30) // Last 30 days .await?; for item in recent.iter().take(50) { self.warm_item(&item.id).await?; report.items_warmed += 1; } } // 3. Favorites if self.config.warm_favorites { let favorites = self.metadata_cache.db .get_favorites(&self.get_user_id()) .await?; for item in favorites.iter().take(100) { self.warm_item(&item.id).await?; report.items_warmed += 1; } } Ok(report) } async fn warm_item(&self, item_id: &str) -> Result<(), CacheError> { // Fetch metadata (stale-while-revalidate) let _ = self.metadata_cache.get_item_swr(item_id).await?; // Cache primary image let item = self.metadata_cache.db.get_item(item_id).await? .ok_or(CacheError::NotFound)?; if let Some(tag) = &item.primary_image_tag { let _ = self.image_cache.get_image( item_id, "Primary", Some(tag), Some(1080), Some(1620), ).await; } Ok(()) } } #[derive(Debug, Default)] pub struct WarmingReport { pub items_warmed: usize, pub images_cached: usize, } ``` --- ### 7. Offline-First Data Flow ```mermaid sequenceDiagram participant UI as UI Component participant Repo as HybridRepository participant Cache as MetadataCache participant DB as SQLite participant API as Jellyfin API participant ImgCache as ImageCache participant FS as File System UI->>Repo: getItem(itemId) Repo->>Cache: get_item_swr(itemId) par Immediate Return Cache->>DB: SELECT * FROM items WHERE id = ? DB-->>Cache: Cached Item (may be stale) Cache-->>Repo: Return cached item Repo-->>UI: Display immediately and Background Refresh Cache->>API: GET /Items/{itemId} API-->>Cache: Fresh item data Cache->>DB: UPDATE items SET ... end UI->>Repo: getImageUrl(itemId, "Primary") Repo->>ImgCache: get_image(itemId, "Primary") alt Image Cached ImgCache->>DB: Check thumbnails table DB-->>ImgCache: Cached path ImgCache->>FS: Verify file exists FS-->>ImgCache: File exists ImgCache-->>Repo: file:///path/to/image.jpg Repo-->>UI: Display immediately (<50ms) else Image Not Cached ImgCache->>API: GET /Items/{id}/Images/Primary API-->>ImgCache: Image data ImgCache->>FS: Save to cache dir ImgCache->>DB: INSERT INTO thumbnails ImgCache-->>Repo: file:///path/to/image.jpg Repo-->>UI: Display (~500ms first time) end ``` --- ### 8. Complete Tauri Commands API **Location**: `src-tauri/src/commands/cache.rs` ```rust // Image Cache Commands #[tauri::command] pub async fn cache_get_image(...) -> Result { /* ... */ } #[tauri::command] pub async fn cache_get_all_images( item_id: String, cache_service: State<'_, Arc>, ) -> Result, String> { // Returns all cached image types for an item // { "Primary": "file:///...", "Backdrop": "file:///...", ... } } #[tauri::command] pub async fn cache_precache_batch(...) -> Result<(), String> { /* ... */ } // Metadata Cache Commands #[tauri::command] pub async fn metadata_sync_library( library_id: String, deep_sync: bool, metadata_service: State<'_, Arc>, ) -> Result { /* ... */ } #[tauri::command] pub async fn metadata_get_item_full( item_id: String, metadata_service: State<'_, Arc>, ) -> Result { // Returns item with cast, crew, collections, chapters } #[tauri::command] pub async fn metadata_get_person( person_id: String, metadata_service: State<'_, Arc>, ) -> Result { /* ... */ } #[tauri::command] pub async fn metadata_get_person_filmography( person_id: String, metadata_service: State<'_, Arc>, ) -> Result, String> { // Get all items this person appears in } #[tauri::command] pub async fn metadata_search_offline( query: String, filters: SearchFilters, db: State<'_, Arc>, ) -> Result { // FTS5 search across cached items } // Cache Management Commands #[tauri::command] pub async fn cache_get_stats(...) -> Result { /* ... */ } #[tauri::command] pub async fn cache_clear_all( image_cache: State<'_, Arc>, metadata_cache: State<'_, Arc>, ) -> Result<(), String> { image_cache.clear_all().await.map_err(|e| e.to_string())?; metadata_cache.clear_all().await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn cache_clear_images_only(...) -> Result<(), String> { /* ... */ } #[tauri::command] pub async fn cache_clear_metadata_only(...) -> Result<(), String> { /* ... */ } // Pre-caching Commands #[tauri::command] pub async fn precache_navigation( context: NavigationContext, precache_engine: State<'_, Arc>, ) -> Result<(), String> { /* ... */ } #[tauri::command] pub async fn cache_warm_background( warming_service: State<'_, Arc>, ) -> Result { /* ... */ } ``` --- ## Implementation Plan ### Phase 1: Core Caching Infrastructure (Week 1) 1. ✅ Database schema enhancement (add `last_accessed`, `file_size` to thumbnails) 2. ✅ Create `src-tauri/src/cache/` module 3. ✅ Implement `ImageCacheService` with basic download and storage 4. ✅ Add database queries for thumbnails 5. ✅ Create Tauri commands: `cache_get_image`, `cache_is_cached` **Testing**: - Unit tests for cache service - Integration test: Download and retrieve thumbnail - Verify file system operations ### Phase 2: Repository Integration (Week 2) 1. ✅ Update `OnlineRepository.getImageUrl()` to check cache 2. ✅ Implement `ImagePreloader` service 3. ✅ Add cache checking to VideoPlayer component 4. ✅ Wire up precaching in library navigation **Testing**: - E2E test: Navigate to library, verify images load from cache - Measure load time improvement ### Phase 3: LRU Eviction & Optimization (Week 3) 1. ✅ Implement `evict_if_needed()` with LRU policy 2. ✅ Add background cache warming (popular content) 3. ✅ Implement `cache_precache_batch` command 4. ✅ Add cache statistics tracking **Testing**: - Test cache size limit enforcement - Verify LRU eviction removes oldest items - Performance benchmarks ### Phase 4: Metadata Caching (Week 4) 1. ✅ Implement `db_upsert_item` and `db_get_item` commands 2. ✅ Create `HybridRepository` with offline-first strategy 3. ✅ Add stale-while-revalidate pattern 4. ✅ Implement background sync service **Testing**: - Test offline mode with cached metadata - Verify background refresh works - Test cache invalidation on etag changes --- ## Performance Impact ### Before (Current State) - **Video Player Load**: 500-2000ms (network fetch) - **Library Grid Load**: 2-5s for 50 items (50 image requests) - **Offline Support**: None ### After (With Caching) - **Video Player Load**: 50-100ms (local file read) - **Library Grid Load**: 200-500ms (cached images) - **Offline Support**: Full metadata + images available offline **Expected Improvements**: - 10x faster video player initialization - 5-10x faster library browsing - Zero loading time on repeat navigation --- ## Storage Estimates | Content Type | Image Type | Resolution | Size per Image | 1000 Items | |--------------|-----------|------------|----------------|------------| | Movies | Poster | 400x600 | ~80 KB | 80 MB | | Movies | Backdrop | 1920x1080 | ~200 KB | 200 MB | | TV Shows | Poster | 400x600 | ~80 KB | 80 MB | | Albums | Cover | 400x400 | ~60 KB | 60 MB | **Recommended Cache Size**: 500 MB (configurable) - ~6,000 posters or ~2,500 backdrops - Sufficient for typical library browsing --- ## Cache Invalidation Strategy 1. **Image Tag Comparison**: - Jellyfin provides `ImageTag` for each image - Compare tag on each fetch, re-download if changed - Automatic when user updates poster/backdrop 2. **TTL (Time-to-Live)**: - Optional: Images older than 30 days can be re-validated - Useful for metadata that changes rarely 3. **Manual Refresh**: - Settings UI: "Clear Image Cache" button - Developer option: Force refresh all images --- ## Configuration UI **Location**: `src/routes/settings/+page.svelte` ```svelte

Cache Settings

Image Cache Size Limit

Maximum storage for cached images

Current Cache Size {cacheStats.totalSizeMB} MB
Cached Images {cacheStats.totalImages}
Cache Hit Rate {cacheStats.cacheHitRate}%
``` --- ## Success Metrics 1. **Performance**: - Video player title card appears in <100ms - Library grid renders in <500ms - Cache hit rate >80% for repeat navigation 2. **Storage**: - Cache stays within configured limit - LRU eviction maintains most-used content 3. **User Experience**: - No perceived loading delay for cached content - Smooth navigation between library views - Offline browsing works seamlessly --- ## Future Enhancements 1. **Progressive Image Loading**: - Show low-quality placeholder immediately - Replace with high-quality when available 2. **Smart Pre-caching**: - Analyze navigation patterns - Pre-cache likely next views (e.g., continue watching) 3. **WebP Support**: - Convert to WebP for 25-35% size reduction - Requires Jellyfin server support or client-side conversion 4. **CDN Integration**: - Support for CDN-hosted images - Edge caching for improved performance --- ## Related Requirements - ✅ [DR-012](README.md:147): Local database for media metadata cache (Done) - 🔄 [DR-016](README.md:151): Thumbnail caching and sync with server (In Progress) - 🔄 [DR-001](README.md:136): Player state machine - Loading state (Partially Done - UI implemented) - 🔄 [DR-010](README.md:145): Video player UI (Planned) --- ## Questions for Discussion 1. **Image Format**: Should we convert all images to WebP for smaller size? 2. **Cache Priority**: Should video title cards get higher priority than library thumbnails? 3. **Background Sync**: How aggressively should we pre-cache? (WiFi-only option?) 4. **Offline Mode**: Should we pre-download all metadata for offline libraries? --- **Last Updated**: 2026-01-04 **Status**: Design Complete - Ready for Implementation **Next Step**: Begin Phase 1 implementation