From c959c07ab4cf1fb29d670f61e6072b512728b795 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 20 Jun 2026 15:28:15 +0200 Subject: [PATCH] Fixes for tests --- src-tauri/src/player/mod.rs | 8 +- src-tauri/src/repository/hybrid.rs | 149 +++++++++--------- src-tauri/src/repository/offline.rs | 4 +- src-tauri/src/repository/online.rs | 2 +- src/lib/components/common/CachedImage.svelte | 11 +- .../library/GenericMediaListPage.svelte | 5 +- src/lib/components/library/LibraryGrid.svelte | 5 +- src/lib/components/library/MediaCard.svelte | 9 +- .../library/RelatedItemsSection.svelte | 3 +- src/lib/components/player/VideoPlayer.svelte | 41 +++-- src/lib/stores/home.ts | 3 +- src/routes/+page.svelte | 4 +- src/routes/library/[id]/+page.svelte | 29 +++- 13 files changed, 163 insertions(+), 110 deletions(-) diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 42f8690..6ea5a34 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -987,14 +987,8 @@ impl PlayerController { } } - // Countdown finished - emit final tick at 0 + // Countdown finished (final tick at 0 was already emitted inside the loop) log::info!("[PlayerController] Autoplay countdown finished"); - if let Some(emitter) = event_emitter.lock().unwrap().as_ref() { - emitter.emit(PlayerStatusEvent::CountdownTick { - remaining_seconds: 0, - }); - } - // The frontend can listen for countdown reaching 0 and trigger playback }); } } diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index bffaea7..159c2f3 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -55,14 +55,12 @@ impl HybridRepository { self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await } - /// Race cache vs server, return first valid result - /// Prefer cache if it has meaningful content, otherwise use server + /// Cache-first query: try cache, fall back to server on miss. /// - /// Core algorithm of the cache-first parallel racing strategy. - /// Runs both cache and server queries concurrently, then: - /// 1. If cache has meaningful content → return cache (fast path) - /// 2. If cache is empty/stale → return server (fresh data) - /// 3. If server fails → return cache even if empty (offline fallback) + /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout) + /// 2. If cache has meaningful content → return immediately (fast path) + /// 3. If cache is empty/stale → query server (fresh data) + /// 4. If server fails → return cache even if empty (offline fallback) /// /// @req: UR-002 - Access media when online or offline /// @req: DR-013 - Repository pattern for online/offline data access @@ -76,24 +74,20 @@ impl HybridRepository { F1: std::future::Future> + Send, F2: std::future::Future> + Send, { - // Wait for both to complete (cache has 100ms timeout) - let (cache_result, server_result) = tokio::join!(cache_future, server_future); + // Try cache first (100ms timeout already applied by callers) + let cache_result = cache_future.await; - // Prefer cache if it has meaningful content if let Ok(data) = &cache_result { if data.has_content() { - debug!("[HybridRepo] Using cache result (has content)"); + debug!("[HybridRepo] Cache hit, returning immediately"); return Ok(data.clone()); } } - // Fall back to server result - match server_result { - Ok(data) => { - debug!("[HybridRepo] Using server result"); - // TODO: Spawn background cache update - Ok(data) - } + // Cache miss — fall back to server + debug!("[HybridRepo] Cache miss, querying server"); + match server_future.await { + Ok(data) => Ok(data), Err(e) => { // Server failed, try to return cache even if empty cache_result.or(Err(e)) @@ -135,46 +129,57 @@ impl MediaRepository for HybridRepository { let parent_id_for_save = parent_id.clone(); let opts_clone = options.clone(); - // Check cache first to see if we have data - let cache_future = self.cache_with_timeout(async move { - offline.get_items(&parent_id, opts_clone).await + // Start server request in background (non-blocking) + let server_handle = tokio::spawn(async move { + online.get_items(&parent_id_clone, options).await }); - let server_future = async move { - online.get_items(&parent_id_clone, options).await - }; + // Check cache first (fast, 100ms timeout) + let cache_result = self.cache_with_timeout(async move { + offline.get_items(&parent_id, opts_clone).await + }).await; - // Wait for both, prefer cache if available - let (cache_result, server_result) = tokio::join!(cache_future, server_future); - - // Check if cache had meaningful content - let cache_had_content = cache_result.as_ref() - .map(|data| data.has_content()) - .unwrap_or(false); - - // Prefer cache if it has content - let result = if cache_had_content { - debug!("[HybridRepo] Using cached data for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]); - cache_result? - } else { - // Use server result and save to cache for next time - let server_data = server_result?; - - if !server_data.items.is_empty() { - let items_clone = server_data.items.clone(); + // Cache hit: return immediately, update cache in background + if let Ok(data) = &cache_result { + if data.has_content() { + debug!("[HybridRepo] Cache hit for get_items, returning immediately for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]); + // Background: save server result to cache when it arrives tokio::spawn(async move { - if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await { - warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e); - } else { - debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]); + match server_handle.await { + Ok(Ok(server_data)) if !server_data.items.is_empty() => { + if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &server_data.items).await { + warn!("[HybridRepo] Background cache update failed: {:?}", e); + } else { + debug!("[HybridRepo] Background updated {} cached items for parent {}", server_data.items.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]); + } + } + _ => {} // Server failed or returned empty — keep existing cache } }); + return Ok(data.clone()); } + } - server_data - }; - - Ok(result) + // Cache miss — wait for server result + match server_handle.await { + Ok(Ok(server_data)) => { + if !server_data.items.is_empty() { + let items_clone = server_data.items.clone(); + tokio::spawn(async move { + if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await { + warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e); + } else { + debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]); + } + }); + } + Ok(server_data) + } + Ok(Err(e)) => cache_result.or(Err(e)), + Err(join_err) => cache_result.or(Err(RepoError::Network { + message: format!("Server task failed: {}", join_err), + })), + } } async fn get_item(&self, item_id: &str) -> Result { @@ -447,35 +452,34 @@ impl MediaRepository for HybridRepository { let playlist_id_clone = playlist_id.clone(); let playlist_id_for_save = playlist_id.clone(); - let cache_future = self.cache_with_timeout(async move { - offline.get_playlist_items(&playlist_id).await + // Start server request in background (non-blocking) + let server_handle = tokio::spawn(async move { + online.get_playlist_items(&playlist_id_clone).await }); - let server_future = async move { - online.get_playlist_items(&playlist_id_clone).await - }; + // Check cache first (fast, 100ms timeout) + let cache_result = self.cache_with_timeout(async move { + offline.get_playlist_items(&playlist_id).await + }).await; - let (cache_result, server_result) = tokio::join!(cache_future, server_future); - - let cache_had_content = cache_result.as_ref() - .map(|data| data.has_content()) - .unwrap_or(false); - - if cache_had_content { - // If server also succeeded, update cache in background - if let Ok(server_entries) = server_result { + // Cache hit: return immediately, update cache in background + if let Ok(data) = &cache_result { + if data.has_content() { + debug!("[HybridRepo] Cache hit for playlist items, returning immediately"); tokio::spawn(async move { - if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await { - warn!("[HybridRepo] Failed to update playlist cache: {:?}", e); + if let Ok(Ok(server_entries)) = server_handle.await { + if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await { + warn!("[HybridRepo] Failed to update playlist cache: {:?}", e); + } } }); + return cache_result; } - return cache_result; } - // Cache miss - use server result - match server_result { - Ok(entries) => { + // Cache miss — wait for server result + match server_handle.await { + Ok(Ok(entries)) => { let entries_clone = entries.clone(); tokio::spawn(async move { if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await { @@ -484,7 +488,10 @@ impl MediaRepository for HybridRepository { }); Ok(entries) } - Err(e) => cache_result.or(Err(e)), + Ok(Err(e)) => cache_result.or(Err(e)), + Err(join_err) => cache_result.or(Err(RepoError::Network { + message: format!("Server task failed: {}", join_err), + })), } } diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 88bddfe..d1637c6 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -630,7 +630,7 @@ impl MediaRepository for OfflineRepository { async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option) -> Result, RepoError> { let limit_val = limit.unwrap_or(12); - // Resume items are always playable items (Audio, Movie, Episode), so simple JOIN with downloads + // Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio let (sql, params) = if let Some(pid) = parent_id { ( format!( @@ -646,6 +646,7 @@ impl MediaRepository for OfflineRepository { WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ? AND ud.playback_position_ticks > 0 AND ud.is_played = 0 AND d.status = 'completed' + AND i.item_type IN ('Movie', 'Episode') ORDER BY ud.last_played_at DESC LIMIT {}", limit_val ), @@ -670,6 +671,7 @@ impl MediaRepository for OfflineRepository { WHERE i.server_id = ? AND ud.user_id = ? AND ud.playback_position_ticks > 0 AND ud.is_played = 0 AND d.status = 'completed' + AND i.item_type IN ('Movie', 'Episode') ORDER BY ud.last_played_at DESC LIMIT {}", limit_val ), diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 6c8503b..966a233 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -545,7 +545,7 @@ impl MediaRepository for OnlineRepository { ) -> Result, RepoError> { let limit_str = limit.unwrap_or(16); let mut endpoint = format!( - "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video,Audio&Fields=BackdropImageTags,ParentBackdropImageTags", + "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str ); diff --git a/src/lib/components/common/CachedImage.svelte b/src/lib/components/common/CachedImage.svelte index df227b3..55fc67e 100644 --- a/src/lib/components/common/CachedImage.svelte +++ b/src/lib/components/common/CachedImage.svelte @@ -26,6 +26,7 @@ let imageUrl = $state(null); let loading = $state(true); let error = $state(false); + let lastLoadKey = ""; async function loadImage() { if (!itemId) { @@ -68,10 +69,14 @@ } } - // Reload image when props change + // Only reload when the image identity changes (not on parent re-renders) $effect(() => { - imageUrl = null; - loadImage(); + const loadKey = `${itemId}|${imageType}|${tag || ""}`; + if (loadKey !== lastLoadKey) { + lastLoadKey = loadKey; + imageUrl = null; + loadImage(); + } }); diff --git a/src/lib/components/library/GenericMediaListPage.svelte b/src/lib/components/library/GenericMediaListPage.svelte index f221af0..bbfa697 100644 --- a/src/lib/components/library/GenericMediaListPage.svelte +++ b/src/lib/components/library/GenericMediaListPage.svelte @@ -69,7 +69,8 @@ } try { - loading = true; + // Only show skeleton on first load (no data yet) + if (items.length === 0) loading = true; const repo = auth.getRepository(); // Use backend search if search query is provided, otherwise use getItems with sort @@ -192,7 +193,7 @@ {:else} {#if config.displayComponent === "grid"} - + {:else if config.displayComponent === "tracklist"} {/if} diff --git a/src/lib/components/library/LibraryGrid.svelte b/src/lib/components/library/LibraryGrid.svelte index 2f40f25..479fc8e 100644 --- a/src/lib/components/library/LibraryGrid.svelte +++ b/src/lib/components/library/LibraryGrid.svelte @@ -10,10 +10,11 @@ loading?: boolean; showViewToggle?: boolean; forceGrid?: boolean; + musicContent?: boolean; onItemClick?: (item: MediaItem | Library) => void; } - let { items, title, loading = false, showViewToggle = true, forceGrid = false, onItemClick }: Props = $props(); + let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
@@ -54,7 +55,7 @@
{#each Array(6) as _}
-
+
diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index ad9ad22..b3462f4 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -30,13 +30,16 @@ large: "w-48", }; + const isMusicType = $derived( + "type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist") + ); + const aspectRatio = $derived(() => { if ("type" in item) { - // MediaItem - return item.type === "Audio" || item.type === "MusicAlbum" ? "aspect-square" : "aspect-[2/3]"; + return isMusicType ? "aspect-square" : "aspect-[2/3]"; } // Library - return "aspect-video"; + return "collectionType" in item && item.collectionType === "music" ? "aspect-square" : "aspect-video"; }); const imageTag = $derived( diff --git a/src/lib/components/library/RelatedItemsSection.svelte b/src/lib/components/library/RelatedItemsSection.svelte index 729cf4a..10cbd42 100644 --- a/src/lib/components/library/RelatedItemsSection.svelte +++ b/src/lib/components/library/RelatedItemsSection.svelte @@ -133,10 +133,11 @@ {#if loading} + {@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
{#each Array(6) as _}
-
+
diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index ad2ce6c..3148166 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -40,7 +40,7 @@ let controlsTimeout: ReturnType | null = null; let seekOffset = $state(0); // Track offset when seeking in transcoded streams let isSeeking = $state(false); - let currentStreamUrl = $derived(streamUrl); + let currentStreamUrl = $state(streamUrl); let hasReportedStart = $state(false); let progressInterval: ReturnType | null = null; let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001) @@ -67,6 +67,7 @@ let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content let swipeType = $state<"brightness" | null>(null); let hls: Hls | null = null; // HLS.js instance for streaming HLS content + let hlsFatalRecoveryAttempts = 0; // Track recovery attempts to prevent infinite restarts // Audio track selection let showAudioTrackMenu = $state(false); @@ -260,14 +261,36 @@ console.log('[VideoPlayer] HLS manifest parsed, ready to play'); }); + // Reset recovery attempts for new HLS instance + hlsFatalRecoveryAttempts = 0; + // Handle errors hls.on(Hls.Events.ERROR, (event, data) => { console.error('[VideoPlayer] HLS error:', data); if (data.fatal) { + // Check if we're near the end of the video - if so, this is likely + // end-of-stream rather than a real error. Jellyfin transcoded HLS + // streams may not always terminate cleanly with #EXT-X-ENDLIST. + const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration; + const effectiveTime = currentTime + seekOffset; + const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9; + switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: - console.error('[VideoPlayer] Fatal network error, trying to recover'); - hls!.startLoad(); + hlsFatalRecoveryAttempts++; + if (isNearEnd) { + // Near end of stream - treat as natural end, don't restart + console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended'); + if (onEnded) { + onEnded(); + } + } else if (hlsFatalRecoveryAttempts <= 3) { + console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')'); + hls!.startLoad(); + } else { + console.error('[VideoPlayer] Fatal network error, max recovery attempts reached'); + hls!.destroy(); + } break; case Hls.ErrorTypes.MEDIA_ERROR: console.error('[VideoPlayer] Fatal media error, trying to recover'); @@ -528,17 +551,17 @@ // Smooth time updates using requestAnimationFrame (60fps) function updateTimeLoop() { if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) { - // Add seek offset to get actual position in the full video const newCurrentTime = seekOffset + videoElement.currentTime; - - // Safety check: only update if the video element is actually playing - // This prevents stale data from overwriting currentTime after failed seeks - if (videoElement.readyState >= 2) { // HAVE_CURRENT_DATA or better + if (videoElement.readyState >= 2) { currentTime = newCurrentTime; } + } - // Continue the loop while playing + // Keep the loop alive while playing — stopped by handlePause/handleEnded + if (isPlaying) { rafId = requestAnimationFrame(updateTimeLoop); + } else { + rafId = null; } } diff --git a/src/lib/stores/home.ts b/src/lib/stores/home.ts index 81e8a90..0386a2b 100644 --- a/src/lib/stores/home.ts +++ b/src/lib/stores/home.ts @@ -30,7 +30,8 @@ function createHomeStore() { const { subscribe, set, update } = writable(initialState); async function loadHomeSections() { - update(s => ({ ...s, isLoading: true, error: null })); + // Only show loading spinner when no data is available yet + update(s => ({ ...s, isLoading: s.heroItems.length === 0 && s.latestItems.length === 0, error: null })); try { const repo = auth.getRepository(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 37c9c84..89362a8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -69,7 +69,9 @@ } const heroItems = $derived($home.heroItems); - const resumeItems = $derived($home.resumeItems); + const resumeItems = $derived($home.resumeItems.filter( + i => i.type === "Movie" || i.type === "Episode" + )); const nextUpItems = $derived($home.nextUpItems); const latestItems = $derived($home.latestItems); const recentlyPlayedAudio = $derived($home.recentlyPlayedAudio); diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 2219b71..b541ebd 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -1,5 +1,5 @@