From 6788943087456fc535f4c2cd2f75832001370fdb Mon Sep 17 00:00:00 2001 From: Jonathan Finley Date: Fri, 13 Mar 2026 22:06:46 -0400 Subject: [PATCH] fix: normalize book browse cursor values --- .../utils/queries/libraryItemsBookFilters.js | 55 ++++++++++++++----- ...braryController.largeLibraryBrowse.test.js | 32 +++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index b8a4f89ed..8ba01f6fb 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -11,20 +11,47 @@ const { loadBrowseCount } = require('./libraryBrowseCount') const { encodeBrowseCursor, decodeBrowseCursor } = require('./libraryBrowseCursor') const countCache = new Map() -function getCursorFieldPath(key) { +function normalizeCursorValue(key, value) { + if (value == null) return value + + if (['title', 'titleIgnorePrefix', 'authorNamesFirstLast', 'authorNamesLastFirst'].includes(key) && typeof value === 'string') { + return value.toLowerCase() + } + + return value +} + +function getQuotedCursorColumn(key) { + const dialect = Database.getDialect() + const getColumnRef = (table, column) => { + return dialect === 'postgres' + ? `"${table}"."${column}"` + : `\`${table}\`.\`${column}\`` + } + if (['title', 'titleIgnorePrefix', 'authorNamesFirstLast', 'authorNamesLastFirst', 'createdAt', 'updatedAt'].includes(key)) { - return `$libraryItem.${key}$` + return getColumnRef('libraryItem', key) } if (key === 'id') { - return '$libraryItem.id$' + return getColumnRef('libraryItem', 'id') } if (key.startsWith('mediaProgresses.')) { - return `$${key}$` + return key.split('.').map((segment) => `"${segment}"`).join('.') } - return `$${key}$` + return key +} + +function getCursorComparisonExpression(key) { + const quotedColumn = getQuotedCursorColumn(key) + + if (['title', 'titleIgnorePrefix', 'authorNamesFirstLast', 'authorNamesLastFirst'].includes(key)) { + return Sequelize.literal(dialectHelpers.getCaseInsensitiveOrder(Database.getDialect(), quotedColumn, false)) + } + + return Sequelize.literal(quotedColumn) } function getCursorValue(book, key) { @@ -38,10 +65,10 @@ function getCursorValue(book, key) { } if (book.libraryItem && Object.prototype.hasOwnProperty.call(book.libraryItem.dataValues || {}, key)) { - return book.libraryItem[key] + return normalizeCursorValue(key, book.libraryItem[key]) } - return book[key] ?? null + return normalizeCursorValue(key, book[key] ?? null) } function buildBookCursorClause(cursor, cursorKeys, sortDesc) { @@ -60,17 +87,19 @@ function buildBookCursorClause(cursor, cursorKeys, sortDesc) { const comparisonOperator = sortDesc ? Sequelize.Op.lt : Sequelize.Op.gt const cursorConditions = decodedCursor.keys.map((key, index) => { - const condition = {} + const condition = [] for (let cursorIndex = 0; cursorIndex < index; cursorIndex++) { - condition[getCursorFieldPath(decodedCursor.keys[cursorIndex])] = decodedCursor.values[cursorIndex] + condition.push(Sequelize.where(getCursorComparisonExpression(decodedCursor.keys[cursorIndex]), normalizeCursorValue(decodedCursor.keys[cursorIndex], decodedCursor.values[cursorIndex]))) } - condition[getCursorFieldPath(key)] = { - [comparisonOperator]: decodedCursor.values[index] - } + condition.push(Sequelize.where(getCursorComparisonExpression(key), { + [comparisonOperator]: normalizeCursorValue(key, decodedCursor.values[index]) + })) - return condition + return { + [Sequelize.Op.and]: condition + } }) return { diff --git a/test/server/controllers/LibraryController.largeLibraryBrowse.test.js b/test/server/controllers/LibraryController.largeLibraryBrowse.test.js index a49f9e2c6..1ce58d598 100644 --- a/test/server/controllers/LibraryController.largeLibraryBrowse.test.js +++ b/test/server/controllers/LibraryController.largeLibraryBrowse.test.js @@ -5,6 +5,7 @@ const sinon = require('sinon') const Database = require('../../../server/Database') const LibraryController = require('../../../server/controllers/LibraryController') const libraryFilters = require('../../../server/utils/queries/libraryFilters') +const { decodeBrowseCursor } = require('../../../server/utils/queries/libraryBrowseCursor') const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy') const libraryItemsBookFilters = require('../../../server/utils/queries/libraryItemsBookFilters') const libraryItemsPodcastFilters = require('../../../server/utils/queries/libraryItemsPodcastFilters') @@ -290,6 +291,37 @@ describe('LibraryController large-library browse contract', () => { expect(findAndCountAllStub.called).to.equal(false) }) + it('encodes case-insensitive title cursor values to match the executed browse order', async () => { + global.ServerSettings = { sortingIgnorePrefix: true } + + sinon.stub(Database.bookModel, 'count').resolves(123) + sinon.stub(Database.bookModel, 'findAndCountAll').resolves({ rows: [], count: 123 }) + sinon.stub(Database.bookModel, 'findAll').resolves([ + { id: 'book-1', title: 'Alpha', libraryItem: { id: 'item-1', titleIgnorePrefix: 'Alpha', dataValues: { titleIgnorePrefix: 'Alpha' } } }, + { id: 'book-2', title: 'aLPHa', libraryItem: { id: 'item-2', titleIgnorePrefix: 'aLPHa', dataValues: { titleIgnorePrefix: 'aLPHa' } } }, + { id: 'book-3', title: 'Beta', libraryItem: { id: 'item-3', titleIgnorePrefix: 'Beta', dataValues: { titleIgnorePrefix: 'Beta' } } } + ]) + + const result = await libraryItemsBookFilters.getFilteredLibraryItems( + 'lib_1', + { id: 'user_1', canAccessExplicitContent: true, accessAllTags: true }, + null, + null, + 'media.metadata.title', + false, + false, + [], + 2, + 0, + false, + { pageMode: 'endless' } + ) + + const decodedCursor = decodeBrowseCursor(result.nextCursor) + + expect(decodedCursor.values).to.deep.equal(['alpha', 'item-2']) + }) + it('defaults omitted sort to deterministic title ordering for endless browse requests', async () => { global.ServerSettings = { sortingIgnorePrefix: true }