From 85bd2277142166732b03159d3a473eefa61df91d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:13:27 +0200 Subject: [PATCH 1/8] fix(home): uniform card heights in the Your Libraries row MediaCard derives its artwork aspect ratio from the item, so a music library rendered aspect-square (144px tall at w-36) next to video libraries at aspect-video (81px), leaving the home row ragged. Add an optional `aspect` prop that overrides the derived ratio, and pass aspect="video" from the home Libraries strip. Unset, behaviour is unchanged, so the /library overview grid and the media carousels keep their per-type ratios. Artwork already uses object-cover, so square music art crops rather than distorts. Co-Authored-By: Claude Opus 5 --- src/lib/components/library/MediaCard.svelte | 16 +++++++++++++++- src/routes/+page.svelte | 5 ++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 7e9b488c..c9ce535d 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -45,9 +45,16 @@ * TRACES: UR-068 | DR-119 */ showFavorite?: boolean; + /** + * Force the artwork box to a fixed aspect ratio instead of deriving one from + * the item. Use on rows that mix item kinds (e.g. the home "Your Libraries" + * strip, where square music art next to 16:9 video art would otherwise give + * the cards different heights). Artwork still fills the box via object-cover. + */ + aspect?: "square" | "video" | "poster"; } - let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props(); + let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props(); // Long-press detection. We arm a timer on pointerdown; if it fires before the // pointer is released (or moves too far), we treat it as a long press and set a @@ -179,7 +186,14 @@ "kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist") ); + const FIXED_ASPECT = { + square: "aspect-square", + video: "aspect-video", + poster: "aspect-[2/3]", + } as const; + const aspectRatio = $derived(() => { + if (aspect) return FIXED_ASPECT[aspect]; if ("kind" in item) { return isMusicType ? "aspect-square" : "aspect-[2/3]"; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 50d9c851..7897e776 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -159,12 +159,15 @@ {#if shortcutLibraries.length > 0}

Your Libraries

