From 231ffae626c1764af69a6b8270b57f43ff391073 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 23 Aug 2026 18:38:24 +0200 Subject: [PATCH] fix(library): podcasts list newest episode first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Jellypod podcast listed its episodes alphabetically. The store pinned SortBy=SortName onto every drill-down, which overrode the order the channel plugin returns — and since Jellypod prefixes played episodes with "[Played]", the name sort also clumped every heard episode at the top. Which order a container's children take is domain knowledge, so it moves to Rust: the caller names the container (GetItemsOptions.parentKind) and default_listing_sort answers with the sort. A channel folder is PremiereDate descending, every other container keeps SortName ascending, and a caller naming no container still gets no SortBy, so the paths that rely on the server's own order keep it. An explicit sort always wins. ChannelFolderItem with is_folder now maps to MediaKind::ChannelFolder instead of collapsing into Folder — while both were Folder there was nothing to key the rule on. The offline leg of the cache/server race applies the same order, so the cached list no longer flashes in name order before the server's arrives. TRACES: UR-007 | DR-257 | UT-229, UT-230, UT-231 --- docs/architecture/03-data-flow.md | 27 +++++++ docs/requirements.md | 6 +- src-tauri/src/domain/from_jellyfin.rs | 11 ++- src-tauri/src/domain/media.rs | 8 +++ src-tauri/src/domain/search_rank.rs | 6 +- src-tauri/src/repository/offline.rs | 27 +++++-- src-tauri/src/repository/online.rs | 84 +++++++++++++++++++++- src-tauri/src/repository/types.rs | 30 ++++++++ src/lib/api/bindings.ts | 21 +++++- src/lib/stores/library.ts | 19 +++-- src/lib/stores/libraryListingOrder.test.ts | 53 ++++++++++++++ src/lib/utils/mediaKind.ts | 1 + src/routes/library/+page.svelte | 1 + src/routes/library/[id]/+page.svelte | 6 +- src/routes/player/[id]/+page.svelte | 1 + 15 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 src/lib/stores/libraryListingOrder.test.ts diff --git a/docs/architecture/03-data-flow.md b/docs/architecture/03-data-flow.md index 47fcd31f..5bf790d6 100644 --- a/docs/architecture/03-data-flow.md +++ b/docs/architecture/03-data-flow.md @@ -49,6 +49,33 @@ sequenceDiagram - Background cache updates (planned) - **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline. +### Listing order is decided in Rust + +**TRACES**: UR-007 | DR-257 + +A browse call names the **container** (`GetItemsOptions.parentKind`, the neutral +`MediaKind` the caller already holds) and not a sort field. +`default_listing_sort` in `repository/types.rs` turns that kind into the order: + +| Container kind | Order | +|---|---| +| `channelFolder` — one podcast inside a plugin channel | `PremiereDate` descending | +| any other container | `SortName` ascending | +| none given | no `SortBy` — the server's own order stands | + +Both legs of the race apply it, so the cached list does not flash in name order +before the server's arrives. An explicit `sortBy` from the caller always wins; +the default only fills the gap. + +This is a domain rule, not a display preference, which is why it is not in the +frontend: the store that asks for a podcast's episodes has no business knowing +that podcasts are read newest-first. `MediaKind::ChannelFolder` exists for the +same reason — Jellyfin gives a channel container and an ordinary folder the same +item type (`ChannelFolderItem`), and while both mapped to `Folder` there was +nothing to key the rule on. The defect this prevents: every Jellypod podcast +listed alphabetically, which discarded the release order *and* clumped every +`[Played] …` episode at the top of the list. + ## Search Flow (Locally Indexed) **TRACES**: UR-065 | DR-108 … DR-111, IR-030 diff --git a/docs/requirements.md b/docs/requirements.md index cb48b8b7..61c838b3 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -450,6 +450,7 @@ Internal architecture, components, and application logic. | DR-254 | Advancing to the next episode drops a per-playback quality override. The override is process-wide and describes one playback: a viewer who drops to 720p for a struggling episode has said nothing about the next. Every advance the frontend drives clears it via `player_play_item`; the background audio-only advance loads the next episode in Rust and skipped all three clearing sites, so every later episode stayed capped with nothing in the UI saying why | Repository | UR-074 | Done | | DR-255 | One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other | Player | UR-081 | Done | | DR-256 | The video control bar opens **at most one menu at a time**, and opens it where it can be read. Audio track, quality and subtitles each owned a `show…` boolean that no other toggle cleared, so a second menu opened stacked over the first — two panels in the same corner, the newer one covering rows of the older, both still taking clicks. A single `openMenu` value replaces them, which makes "one menu" a property of the state rather than something every handler must remember; the desktop volume popup joins the same group through `VolumeControl`'s optional controlled-open props. Placement was the second half of the same defect: every panel was `absolute right-0` against **its own icon button**, and those icons sit mid-row, so a 220 px panel hung off the left edge of a portrait phone and half the tracks could not be read or tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw − 2rem)` wide and `min(300px, 45vh)` tall, with a full-screen dismiss layer inside the controls subtree so a tap elsewhere closes it without reaching the container's tap gestures (DR-098). The icon row itself wraps instead of overflowing — in portrait the transport controls plus nine icons are wider than the screen, which put fullscreen and close past the edge | UI | UR-020, UR-021, UR-066, UR-074 | Done | +| DR-257 | A container's children are ordered by **what the container is**, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope` | Repository | UR-007 | Done | | DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `