fix(library): podcasts list newest episode first

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
This commit is contained in:
2026-08-23 18:38:24 +02:00
parent 2ff07bfa49
commit 231ffae626
15 changed files with 285 additions and 16 deletions
+27
View File
@@ -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
+5 -1
View File
@@ -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 `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -466,7 +467,7 @@ Internal architecture, components, and application logic.
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
@@ -770,6 +771,9 @@ Internal architecture, components, and application logic.
| UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done |
| UT-227 | Opening any one of the control bar's menus closes whichever was open — track, quality, subtitle and the desktop volume popup are one group, never two panels at once — and a second click on the open menu's own toggle closes it | DR-256 | Done |
| UT-228 | The open menu panel is anchored to the control row rather than to the icon that opened it, and carries a viewport-clamped width, so it cannot hang off the edge of a portrait screen | DR-256 | Done |
| UT-229 | A channel folder's children are requested by release date, newest first, while every other container keeps name order; an explicit sort still wins, and a caller naming no container gets no `SortBy` at all | DR-257 | Done |
| UT-230 | A `ChannelFolderItem` that is a folder maps to `ChannelFolder`, not to the generic `Folder` it was indistinguishable from | DR-257 | Done |
| UT-231 | The library store sends the container's kind and no sort field, defaulting to a plain folder when the caller names none | DR-257 | Done |
### Integration Tests
+9 -2
View File
@@ -72,7 +72,7 @@ pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
// channel leaf (distinct kind so the UI can route it to playback).
"ChannelFolderItem" => {
if is_folder {
MediaKind::Folder
MediaKind::ChannelFolder
} else {
MediaKind::ChannelItem
}
@@ -142,11 +142,18 @@ mod tests {
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
}
/// A channel container is not an ordinary folder. Jellyfin gives both the
/// same item type, but only the channel one holds plugin content whose
/// natural order is by release date — a podcast, for instance. Collapsing
/// it into `Folder` left the repository with no way to tell the two apart,
/// so every podcast listed alphabetically.
///
/// TRACES: UR-007 | DR-257 | UT-230
#[test]
fn channel_folder_item_disambiguates_on_is_folder() {
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", true),
MediaKind::Folder
MediaKind::ChannelFolder
);
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", false),
+8
View File
@@ -48,6 +48,14 @@ pub enum MediaKind {
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
/// and from `Other` so the UI can route it to playback.
ChannelItem,
/// A *container* inside a channel — a Jellyfin `ChannelFolderItem` that is
/// itself a folder, e.g. one podcast within a podcast channel. Distinct
/// from `Folder` because its children are plugin content with an order of
/// their own (newest episode first), which a folder's name order silently
/// overrode.
///
/// TRACES: UR-007 | DR-257
ChannelFolder,
/// A kind we do not model explicitly. Reached only for provider item types
/// that map to nothing meaningful; consumers treat it like an opaque
/// container. The mapping must be *total* — it never panics — so this is the
+5 -1
View File
@@ -53,7 +53,11 @@ fn kind_rank(kind: MediaKind) -> u8 {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
MediaKind::Season
| MediaKind::Playlist
| MediaKind::Channel
| MediaKind::ChannelFolder
| MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
+23 -4
View File
@@ -1239,10 +1239,29 @@ impl MediaRepository for OfflineRepository {
let start_index = opts.start_index.unwrap_or(0);
// SortBy=Random is the only sort the landing pages rely on offline (the
// hero "surprise" pool); everything else keeps the stable name order.
let order_by = match opts.sort_by.as_deref() {
Some("Random") => "RANDOM()",
_ => "i.sort_name ASC, i.name ASC",
// hero "surprise" pool); PremiereDate is what a channel folder's
// children are listed by (DR-257), so the cached leg of the race agrees
// with the server's order instead of flashing a name-sorted list first.
// Everything else keeps the stable name order.
//
// Rows with no premiere date sort last rather than leading the list.
let default_sort = default_listing_sort(opts.parent_kind);
let sort_field = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let descending = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order))
== Some("Descending");
let order_by = match sort_field {
Some("Random") => "RANDOM()".to_string(),
Some("PremiereDate") => format!(
"i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
if descending { "DESC" } else { "ASC" }
),
_ => "i.sort_name ASC, i.name ASC".to_string(),
};
// Bind the type filter rather than interpolating it: `include_item_types`
+82 -2
View File
@@ -1230,7 +1230,22 @@ fn build_get_items_endpoint(
.collect();
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
}
if let Some(sort_by) = &opts.sort_by {
// An explicit sort always wins; the container's default only fills the
// gap when the caller named none. A caller that names neither gets no
// SortBy at all, leaving the server's own order intact.
//
// TRACES: UR-007 | DR-257 | UT-229
let default_sort = default_listing_sort(opts.parent_kind);
let sort_by = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let sort_order = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order));
if let Some(sort_by) = sort_by {
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
let encoded: Vec<String> = sort_by
@@ -1239,7 +1254,7 @@ fn build_get_items_endpoint(
.collect();
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
}
if let Some(sort_order) = &opts.sort_order {
if let Some(sort_order) = sort_order {
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
}
if let Some(recursive) = opts.recursive {
@@ -2988,6 +3003,7 @@ impl MediaRepository for OnlineRepository {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::MediaKind;
use crate::utils::lock::MutexSafe;
use std::sync::Arc;
@@ -3987,6 +4003,70 @@ mod tests {
assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
}
/// The reported bug: a Jellypod podcast listed its episodes alphabetically,
/// so "[Played] …" titles clumped at the top and a new episode landed
/// wherever its name happened to fall.
///
/// The cause was the frontend asking for `SortBy=SortName` on *every*
/// drill-down, which overrides the order the channel plugin itself would
/// have returned. Which order a container's children take is domain
/// knowledge, so the caller now names the container and the repository
/// answers with the sort: a channel folder is release-date-newest-first,
/// everything else keeps the name order it had.
///
/// TRACES: UR-007 | DR-257 | UT-229
#[test]
fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
let podcast = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
..Default::default()
}),
);
assert!(
podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
"{podcast}"
);
// Every other container keeps the name order the app has always used.
let season = build_get_items_endpoint(
"u1",
"season-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::Season),
..Default::default()
}),
);
assert!(
season.contains("&SortBy=SortName&SortOrder=Ascending"),
"{season}"
);
// An explicit sort still wins — the default only fills a gap.
let explicit = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
sort_by: Some("SortName".to_string()),
sort_order: Some("Ascending".to_string()),
..Default::default()
}),
);
assert!(
explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
"{explicit}"
);
assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
// A caller that names no container is left alone, so the paths that
// rely on the server's own order (a playlist's stored order) keep it.
let unspecified = build_get_items_endpoint("u1", "lib-1", None);
assert!(!unspecified.contains("SortBy="), "{unspecified}");
}
/// A newly-added album must arrive as one entry, not one per track.
///
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
+30
View File
@@ -342,6 +342,36 @@ pub struct GetItemsOptions {
/// TRACES: UR-067 | DR-116 | UT-104
#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
/// What the container being listed *is*, so the repository can pick the
/// order its children belong in when the caller names none. The frontend
/// sends the neutral kind it already holds; what that kind implies about
/// ordering is decided here, the same division as `SearchScope`.
///
/// TRACES: UR-007 | DR-257
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_kind: Option<crate::domain::MediaKind>,
}
/// The order a container's children take when the caller asked for none.
///
/// Ordering by *name* is right for a library, a series or an album, and wrong
/// for a channel folder: plugin channels — a podcast feed, say — carry a
/// release date and are read newest-first, and Jellypod additionally prefixes
/// played episodes with "[Played]", so a name sort clumped every heard episode
/// at the top of the list. Returns `None` when no container kind was given, so
/// callers that deliberately rely on the server's own order keep it.
///
/// This mapping is domain vocabulary and lives here rather than in the
/// frontend, for the reason in docs/specs/scoped-search-boundary.md.
///
/// TRACES: UR-007 | DR-257 | UT-229
pub fn default_listing_sort(
parent_kind: Option<crate::domain::MediaKind>,
) -> Option<(&'static str, &'static str)> {
match parent_kind? {
crate::domain::MediaKind::ChannelFolder => Some(("PremiereDate", "Descending")),
_ => Some(("SortName", "Ascending")),
}
}
/// An opaque search scope the frontend selects; Rust owns what it *means*.
+20 -1
View File
@@ -2305,7 +2305,16 @@ export type GetItemsOptions = { startIndex?: number | null; limit?: number | nul
*
* TRACES: UR-067 | DR-116 | UT-104
*/
favoritesOnly?: boolean | null }
favoritesOnly?: boolean | null;
/**
* What the container being listed *is*, so the repository can pick the
* order its children belong in when the caller names none. The frontend
* sends the neutral kind it already holds; what that kind implies about
* ordering is decided here, the same division as `SearchScope`.
*
* TRACES: UR-007 | DR-257
*/
parentKind?: MediaKind | null }
/**
* Image options
*/
@@ -2463,6 +2472,16 @@ export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "s
* and from `Other` so the UI can route it to playback.
*/
"channelItem" |
/**
* A *container* inside a channel a Jellyfin `ChannelFolderItem` that is
* itself a folder, e.g. one podcast within a podcast channel. Distinct
* from `Folder` because its children are plugin content with an order of
* their own (newest episode first), which a folder's name order silently
* overrode.
*
* TRACES: UR-007 | DR-257
*/
"channelFolder" |
/**
* A kind we do not model explicitly. Reached only for provider item types
* that map to nothing meaningful; consumers treat it like an opaque
+15 -4
View File
@@ -3,7 +3,7 @@
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import type { Library, MediaItem, MediaKind, SearchResult, Genre } from "$lib/api/types";
import type { SearchOptions } from "$lib/api/bindings";
import type { SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
@@ -113,9 +113,21 @@ function createLibraryStore() {
}
}
// What a container's children are ordered by is domain knowledge, so the
// store names the *container* and Rust answers with the sort (see
// `default_listing_sort`). A channel folder — one podcast inside a plugin
// channel — is read newest-episode-first; naming `SortName` here, as this did
// for every drill-down, threw that order away.
//
// TRACES: UR-007 | DR-257 | UT-231
async function loadItems(
parentId: string,
options: { startIndex?: number; limit?: number; genres?: string[] } = {},
options: {
startIndex?: number;
limit?: number;
genres?: string[];
parentKind?: MediaKind;
} = {},
) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -129,8 +141,7 @@ function createLibraryStore() {
startIndex: options.startIndex ?? 0,
limit: options.limit ?? 10000,
fields: ["PrimaryImageAspectRatio", "Overview", "MediaStreams"],
sortBy: "SortName",
sortOrder: "Ascending",
parentKind: options.parentKind ?? "folder",
genres: options.genres,
});
@@ -0,0 +1,53 @@
/**
* What order a container's children come back in.
*
* The store used to pin `sortBy: "SortName"` onto every drill-down, which is
* where the podcast bug came from: a Jellypod channel folder lists its episodes
* newest-first, and an alphabetical sort not only lost that order but clumped
* every "[Played] …" title at the top. The store now says *what the container
* is* and lets Rust say how it orders the same division as `SearchScope`.
*
* TRACES: UR-007 | DR-257 | UT-231
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const getItemsMock = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("./auth", () => ({
auth: {
getRepository: () => ({ getItems: getItemsMock }),
},
}));
import { library } from "./library";
describe("library.loadItems ordering", () => {
beforeEach(() => {
getItemsMock.mockReset();
getItemsMock.mockResolvedValue({ items: [], totalRecordCount: 0 });
});
it("names the container rather than a sort field", async () => {
await library.loadItems("podcast-1", { parentKind: "channelFolder" });
const options = getItemsMock.mock.calls[0][1];
expect(options.parentKind).toBe("channelFolder");
// Naming a sort field here would put the ordering rule back in the
// presentation layer, which is the leak this fix removes.
expect(options.sortBy).toBeUndefined();
expect(options.sortOrder).toBeUndefined();
});
it("falls back to a plain folder when the caller names no container", async () => {
await library.loadItems("library-1");
const options = getItemsMock.mock.calls[0][1];
expect(options.parentKind).toBe("folder");
expect(options.sortBy).toBeUndefined();
});
});
+1
View File
@@ -20,6 +20,7 @@ const KIND_LABELS: Record<MediaKind, string> = {
channel: "Channel",
liveChannel: "Live TV",
channelItem: "Channel",
channelFolder: "Channel",
folder: "Folder",
other: "",
};
+1
View File
@@ -155,6 +155,7 @@
case "album":
case "artist":
case "folder":
case "channelFolder":
case "playlist":
case "channel":
// Navigate to detail view
+5 -1
View File
@@ -171,7 +171,10 @@
}
}
await library.loadItems(itemId, { limit: 100 });
// Name the container so Rust can order its children: a podcast (a channel
// folder) is listed newest episode first, everything else by name.
// TRACES: UR-007 | DR-257
await library.loadItems(itemId, { limit: 100, parentKind: item?.kind });
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
@@ -277,6 +280,7 @@
case "album":
case "artist":
case "folder":
case "channelFolder":
case "playlist":
case "channel":
case "movie":
+1
View File
@@ -174,6 +174,7 @@
"series",
"season",
"folder",
"channelFolder",
"playlist",
"channel",
];