fix(library): scope sort options to audiobook libraries

getLibrarySortRelevanceScope (in both LibraryBrowse and
libraryPageSearchParams) now recognizes the audiobook libraryType —
the DB stores it as the plural "audiobooks", the sort-scope constant
is singular "audiobook", so accept both spellings.

Video-only sort fields (Content Rating, IMDb/TMDB/RT ratings,
Resolution) now declare applicableMediaScopes = ALL_VIDEO_SCOPES
(no audiobook), so audiobook libraries no longer offer them in the
sort dropdown. Mixed libraries and episode browse mode keep the same
sort set as before: the "all" relevance check still uses
ALL_VIDEO_SCOPES with the historical "every-scope" semantic, so
series-only sorts like Latest Episode Air Date continue to normalize
away in episode mode.

Adds two tests covering the audiobook scope: video-only sorts get
normalized to title; audiobook-applicable sorts (runtime) survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-27 18:44:17 +02:00
co-authored by Claude Opus 4.7
parent eb8f67d2a2
commit 1c291935c5
4 changed files with 58 additions and 10 deletions
+19 -8
View File
@@ -47,7 +47,14 @@ interface QuerySortLike {
order?: string | null;
}
const ALL_MEDIA_SCOPES: ApplicableMediaScope[] = ["movie", "series", "episode", "audiobook"];
// ALL_VIDEO_SCOPES is the universe of video-side scopes — used as the
// baseline for the "all" relevance scope (mixed libraries / episode
// browse mode) so that sorts limited to series+episode are still
// filtered out as not universally applicable. Audiobook is explicit
// opt-in: a sort field must list "audiobook" in its applicableMediaScopes
// to be eligible for an audiobook-only library.
const ALL_VIDEO_SCOPES: ApplicableMediaScope[] = ["movie", "series", "episode"];
const ALL_MEDIA_SCOPES: ApplicableMediaScope[] = [...ALL_VIDEO_SCOPES, "audiobook"];
export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
{
@@ -91,7 +98,7 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
label: "Content Rating",
defaultOrder: "asc",
personalized: false,
applicableMediaScopes: ALL_MEDIA_SCOPES,
applicableMediaScopes: ALL_VIDEO_SCOPES,
},
{
value: "runtime",
@@ -105,35 +112,35 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
label: "IMDb Rating",
defaultOrder: "desc",
personalized: false,
applicableMediaScopes: ALL_MEDIA_SCOPES,
applicableMediaScopes: ALL_VIDEO_SCOPES,
},
{
value: "rating_tmdb",
label: "TMDB Rating",
defaultOrder: "desc",
personalized: false,
applicableMediaScopes: ALL_MEDIA_SCOPES,
applicableMediaScopes: ALL_VIDEO_SCOPES,
},
{
value: "rating_rt_critic",
label: "RT Critic Rating",
defaultOrder: "desc",
personalized: false,
applicableMediaScopes: ALL_MEDIA_SCOPES,
applicableMediaScopes: ALL_VIDEO_SCOPES,
},
{
value: "rating_rt_audience",
label: "RT Audience Rating",
defaultOrder: "desc",
personalized: false,
applicableMediaScopes: ALL_MEDIA_SCOPES,
applicableMediaScopes: ALL_VIDEO_SCOPES,
},
{
value: "resolution",
label: "Resolution",
defaultOrder: "desc",
personalized: false,
applicableMediaScopes: ALL_MEDIA_SCOPES,
applicableMediaScopes: ALL_VIDEO_SCOPES,
},
{
value: "bitrate",
@@ -183,8 +190,12 @@ function optionMatchesRelevanceScope(
if (!relevanceScope) {
return true;
}
// "all" — applies to mixed libraries and the episode browse mode.
// Match the historical baseline (universal across all video scopes)
// so series-only sorts still get normalized away. Audiobook scope is
// intentionally not part of this baseline; see ALL_VIDEO_SCOPES.
if (relevanceScope === "all") {
return ALL_MEDIA_SCOPES.every((scope) => option.applicableMediaScopes.includes(scope));
return ALL_VIDEO_SCOPES.every((scope) => option.applicableMediaScopes.includes(scope));
}
return option.applicableMediaScopes.includes(relevanceScope);
}
+12 -1
View File
@@ -32,7 +32,17 @@ function getLibrarySortRelevanceScope(
if (libraryType === "movie" || libraryType === "series") {
return libraryType;
}
if (mediaScope === "movie" || mediaScope === "series" || mediaScope === "episode") {
// The DB stores audiobook library type as the plural "audiobooks";
// the sort scope is the singular "audiobook" (matches QueryDefinition.media_scope).
if (libraryType === "audiobook" || libraryType === "audiobooks") {
return "audiobook";
}
if (
mediaScope === "movie" ||
mediaScope === "series" ||
mediaScope === "episode" ||
mediaScope === "audiobook"
) {
return mediaScope;
}
return "all";
@@ -148,6 +158,7 @@ export default function LibraryBrowse({
sortRelevanceScope={sortRelevanceScope}
resultCountLabel={itemCountLabel}
resultCountLoading={isLoading}
libraryType={libraryType}
/>
<ItemGrid
totalItems={totalItems}
@@ -169,6 +169,22 @@ describe("parseLibraryPageState", () => {
expect(state.queryDefinition.sort).toEqual({ field: "title", order: "asc" });
});
it("normalizes video-only sorts away on audiobook libraries", () => {
const state = parseLibraryPageState(
params("tab=library&sort=rating_imdb&order=desc"),
"audiobooks",
);
expect(state.queryDefinition.sort).toEqual({ field: "title", order: "asc" });
});
it("keeps audiobook-applicable sorts on audiobook libraries", () => {
const state = parseLibraryPageState(
params("tab=library&sort=runtime&order=desc"),
"audiobooks",
);
expect(state.queryDefinition.sort).toEqual({ field: "runtime", order: "desc" });
});
it("normalizes legacy sort aliases to canonical values", () => {
expect(
parseLibraryPageState(params("tab=library&sort=sort_title"), "mixed").queryDefinition.sort
+11 -1
View File
@@ -66,7 +66,17 @@ function getLibrarySortRelevanceScope(
if (libraryType === "movie" || libraryType === "series") {
return libraryType;
}
if (mediaScope === "movie" || mediaScope === "series" || mediaScope === "episode") {
// The DB stores audiobook library type as the plural "audiobooks";
// the sort scope is the singular "audiobook" (matches QueryDefinition.media_scope).
if (libraryType === "audiobook" || libraryType === "audiobooks") {
return "audiobook";
}
if (
mediaScope === "movie" ||
mediaScope === "series" ||
mediaScope === "episode" ||
mediaScope === "audiobook"
) {
return mediaScope;
}
return "all";