mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 18:31:43 +00:00
fix: restore collapsed-series browse payloads
This commit is contained in:
parent
394f2e2254
commit
168844e7a8
4 changed files with 208 additions and 16 deletions
|
|
@ -640,7 +640,7 @@ class LibraryController {
|
||||||
}
|
}
|
||||||
const { libraryItems, count } = await libraryItemsBookFilters.getCollapsedSeriesWindow(req.library.id, seriesId, req.user, include, payload, collapsedSeriesWindow)
|
const { libraryItems, count } = await libraryItemsBookFilters.getCollapsedSeriesWindow(req.library.id, seriesId, req.user, include, payload, collapsedSeriesWindow)
|
||||||
payload.total = count
|
payload.total = count
|
||||||
payload.results = await libraryHelpers.toCollapsedSeriesPayload(libraryItems, seriesId)
|
payload.results = await libraryHelpers.toCollapsedSeriesPayload(libraryItems, seriesId, req.library.settings.hideSingleBookSeries)
|
||||||
} else {
|
} else {
|
||||||
const { libraryItems, count, nextCursor, paginationMode, countMode, isCountDeferred } = 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.results = libraryItems
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
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(
|
return Promise.all(
|
||||||
libraryItems.map(async (libraryItem) => {
|
shapedItems.map(async (libraryItem) => {
|
||||||
const filteredSeries = libraryItem.media.series.find((series) => series.id === seriesId)
|
const filteredSeries = libraryItem.media.series.find((series) => series.id === seriesId)
|
||||||
const json = libraryItem.toOldJSONMinified()
|
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
|
return json
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -917,13 +917,14 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
|
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
|
||||||
|
const collapsedSortBy = payload.sortBy || 'sequence'
|
||||||
const direction = payload.sortDesc ? 'DESC' : 'ASC'
|
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(`${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 = {
|
const findOptions = {
|
||||||
where: userPermissionBookWhere.bookWhere,
|
where: userPermissionBookWhere.bookWhere,
|
||||||
|
|
@ -949,6 +950,13 @@ module.exports = {
|
||||||
attributes: ['id', 'name', 'nameIgnorePrefix']
|
attributes: ['id', 'name', 'nameIgnorePrefix']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: Database.seriesModel,
|
||||||
|
attributes: ['id', 'name', 'nameIgnorePrefix'],
|
||||||
|
through: {
|
||||||
|
attributes: ['sequence']
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
model: Database.bookAuthorModel,
|
model: Database.bookAuthorModel,
|
||||||
attributes: ['authorId', 'createdAt'],
|
attributes: ['authorId', 'createdAt'],
|
||||||
|
|
@ -966,10 +974,28 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const countOptions = {
|
const countOptions = {
|
||||||
...findOptions,
|
where: userPermissionBookWhere.bookWhere,
|
||||||
distinct: true
|
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({
|
const { books, count } = await loadCollapsedSeriesWindow({
|
||||||
findOptions,
|
findOptions,
|
||||||
|
|
@ -985,13 +1011,7 @@ module.exports = {
|
||||||
|
|
||||||
delete book.libraryItem
|
delete book.libraryItem
|
||||||
|
|
||||||
book.series =
|
book.series = book.series || []
|
||||||
book.bookSeries?.map((bs) => {
|
|
||||||
const series = bs.series
|
|
||||||
delete bs.series
|
|
||||||
series.bookSeries = bs
|
|
||||||
return series
|
|
||||||
}) || []
|
|
||||||
delete book.bookSeries
|
delete book.bookSeries
|
||||||
|
|
||||||
book.authors = book.bookAuthors?.map((ba) => ba.author) || []
|
book.authors = book.bookAuthors?.map((ba) => ba.author) || []
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const { decodeBrowseCursor } = require('../../../server/utils/queries/libraryBro
|
||||||
const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy')
|
const { getLibraryBrowseStrategy } = require('../../../server/utils/queries/libraryBrowseStrategy')
|
||||||
const libraryItemsBookFilters = require('../../../server/utils/queries/libraryItemsBookFilters')
|
const libraryItemsBookFilters = require('../../../server/utils/queries/libraryItemsBookFilters')
|
||||||
const libraryItemsPodcastFilters = require('../../../server/utils/queries/libraryItemsPodcastFilters')
|
const libraryItemsPodcastFilters = require('../../../server/utils/queries/libraryItemsPodcastFilters')
|
||||||
|
const libraryHelpers = require('../../../server/utils/libraryHelpers')
|
||||||
|
|
||||||
describe('LibraryController large-library browse contract', () => {
|
describe('LibraryController large-library browse contract', () => {
|
||||||
const buildBookPredicateSql = (where, replacements = {}) => {
|
const buildBookPredicateSql = (where, replacements = {}) => {
|
||||||
|
|
@ -321,6 +322,68 @@ describe('LibraryController large-library browse contract', () => {
|
||||||
expect(findAllStub.firstCall.args[0].offset).to.equal(20)
|
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 () => {
|
it('adds a deterministic libraryItem.id tie-breaker for keyset title browse with duplicate titles', async () => {
|
||||||
global.ServerSettings = { sortingIgnorePrefix: true }
|
global.ServerSettings = { sortingIgnorePrefix: true }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue