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
+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