diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..8627672b0 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -612,6 +612,12 @@ class LibraryController { total: undefined, limit: req.query.limit || 0, page: req.query.page || 0, + cursor: req.query.cursor || null, + pageMode: req.query.pageMode || 'paged', + nextCursor: null, + paginationMode: 'offset', + isCountDeferred: false, + countMode: 'exact-on-initial-page', sortBy: req.query.sort, sortDesc: req.query.desc === '1', filterBy: req.query.filter, @@ -630,9 +636,13 @@ class LibraryController { const seriesId = libraryFilters.decode(payload.filterBy.split('.')[1]) payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, req.library) } else { - const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload) + const { libraryItems, count, nextCursor, paginationMode, countMode, isCountDeferred } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload) payload.results = libraryItems payload.total = count + payload.nextCursor = nextCursor || null + payload.paginationMode = paginationMode || 'offset' + payload.countMode = countMode || 'exact-on-initial-page' + payload.isCountDeferred = !!isCountDeferred } res.json(payload) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index b33fa585c..55f2aa2f6 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -293,7 +293,11 @@ class LibraryItem extends Model { */ static async getByFilterAndSort(library, user, options) { let start = Date.now() - const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library.id, user, options) + const { libraryItems, count, nextCursor, paginationMode, countMode, isCountDeferred } = await libraryFilters.getFilteredLibraryItems(library.id, user, { + ...options, + cursor: options.cursor || null, + pageMode: options.pageMode || 'paged' + }) Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`) return { @@ -323,7 +327,11 @@ class LibraryItem extends Model { return oldLibraryItem }), - count + count, + nextCursor, + paginationMode, + countMode, + isCountDeferred } } diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 7312b9d5d..3a9c38abd 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -22,7 +22,7 @@ module.exports = { * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>} */ async getFilteredLibraryItems(libraryId, user, options) { - const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options + const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType, cursor, pageMode } = options let filterValue = null let filterGroup = null @@ -34,9 +34,17 @@ module.exports = { } if (mediaType === 'book') { - return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) + return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, false, { + cursor: cursor || null, + pageMode: pageMode || 'paged', + collapseseries + }) } else { - return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) + return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset, { + cursor: cursor || null, + pageMode: pageMode || 'paged', + collapseseries + }) } }, diff --git a/test/server/controllers/LibraryController.largeLibraryBrowse.test.js b/test/server/controllers/LibraryController.largeLibraryBrowse.test.js new file mode 100644 index 000000000..1a98811d3 --- /dev/null +++ b/test/server/controllers/LibraryController.largeLibraryBrowse.test.js @@ -0,0 +1,114 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const LibraryController = require('../../../server/controllers/LibraryController') +const libraryFilters = require('../../../server/utils/queries/libraryFilters') + +describe('LibraryController large-library browse contract', () => { + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.sync({ force: true }) + }) + + it('returns pagination metadata with deferred count semantics for title browse', async () => { + const getByFilterAndSortStub = sinon.stub(Database.libraryItemModel, 'getByFilterAndSort').resolves({ + libraryItems: [{ id: 'item_1' }], + count: 123, + nextCursor: 'cursor-2', + paginationMode: 'keyset', + countMode: 'deferred-exact', + isCountDeferred: true + }) + const req = { + query: { limit: '40', sort: 'media.metadata.title', minified: '1', cursor: 'cursor-1', pageMode: 'endless' }, + library: { id: 'lib_1', mediaType: 'book', isVirtual: false }, + user: { id: 'user_1' } + } + const res = { json: sinon.spy() } + + await LibraryController.getLibraryItems(req, res) + + expect(getByFilterAndSortStub.firstCall.args[2]).to.include({ + cursor: 'cursor-1', + pageMode: 'endless' + }) + expect(res.json.firstCall.args[0]).to.include({ + total: 123, + cursor: 'cursor-1', + pageMode: 'endless', + nextCursor: 'cursor-2', + paginationMode: 'keyset', + isCountDeferred: true, + countMode: 'deferred-exact' + }) + }) + + it('defaults pageMode to paged even when a cursor is present', async () => { + const getByFilterAndSortStub = sinon.stub(Database.libraryItemModel, 'getByFilterAndSort').resolves({ + libraryItems: [], + count: 0, + nextCursor: null, + paginationMode: 'offset', + countMode: 'exact-on-initial-page', + isCountDeferred: false + }) + const req = { + query: { limit: '40', sort: 'media.metadata.title', cursor: 'cursor-1' }, + library: { id: 'lib_1', mediaType: 'book', isVirtual: false }, + user: { id: 'user_1' } + } + const res = { json: sinon.spy() } + + await LibraryController.getLibraryItems(req, res) + + expect(getByFilterAndSortStub.firstCall.args[2]).to.include({ + cursor: 'cursor-1', + pageMode: 'paged' + }) + expect(res.json.firstCall.args[0]).to.include({ + cursor: 'cursor-1', + pageMode: 'paged' + }) + }) + + it('forwards cursor, pageMode, and collapseseries through the real browse stack', async () => { + const getFilteredLibraryItemsStub = sinon.stub(libraryFilters, 'getFilteredLibraryItems').resolves({ + libraryItems: [], + count: 0, + nextCursor: 'cursor-2', + paginationMode: 'keyset', + countMode: 'deferred-exact', + isCountDeferred: true + }) + + await Database.libraryItemModel.getByFilterAndSort( + { id: 'lib_1', mediaType: 'book' }, + { id: 'user_1' }, + { + sortBy: 'media.metadata.title', + limit: 40, + offset: 0, + cursor: 'cursor-1', + pageMode: 'endless', + include: [], + collapseseries: true, + mediaType: 'book' + } + ) + + expect(getFilteredLibraryItemsStub.firstCall.args[2]).to.include({ + cursor: 'cursor-1', + pageMode: 'endless', + collapseseries: true + }) + }) +})