diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 97cf6fbd5..9b9a63760 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -640,7 +640,7 @@ class LibraryController { } const { libraryItems, count } = await libraryItemsBookFilters.getCollapsedSeriesWindow(req.library.id, seriesId, req.user, include, payload, collapsedSeriesWindow) payload.total = count - payload.results = await libraryHelpers.toCollapsedSeriesPayload(libraryItems, seriesId) + payload.results = await libraryHelpers.toCollapsedSeriesPayload(libraryItems, seriesId, req.library.settings.hideSingleBookSeries) } else { const { libraryItems, count, nextCursor, paginationMode, countMode, isCountDeferred } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload) payload.results = libraryItems diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 602aba7da..1baee56e6 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -1,7 +1,101 @@ +const { createNewSortInstance } = require('../libs/fastSort') +const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index') + +const naturalSort = createNewSortInstance({ + comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare +}) + +function getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) { + const seriesById = {} + + libraryItems.forEach((libraryItem) => { + const allBookSeries = libraryItem.media.series || [] + if (!allBookSeries.length) return + + allBookSeries.forEach((bookSeries) => { + const itemJson = libraryItem.toOldJSONMinified() + itemJson.sequence = bookSeries.bookSeries.sequence + if (filterSeries) { + const filteredSeries = libraryItem.media.series.find((series) => series.id === filterSeries) + itemJson.filterSeriesSequence = filteredSeries?.bookSeries?.sequence + } + + if (!seriesById[bookSeries.id]) { + seriesById[bookSeries.id] = { + id: bookSeries.id, + name: bookSeries.name, + nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name), + nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name), + type: 'series', + books: [itemJson], + totalDuration: isNullOrNaN(itemJson.media.duration) ? 0 : Number(itemJson.media.duration) + } + } else { + seriesById[bookSeries.id].books.push(itemJson) + seriesById[bookSeries.id].totalDuration += isNullOrNaN(itemJson.media.duration) ? 0 : Number(itemJson.media.duration) + } + }) + }) + + let seriesItems = Object.values(seriesById) + if (hideSingleBookSeries) { + seriesItems = seriesItems.filter((series) => series.books.length > 1) + } + + return seriesItems.map((series) => { + series.books = naturalSort(series.books).asc((libraryItem) => libraryItem.sequence) + return series + }) +} + +function collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) { + const seriesObjects = getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries).filter((series) => series.id !== filterSeries) + const filteredLibraryItems = [] + + libraryItems.forEach((libraryItem) => { + if (libraryItem.mediaType && libraryItem.mediaType !== 'book') return + + seriesObjects + .filter((series) => series.books[0].id === libraryItem.id) + .forEach((series) => { + filteredLibraryItems.push(Object.assign(Object.create(Object.getPrototypeOf(libraryItem)), libraryItem, { collapsedSeries: series })) + }) + + if (!seriesObjects.some((series) => series.books.some((book) => book.id === libraryItem.id))) { + filteredLibraryItems.push(libraryItem) + } + }) + + return filteredLibraryItems +} + +function getSeriesSequenceList(collapsedSeries) { + return naturalSort(collapsedSeries.books.filter((book) => book.filterSeriesSequence).map((book) => book.filterSeriesSequence)) + .asc() + .reduce((ranges, currentSequence) => { + const lastRange = ranges.at(-1) + const isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence) + const normalizedSequence = isNumber ? parseFloat(currentSequence) : currentSequence + + if (lastRange && isNumber && lastRange.isNumber && lastRange.end + 1 === normalizedSequence) { + lastRange.end = normalizedSequence + } else { + ranges.push({ start: normalizedSequence, end: normalizedSequence, isNumber }) + } + + return ranges + }, []) + .map((range) => (range.start === range.end ? range.start : `${range.start}-${range.end}`)) + .join(', ') +} + module.exports = { - async toCollapsedSeriesPayload(libraryItems, seriesId) { + async toCollapsedSeriesPayload(libraryItems, seriesId, hideSingleBookSeries = false) { + const collapsedItems = collapseBookSeries(libraryItems, seriesId, hideSingleBookSeries) + const shapedItems = !(collapsedItems.length === 1 && collapsedItems[0].collapsedSeries) ? collapsedItems : libraryItems + return Promise.all( - libraryItems.map(async (libraryItem) => { + shapedItems.map(async (libraryItem) => { const filteredSeries = libraryItem.media.series.find((series) => series.id === seriesId) const json = libraryItem.toOldJSONMinified() @@ -13,6 +107,21 @@ module.exports = { } } + if (libraryItem.collapsedSeries) { + json.collapsedSeries = { + id: libraryItem.collapsedSeries.id, + name: libraryItem.collapsedSeries.name, + nameIgnorePrefix: libraryItem.collapsedSeries.nameIgnorePrefix, + libraryItemIds: libraryItem.collapsedSeries.books.map((book) => book.id), + numBooks: libraryItem.collapsedSeries.books.length, + seriesSequenceList: getSeriesSequenceList(libraryItem.collapsedSeries) + } + } + + if (libraryItem.rssFeed?.toOldJSONMinified) { + json.rssFeed = libraryItem.rssFeed.toOldJSONMinified() + } + return json }) ) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index e76ed3438..accd9f419 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -917,13 +917,14 @@ module.exports = { } const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) + const collapsedSortBy = payload.sortBy || 'sequence' const direction = payload.sortDesc ? 'DESC' : 'ASC' - const sortOrder = payload.sortBy === 'sequence' + const sortOrder = collapsedSortBy === 'sequence' ? [ [Sequelize.literal(`${dialectHelpers.getSafeSequenceCast(Database.getDialect(), '"bookSeries"."sequence"')} ${direction} NULLS LAST`)], - [Sequelize.literal('"libraryItem"."id"'), 'ASC'] + ...this.getOrder('media.metadata.title', payload.sortDesc, false) ] - : this.getOrder(payload.sortBy, payload.sortDesc, false) + : this.getOrder(collapsedSortBy, payload.sortDesc, false) const findOptions = { where: userPermissionBookWhere.bookWhere, @@ -949,6 +950,13 @@ module.exports = { attributes: ['id', 'name', 'nameIgnorePrefix'] } }, + { + model: Database.seriesModel, + attributes: ['id', 'name', 'nameIgnorePrefix'], + through: { + attributes: ['sequence'] + } + }, { model: Database.bookAuthorModel, attributes: ['authorId', 'createdAt'], @@ -966,10 +974,28 @@ module.exports = { } const countOptions = { - ...findOptions, - distinct: true + where: userPermissionBookWhere.bookWhere, + replacements: userPermissionBookWhere.replacements, + include: [ + { + model: Database.libraryItemModel, + required: true, + where: { + libraryId + }, + include: libraryItemIncludes + }, + { + model: Database.bookSeriesModel, + required: true, + where: { + seriesId + } + } + ], + distinct: true, + subQuery: false } - delete countOptions.order const { books, count } = await loadCollapsedSeriesWindow({ findOptions, @@ -985,13 +1011,7 @@ module.exports = { delete book.libraryItem - book.series = - book.bookSeries?.map((bs) => { - const series = bs.series - delete bs.series - series.bookSeries = bs - return series - }) || [] + book.series = book.series || [] delete book.bookSeries book.authors = book.bookAuthors?.map((ba) => ba.author) || [] diff --git a/test/server/controllers/LibraryController.largeLibraryBrowse.test.js b/test/server/controllers/LibraryController.largeLibraryBrowse.test.js index a4e2e88aa..d04b78b44 100644 --- a/test/server/controllers/LibraryController.largeLibraryBrowse.test.js +++ b/test/server/controllers/LibraryController.largeLibraryBrowse.test.js @@ -9,6 +9,7 @@ const { decodeBrowseCursor } = require('../../../server/utils/queries/libraryBro const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy') const libraryItemsBookFilters = require('../../../server/utils/queries/libraryItemsBookFilters') const libraryItemsPodcastFilters = require('../../../server/utils/queries/libraryItemsPodcastFilters') +const libraryHelpers = require('../../../server/utils/libraryHelpers') describe('LibraryController large-library browse contract', () => { const buildBookPredicateSql = (where, replacements = {}) => { @@ -321,6 +322,68 @@ describe('LibraryController large-library browse contract', () => { expect(findAllStub.firstCall.args[0].offset).to.equal(20) }) + it('restores collapsed-series payload fields and preserves rssfeed serialization', async () => { + const payload = await libraryHelpers.toCollapsedSeriesPayload([ + { + media: { + series: [{ id: 'series_1', name: 'Main Series', bookSeries: { sequence: '5' } }] + }, + collapsedSeries: { + id: 'subseries_1', + name: 'Subseries', + nameIgnorePrefix: 'Subseries', + books: [ + { id: 'li_2', filterSeriesSequence: '2' }, + { id: 'li_3', filterSeriesSequence: '3' }, + { id: 'li_5', filterSeriesSequence: '5' } + ] + }, + rssFeed: { + toOldJSONMinified: () => ({ id: 'feed_1' }) + }, + toOldJSONMinified() { + return { + id: 'li_1', + media: { metadata: {} } + } + } + } + ], 'series_1') + + expect(payload[0].media.metadata.series).to.deep.equal({ + id: 'series_1', + name: 'Main Series', + sequence: '5' + }) + expect(payload[0].collapsedSeries).to.deep.equal({ + id: 'subseries_1', + name: 'Subseries', + nameIgnorePrefix: 'Subseries', + libraryItemIds: ['li_2', 'li_3', 'li_5'], + numBooks: 3, + seriesSequenceList: '2-3, 5' + }) + expect(payload[0].rssFeed).to.deep.equal({ id: 'feed_1' }) + }) + + it('uses a deterministic default collapsed-series order when sort is omitted', async () => { + const findAllStub = sinon.stub(Database.bookModel, 'findAll').resolves([]) + sinon.stub(Database.bookModel, 'count').resolves(0) + + await libraryItemsBookFilters.getCollapsedSeriesWindow( + 'lib_1', + 'series_1', + { id: 'user_1', canAccessExplicitContent: true, accessAllTags: true }, + [], + { sortBy: null, sortDesc: false }, + { limit: 20, offset: 0 } + ) + + expect(findAllStub.firstCall.args[0].order).to.have.length.greaterThan(0) + expect(findAllStub.firstCall.args[0].order[0][0].val || findAllStub.firstCall.args[0].order[0][0]).to.include('bookSeries') + expect(findAllStub.firstCall.args[0].order[0][0].val || findAllStub.firstCall.args[0].order[0][0]).to.include('sequence') + }) + it('adds a deterministic libraryItem.id tie-breaker for keyset title browse with duplicate titles', async () => { global.ServerSettings = { sortingIgnorePrefix: true }