From 8585cfff6bad60e86518385be5ab88d0e63360a2 Mon Sep 17 00:00:00 2001 From: Jonathan Finley Date: Fri, 13 Mar 2026 21:14:59 -0400 Subject: [PATCH] fix: align large-library browse strategy keys --- server/utils/queries/libraryBrowseStrategy.js | 48 +++++++---- .../utils/libraryBrowseStrategy.test.js | 81 ++++++++++++++++++- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/server/utils/queries/libraryBrowseStrategy.js b/server/utils/queries/libraryBrowseStrategy.js index a43d73272..c7369067b 100644 --- a/server/utils/queries/libraryBrowseStrategy.js +++ b/server/utils/queries/libraryBrowseStrategy.js @@ -1,25 +1,45 @@ -function getCursorKeys(sortBy) { +function getTitleCursorKey() { + return global.ServerSettings && global.ServerSettings.sortingIgnorePrefix ? 'titleIgnorePrefix' : 'title' +} + +function getKeysetCursorKeys(sortBy) { + if (sortBy === 'media.metadata.title') { + return [getTitleCursorKey(), 'id'] + } + if (sortBy === 'media.metadata.authorName') { - return ['authorNamesFirstLast', 'titleIgnorePrefix', 'id'] + return ['authorNamesFirstLast', getTitleCursorKey(), 'id'] } if (sortBy === 'media.metadata.authorNameLF') { - return ['authorNamesLastFirst', 'titleIgnorePrefix', 'id'] - } - - if (sortBy === 'progress') { - return ['mediaProgress.updatedAt', 'id'] - } - - if (sortBy === 'updatedAt') { - return ['updatedAt', 'id'] + return ['authorNamesLastFirst', getTitleCursorKey(), 'id'] } if (sortBy === 'addedAt') { return ['createdAt', 'id'] } - return ['titleIgnorePrefix', 'id'] + if (sortBy === 'progress') { + return ['mediaProgresses.updatedAt', 'id'] + } + + if (sortBy === 'progress.createdAt') { + return ['mediaProgresses.createdAt', 'id'] + } + + if (sortBy === 'progress.finishedAt') { + return ['mediaProgresses.finishedAt', 'id'] + } + + return [] +} + +function getPaginationMode(pageMode, sortBy) { + if (pageMode !== 'endless') { + return 'offset' + } + + return getKeysetCursorKeys(sortBy).length ? 'keyset' : 'offset' } function getFamily({ mediaType, sortBy, filterGroup, collapseseries }) { @@ -50,11 +70,11 @@ function getLibraryBrowseStrategy({ mediaType, sortBy, filterGroup, pageMode, co return { family: getFamily({ mediaType, sortBy: normalizedSort, filterGroup, collapseseries }), - paginationMode: pageMode === 'endless' ? 'keyset' : 'offset', + paginationMode: getPaginationMode(pageMode, normalizedSort), countMode: 'deferred-exact', deepScrollAllowed: true, tieBreaker: 'id', - cursorKeys: getCursorKeys(normalizedSort) + cursorKeys: getKeysetCursorKeys(normalizedSort) } } diff --git a/test/server/utils/libraryBrowseStrategy.test.js b/test/server/utils/libraryBrowseStrategy.test.js index 3f1ed4d6b..6c5ce954b 100644 --- a/test/server/utils/libraryBrowseStrategy.test.js +++ b/test/server/utils/libraryBrowseStrategy.test.js @@ -4,7 +4,13 @@ const expect = chai.expect const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy') describe('libraryBrowseStrategy', () => { + afterEach(() => { + delete global.ServerSettings + }) + it('uses keyset pagination for title browse', () => { + global.ServerSettings = { sortingIgnorePrefix: true } + const strategy = getLibraryBrowseStrategy({ mediaType: 'book', sortBy: 'media.metadata.title', @@ -19,6 +25,20 @@ describe('libraryBrowseStrategy', () => { expect(strategy.cursorKeys).to.deep.equal(['titleIgnorePrefix', 'id']) }) + it('uses raw title cursor keys when prefix ignoring is disabled', () => { + global.ServerSettings = { sortingIgnorePrefix: false } + + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'media.metadata.title', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['title', 'id']) + }) + it('falls back to offset for random sort', () => { const strategy = getLibraryBrowseStrategy({ mediaType: 'book', @@ -41,6 +61,7 @@ describe('libraryBrowseStrategy', () => { expect(strategy.family).to.equal('recent-browse') expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['createdAt', 'id']) }) it('uses keyset pagination for progress browse', () => { @@ -53,6 +74,7 @@ describe('libraryBrowseStrategy', () => { expect(strategy.family).to.equal('progress-browse') expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['mediaProgresses.updatedAt', 'id']) }) it('uses keyset pagination for updated browse', () => { @@ -64,11 +86,13 @@ describe('libraryBrowseStrategy', () => { }) expect(strategy.family).to.equal('plain-browse') - expect(strategy.paginationMode).to.equal('keyset') - expect(strategy.cursorKeys).to.deep.equal(['updatedAt', 'id']) + expect(strategy.paginationMode).to.equal('offset') + expect(strategy.cursorKeys).to.deep.equal([]) }) it('uses keyset pagination for deterministic author browse', () => { + global.ServerSettings = { sortingIgnorePrefix: true } + const strategy = getLibraryBrowseStrategy({ mediaType: 'book', sortBy: 'media.metadata.authorName', @@ -81,6 +105,47 @@ describe('libraryBrowseStrategy', () => { expect(strategy.cursorKeys).to.deep.equal(['authorNamesFirstLast', 'titleIgnorePrefix', 'id']) }) + it('uses keyset pagination for deterministic author last-first browse', () => { + global.ServerSettings = { sortingIgnorePrefix: false } + + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'media.metadata.authorNameLF', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('author-browse') + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['authorNamesLastFirst', 'title', 'id']) + }) + + it('uses keyset pagination for progress created-at browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'progress.createdAt', + filterGroup: 'progress', + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('progress-browse') + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['mediaProgresses.createdAt', 'id']) + }) + + it('uses keyset pagination for progress finished-at browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'progress.finishedAt', + filterGroup: 'progress', + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('progress-browse') + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['mediaProgresses.finishedAt', 'id']) + }) + it('defines a filtered browse family for generic library filters', () => { const strategy = getLibraryBrowseStrategy({ mediaType: 'book', @@ -125,4 +190,16 @@ describe('libraryBrowseStrategy', () => { expect(strategy.family).to.equal('podcast-browse') }) + + it('falls back to offset for unsupported endless-scroll sorts', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'size', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.paginationMode).to.equal('offset') + expect(strategy.cursorKeys).to.deep.equal([]) + }) })