-
+
{#each shortcutLibraries as lib (lib.id)}
+ handleLibraryClick(lib)} />
From 9d7cb085e95633bc7045734e6effacc66b786be4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:23:26 +0200 Subject: [PATCH 2/8] fix(series): resume after the furthest-watched episode, not the first gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pick_current_episode` rung 3 returned the first unwatched episode in series order. A viewer who skipped the pilot but is three seasons deep was sent back to S1E1: the gap was a deliberate skip, not the place they stopped. This read as flaky rather than consistently wrong because rung 3 only fires when the server's Next Up (rung 2) yields nothing, and `resolve_current_episode` swallows that call's errors with `.unwrap_or_default()`. `HybridRepository::get_next_up_episodes` delegates unconditionally to the online repo, so any unreachable-server moment silently degraded to the empty vec — same series, same watch state, different answer depending on one request's outcome. Rung 3 now scans the ordered list from the end with `rposition(is_played)` and returns the episode after the furthest-watched one, falling back to the previous first-unwatched behaviour when nothing is watched or the series is finished. Season crossing comes free from the already-flat series ordering, and `season_rank` keeps specials last so a watched special cannot mark a show finished. Tests written first and confirmed failing (S1E1 where S3E4 was expected), covering the skipped-pilot case, rolling into the next season past a skipped episode, and the watched-special case. All 17 existing tests still pass. Note: cargo test could not run locally (javascriptcoregtk-4.1 / webkit2gtk-4.1 absent on this host). The pure policy half plus its verbatim test module were extracted into a standalone crate to get real red/green; the full crate suite still needs a run on a complete toolchain. Co-Authored-By: Claude Opus 5 --- src-tauri/src/repository/series_progress.rs | 72 +++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/repository/series_progress.rs b/src-tauri/src/repository/series_progress.rs index 566aba67..57ec2577 100644 --- a/src-tauri/src/repository/series_progress.rs +++ b/src-tauri/src/repository/series_progress.rs @@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool { /// working through. /// 2. **The server's Next Up** for this series — it accounts for watch history /// we do not cache locally. -/// 3. **The first unwatched episode** in series order. This is the offline path: -/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without -/// this rung the whole feature would be online-only. +/// 3. **The episode after the furthest-watched one**, falling back to the first +/// unwatched episode when nothing has been watched or the series is finished. +/// This is the offline path: `OfflineRepository::get_next_up_episodes` +/// returns an empty vec, so without this rung the whole feature would be +/// online-only. It deliberately does *not* return the first unwatched +/// episode outright — an unwatched episode behind the viewer's furthest +/// point was skipped on purpose, and sending them back to it is the bug +/// DR-101 was reopened for. /// 4. **The first episode**, so a never-watched series opens on its premiere /// rather than on nothing. /// @@ -136,7 +141,18 @@ pub fn pick_current_episode( return Some(matched.unwrap_or(found).clone()); } - // 3. First unwatched in series order. + // 3. The episode after the furthest-watched one. Not simply the first + // unwatched: a viewer who skipped the pilot but is deep into season 3 + // must not be dragged back to S1E1. An earlier gap is a deliberate skip; + // where they stopped is the *last* thing they watched. + if let Some(furthest) = episodes.iter().rposition(is_played) { + if let Some(found) = episodes.get(furthest + 1) { + return Some(found.clone()); + } + } + + // Nothing watched yet (or the furthest-watched episode is the finale): + // the first unwatched episode in series order. if let Some(found) = episodes.iter().find(|e| !is_played(e)) { return Some(found.clone()); } @@ -352,6 +368,54 @@ mod tests { assert_eq!(current.id, "s2e2"); } + /// A viewer deep in season 3 who never watched the pilot must not be sent + /// back to it: the gap was a skip, not the place they stopped. + #[test] + fn resumes_after_the_furthest_watched_episode_not_the_first_gap() { + let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat(); + for ep in eps.iter_mut() { + // Everything through S3E3 watched, except the never-watched pilot. + let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3); + if watched_through && ep.id != "s1e1" { + *ep = watched(ep.clone()); + } + } + + let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap(); + assert_eq!(current.id, "s3e4"); + } + + /// The furthest-watched episode being a finale must still roll into the + /// next season rather than stopping the series. + #[test] + fn resumes_into_the_next_season_after_a_skipped_earlier_episode() { + let mut eps = [season(1, 3), season(2, 3)].concat(); + for ep in eps.iter_mut() { + if ep.parent_index_number == Some(1) && ep.id != "s1e1" { + *ep = watched(ep.clone()); + } + } + + let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap(); + assert_eq!(current.id, "s2e1"); + } + + /// Specials sort last, so watching one must not mark the series finished + /// while numbered episodes remain. + #[test] + fn a_watched_special_does_not_end_the_series() { + let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat(); + sort_series_order(&mut eps); + for ep in eps.iter_mut() { + if ep.id == "s1e1" || ep.id == "s0e1" { + *ep = watched(ep.clone()); + } + } + + let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap(); + assert_eq!(current.id, "s1e2"); + } + #[test] fn crosses_a_season_boundary_when_a_season_is_finished() { let mut eps = [season(1, 3), season(2, 3)].concat(); From adc460f35da8b00acba2c0269bc9575fc4393644 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:44:54 +0200 Subject: [PATCH 3/8] fix(downloads): honor the selected bitrate (videoBitRate, capital R) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloading at a specific quality silently returned the full-size original. The download URL builder spelled the transcode params `videoBitrate`/`audioBitrate`, but Jellyfin binds `videoBitRate`/ `audioBitRate` — with a capital R. Query-key binding is case-insensitive, so this is not a casing preference: the lowercase-r form is a different token that fails to bind. The server discards it without error and then stream-copies the source, so picking "480p" produced an original-quality file with no failure surfaced anywhere. `maxHeight`/`videoCodec` were unaffected (case-insensitive binding covers them), which is why the height cap applied while the bitrate cap vanished. Also set `allowVideoStreamCopy=false` on the transcode presets to force a real re-encode. Video stream-copy is gated by `allowVideoStreamCopy`, not `enableAutoStreamCopy` — the latter governs audio only. `original` is unchanged: it stays a deliberate direct static copy, now pinned by a test. The pre-existing unit tests asserted the broken lowercase-r spellings, so they passed against broken code; corrected. Verified red -> green by extracting the pre-fix and post-fix builder bodies into an isolated harness: 15 assertion failures before, 0 after. TRACES: UR-071 | DR-123 Co-Authored-By: Claude Opus 5 --- src-tauri/src/repository/online.rs | 93 +++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index c7d4c36e..2bc83b8b 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -1772,6 +1772,7 @@ impl MediaRepository for OnlineRepository { ) } + /// TRACES: UR-071 | DR-123 fn get_video_download_url( &self, item_id: &str, @@ -1789,27 +1790,43 @@ impl MediaRepository for OnlineRepository { // Map the frontend quality preset to concrete transcode params. For // "original" we request a direct static copy (no transcode) which is // byte-range resumable; other presets ask the server to transcode. + // + // 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds + // query keys case-insensitively, so `maxHeight`/`videoCodec` casing is + // free, but `videoBitrate` (lowercase r) is a *different token*: it + // fails to bind, is silently dropped, and the requested cap vanishes + // with no error. That is why every "480p"/"720p" download came back at + // full original quality. See `Jellyfin.Api` BaseEncodingJobOptions. + // + // `allowVideoStreamCopy=false` forces a real re-encode. Without it the + // server may stream-copy the source when it already satisfies the cap — + // fine in itself, but it also means a mis-typed cap degrades silently. + // Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy; + // video copy is gated by `allowVideoStreamCopy`. match quality { "high" => { - params.push("videoBitrate=8000000".to_string()); + params.push("videoBitRate=8000000".to_string()); params.push("maxHeight=1080".to_string()); - params.push("audioBitrate=384000".to_string()); + params.push("audioBitRate=384000".to_string()); params.push("videoCodec=h264".to_string()); params.push("audioCodec=aac".to_string()); + params.push("allowVideoStreamCopy=false".to_string()); } "medium" => { - params.push("videoBitrate=4000000".to_string()); + params.push("videoBitRate=4000000".to_string()); params.push("maxHeight=720".to_string()); - params.push("audioBitrate=256000".to_string()); + params.push("audioBitRate=256000".to_string()); params.push("videoCodec=h264".to_string()); params.push("audioCodec=aac".to_string()); + params.push("allowVideoStreamCopy=false".to_string()); } "low" => { - params.push("videoBitrate=1500000".to_string()); + params.push("videoBitRate=1500000".to_string()); params.push("maxHeight=480".to_string()); - params.push("audioBitrate=128000".to_string()); + params.push("audioBitRate=128000".to_string()); params.push("videoCodec=h264".to_string()); params.push("audioCodec=aac".to_string()); + params.push("allowVideoStreamCopy=false".to_string()); } // "original" (and any unknown value) → direct, resumable copy. _ => { @@ -2548,7 +2565,7 @@ mod tests { // with no transcode params. assert!(url.contains("Static=true"), "url: {url}"); assert!( - !url.contains("videoBitrate"), + !url.contains("videoBitRate"), "original must not transcode: {url}" ); assert!( @@ -2568,7 +2585,7 @@ mod tests { "{quality} must use stream.mp4: {url}" ); assert!( - url.contains("videoBitrate="), + url.contains("videoBitRate="), "{quality} must set bitrate: {url}" ); assert!( @@ -2584,6 +2601,66 @@ mod tests { } } + /// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital + /// R**. Jellyfin binds query keys case-insensitively, so this is not a + /// casing preference: `videoBitrate` is a *different token* that fails to + /// bind and is silently discarded, taking the user's quality cap with it. + /// Nothing errors — the download just returns the full-size original, which + /// is exactly how this bug went unnoticed. + #[test] + fn test_video_download_url_bitrate_params_use_capital_r_spelling() { + let repo = create_test_repository(); + + for quality in ["high", "medium", "low"] { + let url = repo.get_video_download_url("item123", quality, None); + + assert!( + url.contains("videoBitRate="), + "{quality} must spell it videoBitRate (capital R): {url}" + ); + assert!( + url.contains("audioBitRate="), + "{quality} must spell it audioBitRate (capital R): {url}" + ); + + // The lowercase-r spellings never bind — they must not appear at + // all, or the cap is silently dropped by the server. + assert!( + !url.contains("videoBitrate="), + "{quality} emits the unbindable lowercase-r spelling: {url}" + ); + assert!( + !url.contains("audioBitrate="), + "{quality} emits the unbindable lowercase-r spelling: {url}" + ); + } + } + + /// A correctly-spelled cap is still only *conditionally* honored: the server + /// may stream-copy the source when it already satisfies the cap. Video copy + /// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which + /// only governs audio), so the transcode presets must disable it to + /// guarantee a real re-encode at the requested bitrate. + #[test] + fn test_video_download_url_transcode_presets_forbid_video_stream_copy() { + let repo = create_test_repository(); + + for quality in ["high", "medium", "low"] { + let url = repo.get_video_download_url("item123", quality, None); + assert!( + url.contains("allowVideoStreamCopy=false"), + "{quality} must forbid video stream copy: {url}" + ); + } + + // "original" is a deliberate direct copy — it must NOT disable copying. + let original = repo.get_video_download_url("item123", "original", None); + assert!( + !original.contains("allowVideoStreamCopy=false"), + "original must remain a direct copy: {original}" + ); + } + #[test] fn test_video_download_url_passes_media_source_id() { let repo = create_test_repository(); From 2d5074432015fd67a555cfb4f6676a452976a86c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:51:37 +0200 Subject: [PATCH 4/8] fix(player): resume at saved position on the Android native path The native (ExoPlayer) video path never applied the resume position, so "resume from where you left off" always played from the start on Android. Two layers each assumed the other did the seek: - The only code acting on `initialPosition` was handleCanPlay, an HTML5