diff --git a/server/utils/queries/libraryBrowseCount.js b/server/utils/queries/libraryBrowseCount.js new file mode 100644 index 000000000..8cabe88be --- /dev/null +++ b/server/utils/queries/libraryBrowseCount.js @@ -0,0 +1,21 @@ +async function loadBrowseCount({ mode, exactCountLoader } = {}) { + if (mode === 'skip') { + return { + total: null, + isExact: false, + isDeferred: true + } + } + + const total = await exactCountLoader() + + return { + total, + isExact: true, + isDeferred: mode === 'deferred-exact' + } +} + +module.exports = { + loadBrowseCount +} diff --git a/server/utils/queries/libraryBrowseStrategy.js b/server/utils/queries/libraryBrowseStrategy.js new file mode 100644 index 000000000..a43d73272 --- /dev/null +++ b/server/utils/queries/libraryBrowseStrategy.js @@ -0,0 +1,63 @@ +function getCursorKeys(sortBy) { + if (sortBy === 'media.metadata.authorName') { + return ['authorNamesFirstLast', 'titleIgnorePrefix', 'id'] + } + + if (sortBy === 'media.metadata.authorNameLF') { + return ['authorNamesLastFirst', 'titleIgnorePrefix', 'id'] + } + + if (sortBy === 'progress') { + return ['mediaProgress.updatedAt', 'id'] + } + + if (sortBy === 'updatedAt') { + return ['updatedAt', 'id'] + } + + if (sortBy === 'addedAt') { + return ['createdAt', 'id'] + } + + return ['titleIgnorePrefix', 'id'] +} + +function getFamily({ mediaType, sortBy, filterGroup, collapseseries }) { + if (sortBy === 'random') return 'random-browse' + if (mediaType === 'podcast') return 'podcast-browse' + if (collapseseries && filterGroup === 'series') return 'collapsed-series-browse' + if (filterGroup === 'series') return 'series-browse' + if (filterGroup === 'progress') return 'progress-browse' + if (filterGroup === 'recent' || sortBy === 'addedAt') return 'recent-browse' + if (sortBy === 'media.metadata.authorName' || sortBy === 'media.metadata.authorNameLF') return 'author-browse' + if (filterGroup) return 'filtered-browse' + return 'plain-browse' +} + +function getLibraryBrowseStrategy({ mediaType, sortBy, filterGroup, pageMode, collapseseries } = {}) { + const normalizedSort = sortBy || 'media.metadata.title' + + if (normalizedSort === 'random') { + return { + family: 'random-browse', + paginationMode: 'offset', + countMode: 'exact-on-initial-page', + deepScrollAllowed: false, + tieBreaker: 'id', + cursorKeys: [] + } + } + + return { + family: getFamily({ mediaType, sortBy: normalizedSort, filterGroup, collapseseries }), + paginationMode: pageMode === 'endless' ? 'keyset' : 'offset', + countMode: 'deferred-exact', + deepScrollAllowed: true, + tieBreaker: 'id', + cursorKeys: getCursorKeys(normalizedSort) + } +} + +module.exports = { + getLibraryBrowseStrategy +} diff --git a/test/server/utils/libraryBrowseCount.test.js b/test/server/utils/libraryBrowseCount.test.js new file mode 100644 index 000000000..d15aafd81 --- /dev/null +++ b/test/server/utils/libraryBrowseCount.test.js @@ -0,0 +1,29 @@ +const chai = require('chai') +const expect = chai.expect + +const { loadBrowseCount } = require('../../../server/utils/queries/libraryBrowseCount') + +describe('libraryBrowseCount', () => { + it('returns deferred exact count metadata on first chunk', async () => { + const payload = await loadBrowseCount({ + mode: 'deferred-exact', + exactCountLoader: async () => 123 + }) + + expect(payload).to.deep.equal({ total: 123, isExact: true, isDeferred: true }) + }) + + it('skips exact count work on follow-up endless-scroll chunks', async () => { + let called = false + const payload = await loadBrowseCount({ + mode: 'skip', + exactCountLoader: async () => { + called = true + return 123 + } + }) + + expect(payload).to.deep.equal({ total: null, isExact: false, isDeferred: true }) + expect(called).to.equal(false) + }) +}) diff --git a/test/server/utils/libraryBrowseStrategy.test.js b/test/server/utils/libraryBrowseStrategy.test.js new file mode 100644 index 000000000..3f1ed4d6b --- /dev/null +++ b/test/server/utils/libraryBrowseStrategy.test.js @@ -0,0 +1,128 @@ +const chai = require('chai') +const expect = chai.expect + +const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy') + +describe('libraryBrowseStrategy', () => { + it('uses keyset pagination for title browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'media.metadata.title', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.countMode).to.equal('deferred-exact') + expect(strategy.deepScrollAllowed).to.equal(true) + expect(strategy.tieBreaker).to.equal('id') + expect(strategy.cursorKeys).to.deep.equal(['titleIgnorePrefix', 'id']) + }) + + it('falls back to offset for random sort', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'random', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.paginationMode).to.equal('offset') + expect(strategy.deepScrollAllowed).to.equal(false) + }) + + it('uses keyset pagination for recently added browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'addedAt', + filterGroup: 'recent', + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('recent-browse') + expect(strategy.paginationMode).to.equal('keyset') + }) + + it('uses keyset pagination for progress browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'progress', + filterGroup: 'progress', + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('progress-browse') + expect(strategy.paginationMode).to.equal('keyset') + }) + + it('uses keyset pagination for updated browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'updatedAt', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('plain-browse') + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['updatedAt', 'id']) + }) + + it('uses keyset pagination for deterministic author browse', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'media.metadata.authorName', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('author-browse') + expect(strategy.paginationMode).to.equal('keyset') + expect(strategy.cursorKeys).to.deep.equal(['authorNamesFirstLast', 'titleIgnorePrefix', 'id']) + }) + + it('defines a filtered browse family for generic library filters', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'media.metadata.title', + filterGroup: 'tags', + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('filtered-browse') + }) + + it('defines a series browse family', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'sequence', + filterGroup: 'series', + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('series-browse') + }) + + it('defines a collapsed-series browse family', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'book', + sortBy: 'sequence', + filterGroup: 'series', + pageMode: 'endless', + collapseseries: true + }) + + expect(strategy.family).to.equal('collapsed-series-browse') + }) + + it('defines a podcast browse family', () => { + const strategy = getLibraryBrowseStrategy({ + mediaType: 'podcast', + sortBy: 'media.metadata.title', + filterGroup: null, + pageMode: 'endless' + }) + + expect(strategy.family).to.equal('podcast-browse') + }) +})