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:
+20
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const KIND_LABELS: Record<MediaKind, string> = {
|
||||
channel: "Channel",
|
||||
liveChannel: "Live TV",
|
||||
channelItem: "Channel",
|
||||
channelFolder: "Channel",
|
||||
folder: "Folder",
|
||||
other: "",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user