Replace old items filter/sort api endpoint with new, handle collapse sub-series

This commit is contained in:
advplyr 2023-09-04 15:26:07 -05:00
parent b1c07834be
commit 03115e5e53
5 changed files with 153 additions and 395 deletions

View File

@ -314,11 +314,6 @@ export default {
}
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
// TODO: Temp use new library items API for everything except collapse sub-series
if (entityPath === 'items' && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) {
entityPath += '2'
}
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`

View File

@ -219,7 +219,7 @@ export default {
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
return this.series?.sequence || null
},
libraryId() {
return this._libraryItem.libraryId

View File

@ -262,7 +262,7 @@ class LibraryController {
return res.json(libraryJson)
}
async getLibraryItemsNew(req, res) {
async getLibraryItems(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
@ -280,203 +280,15 @@ class LibraryController {
}
payload.offset = payload.page * payload.limit
const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
payload.results = libraryItems
payload.total = count
res.json(payload)
}
/**
* GET: /api/libraries/:id/items
* TODO: Remove after implementing getLibraryItemsNew
* @param {*} req
* @param {*} res
*/
async getLibraryItems(req, res) {
let libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
results: [],
total: libraryItems.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
mediaType: req.library.mediaType,
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1',
include: include.join(',')
// TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries
if (payload.filterBy?.split('.')[0] === 'series' && payload.collapseseries) {
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)
payload.results = libraryItems
payload.total = count
}
const mediaIsBook = payload.mediaType === 'book'
const mediaIsPodcast = payload.mediaType === 'podcast'
// Step 1 - Filter the retrieved library items
let filterSeries = null
if (payload.filterBy) {
libraryItems = await libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
payload.total = libraryItems.length
// Determining if we are filtering titles by a series, and if so, which series
filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
if (filterSeries === 'no-series') filterSeries = null
}
// Step 2 - If selected, collapse library items by the series they belong to.
// If also filtering by series, will not collapse the filtered series as this would lead
// to series having a collapsed series that is just that series.
if (payload.collapseseries) {
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, Database.series, filterSeries, req.library.settings.hideSingleBookSeries)
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
libraryItems = collapsedItems
payload.total = libraryItems.length
}
}
// Step 3 - Sort the retrieved library items.
const sortArray = []
// When on the series page, sort by sequence only
if (filterSeries && !payload.sortBy) {
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
sortArray.push({
asc: (li) => {
if (Database.serverSettings.sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
} else {
return li.collapsedSeries?.name || li.media.metadata.title
}
}
})
}
if (payload.sortBy) {
let sortKey = payload.sortBy
// Handle server setting sortingIgnorePrefix
const sortByTitle = sortKey === 'media.metadata.title'
if (sortByTitle && Database.serverSettings.sortingIgnorePrefix) {
// BookMetadata.js has titleIgnorePrefix getter
sortKey += 'IgnorePrefix'
}
// If series are collapsed and not sorting by title or sequence,
// sort all collapsed series to the end in alphabetical order
const sortBySequence = filterSeries && (sortKey === 'sequence')
if (payload.collapseseries && !(sortByTitle || sortBySequence)) {
sortArray.push({
asc: (li) => {
if (li.collapsedSeries) {
return Database.serverSettings.sortingIgnorePrefix ?
li.collapsedSeries.nameIgnorePrefix :
li.collapsedSeries.name
} else {
return ''
}
}
})
}
// Sort series based on the sortBy attribute
const direction = payload.sortDesc ? 'desc' : 'asc'
sortArray.push({
[direction]: (li) => {
if (mediaIsBook && sortBySequence) {
return li.media.metadata.getSeries(filterSeries).sequence
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
return Database.serverSettings.sortingIgnorePrefix ?
li.collapsedSeries.nameIgnorePrefix :
li.collapsedSeries.name
} else {
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
}
}
})
// Secondary sort when sorting by book author use series sort title
if (mediaIsBook && payload.sortBy.includes('author')) {
sortArray.push({
asc: (li) => {
if (li.media.metadata.series && li.media.metadata.series.length) {
return li.media.metadata.getSeriesSortTitle(li.media.metadata.series[0])
}
return null
}
})
}
}
if (sortArray.length) {
libraryItems = naturalSort(libraryItems).by(sortArray)
}
// Step 3.5: Limit items
if (payload.limit) {
const startIndex = payload.page * payload.limit
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
// Step 4 - Transform the items to pass to the client side
payload.results = await Promise.all(libraryItems.map(async li => {
const json = payload.minified ? li.toJSONMinified() : li.toJSON()
if (li.collapsedSeries) {
json.collapsedSeries = {
id: li.collapsedSeries.id,
name: li.collapsedSeries.name,
nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
libraryItemIds: li.collapsedSeries.books.map(b => b.id),
numBooks: li.collapsedSeries.books.length
}
// If collapsing by series and filtering by a series, generate the list of sequences the collapsed
// series represents in the filtered series
if (filterSeries) {
json.collapsedSeries.seriesSequenceList =
naturalSort(li.collapsedSeries.books.filter(b => b.filterSeriesSequence).map(b => b.filterSeriesSequence)).asc()
.reduce((ranges, currentSequence) => {
let lastRange = ranges.at(-1)
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
if (isNumber) currentSequence = parseFloat(currentSequence)
if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
lastRange.end = currentSequence
}
else {
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
}
return ranges
}, [])
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
.join(', ')
}
} else {
// add rssFeed object if "include=rssfeed" was put in query string (only for non-collapsed series)
if (include.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(json.id)
json.rssFeed = feedData ? feedData.toJSONMinified() : null
}
// add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
if (mediaIsPodcast && include.includes('numepisodesincomplete')) {
json.numEpisodesIncomplete = req.user.getNumEpisodesIncompleteForPodcast(li)
}
if (filterSeries) {
// If filtering by series, make sure to include the series metadata
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
}
}
return json
}))
res.json(payload)
}
@ -885,7 +697,7 @@ class LibraryController {
return res.sendStatus(403)
}
const narratorName = libraryHelpers.decode(req.params.narratorId)
const narratorName = libraryFilters.decode(req.params.narratorId)
const updatedName = req.body.name
if (!updatedName) {
return res.status(400).send('Invalid request payload. Name not specified.')
@ -932,7 +744,7 @@ class LibraryController {
return res.sendStatus(403)
}
const narratorName = libraryHelpers.decode(req.params.narratorId)
const narratorName = libraryFilters.decode(req.params.narratorId)
// Update filter data
Database.removeNarratorFromFilterData(narratorName)
@ -1030,36 +842,13 @@ class LibraryController {
res.send(opmlText)
}
/**
* TODO: Replace with middlewareNew
* @param {*} req
* @param {*} res
* @param {*} next
*/
async middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
return res.sendStatus(403)
}
const library = await Database.libraryModel.getOldById(req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
req.library = library
req.libraryItems = Database.libraryItems.filter(li => {
return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
})
next()
}
/**
* Middleware that is not using libraryItems from memory
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async middlewareNew(req, res, next) {
async middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
return res.sendStatus(403)

View File

@ -71,30 +71,29 @@ class ApiRouter {
//
this.router.post('/libraries', LibraryController.create.bind(this))
this.router.get('/libraries', LibraryController.findAll.bind(this))
this.router.get('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.findOne.bind(this))
this.router.patch('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.update.bind(this))
this.router.delete('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.delete.bind(this))
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
this.router.get('/libraries/:id/items2', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryItemsNew.bind(this))
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.delete('/libraries/:id/issues', LibraryController.middlewareNew.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series/:seriesId', LibraryController.middlewareNew.bind(this), LibraryController.getSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/playlists', LibraryController.middlewareNew.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middlewareNew.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this))
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this))
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.bind(this), LibraryController.getRecentEpisodes.bind(this))
this.router.get('/libraries/:id/opml', LibraryController.middlewareNew.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
//

View File

@ -6,126 +6,7 @@ const naturalSort = createNewSortInstance({
})
module.exports = {
decode(text) {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
async getFilteredLibraryItems(libraryItems, filterBy, user) {
let filtered = libraryItems
const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
const filterVal = filterBy.replace(`${group}.`, '')
const filter = this.decode(filterVal)
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata.genres?.includes(filter))
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') {
if (filter === 'no-series') filtered = filtered.filter(li => li.isBook && !li.media.metadata.series.length)
else {
filtered = filtered.filter(li => li.isBook && li.media.metadata.hasSeries(filter))
}
}
else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter))
else if (group === 'publishers') filtered = filtered.filter(li => li.isBook && li.media.metadata.publisher === filter)
else if (group === 'progress') {
filtered = filtered.filter(li => {
const itemProgress = user.getMediaProgress(li.id)
if (filter === 'finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'not-started' && (!itemProgress || itemProgress.notStarted)) return true
if (filter === 'not-finished' && (!itemProgress || !itemProgress.isFinished)) return true
if (filter === 'in-progress' && (itemProgress && itemProgress.inProgress)) return true
return false
})
} else if (group == 'missing') {
filtered = filtered.filter(li => {
if (li.isBook) {
if (filter === 'asin' && !li.media.metadata.asin) return true
if (filter === 'isbn' && !li.media.metadata.isbn) return true
if (filter === 'subtitle' && !li.media.metadata.subtitle) return true
if (filter === 'authors' && !li.media.metadata.authors.length) return true
if (filter === 'publishedYear' && !li.media.metadata.publishedYear) return true
if (filter === 'series' && !li.media.metadata.series.length) return true
if (filter === 'description' && !li.media.metadata.description) return true
if (filter === 'genres' && !li.media.metadata.genres.length) return true
if (filter === 'tags' && !li.media.tags.length) return true
if (filter === 'narrators' && !li.media.metadata.narrators.length) return true
if (filter === 'publisher' && !li.media.metadata.publisher) return true
if (filter === 'language' && !li.media.metadata.language) return true
if (filter === 'cover' && !li.media.coverPath) return true
} else {
return false
}
})
} else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata.language === filter)
} else if (group === 'tracks') {
if (filter === 'none') filtered = filtered.filter(li => li.isBook && !li.media.numTracks)
else if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
} else if (group === 'ebooks') {
if (filter === 'ebook') filtered = filtered.filter(li => li.media.ebookFile)
else if (filter === 'supplementary') filtered = filtered.filter(li => li.libraryFiles.some(lf => lf.isEBookFile && lf.ino !== li.media.ebookFile?.ino))
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(li => li.hasIssues)
} else if (filterBy === 'feed-open') {
const libraryItemIdsWithFeed = await Database.feedModel.findAllLibraryItemIds()
filtered = filtered.filter(li => libraryItemIdsWithFeed.includes(li.id))
} else if (filterBy === 'abridged') {
filtered = filtered.filter(li => !!li.media.metadata?.abridged)
} else if (filterBy === 'ebook') {
filtered = filtered.filter(li => li.media.ebookFile)
}
return filtered
},
// Returns false if should be filtered out
checkFilterForSeriesLibraryItem(libraryItem, filterBy) {
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
const filterVal = filterBy.replace(`${group}.`, '')
const filter = this.decode(filterVal)
if (group === 'genres') return libraryItem.media.metadata.genres.includes(filter)
else if (group === 'tags') return libraryItem.media.tags.includes(filter)
else if (group === 'authors') return libraryItem.isBook && libraryItem.media.metadata.hasAuthor(filter)
else if (group === 'narrators') return libraryItem.isBook && libraryItem.media.metadata.hasNarrator(filter)
else if (group === 'publishers') return libraryItem.isBook && libraryItem.media.metadata.publisher === filter
else if (group === 'languages') {
return libraryItem.media.metadata.language === filter
}
}
return true
},
// Return false to filter out series
checkSeriesProgressFilter(series, filterBy, user) {
const filter = this.decode(filterBy.split('.')[1])
let someBookHasProgress = false
let someBookIsUnfinished = false
for (const libraryItem of series.books) {
const itemProgress = user.getMediaProgress(libraryItem.id)
if (!itemProgress || !itemProgress.isFinished) someBookIsUnfinished = true
if (itemProgress && itemProgress.progress > 0) someBookHasProgress = true
if (filter === 'finished' && (!itemProgress || !itemProgress.isFinished)) return false
if (filter === 'not-started' && itemProgress) return false
}
if (!someBookIsUnfinished && (filter === 'not-finished' || filter === 'in-progress')) { // Completely finished series
return false
} else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
return false
}
return true
},
getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified, hideSingleBookSeries) {
getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) {
const _series = {}
const seriesToFilterOut = {}
books.forEach((libraryItem) => {
@ -133,23 +14,10 @@ module.exports = {
const bookSeries = (libraryItem.media.metadata.series || []).filter(se => !seriesToFilterOut[se.id])
if (!bookSeries.length) return
if (filterBy && user && !filterBy.startsWith('progress.')) { // Series progress filters are evaluated after grouping
// If a single book in a series is filtered out then filter out the entire series
if (!this.checkFilterForSeriesLibraryItem(libraryItem, filterBy)) {
// filter out this library item
bookSeries.forEach((bookSeriesObj) => {
// flag series to filter it out
seriesToFilterOut[bookSeriesObj.id] = true
delete _series[bookSeriesObj.id]
})
return
}
}
bookSeries.forEach((bookSeriesObj) => {
const series = allSeries.find(se => se.id === bookSeriesObj.id)
// const series = allSeries.find(se => se.id === bookSeriesObj.id)
const abJson = minified ? libraryItem.toJSONMinified() : libraryItem.toJSONExpanded()
const abJson = libraryItem.toJSONMinified()
abJson.sequence = bookSeriesObj.sequence
if (filterSeries) {
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
@ -162,10 +30,8 @@ module.exports = {
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
type: 'series',
books: [abJson],
addedAt: series ? series.addedAt : 0,
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
}
} else {
_series[bookSeriesObj.id].books.push(abJson)
_series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
@ -180,22 +46,17 @@ module.exports = {
seriesItems = seriesItems.filter(se => se.books.length > 1)
}
// check progress filter
if (filterBy && filterBy.startsWith('progress.') && user) {
seriesItems = seriesItems.filter(se => this.checkSeriesProgressFilter(se, filterBy, user))
}
return seriesItems.map((series) => {
series.books = naturalSort(series.books).asc(li => li.sequence)
return series
})
},
collapseBookSeries(libraryItems, series, filterSeries, hideSingleBookSeries) {
collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
// Get series from the library items. If this list is being collapsed after filtering for a series,
// don't collapse that series, only books that are in other series.
const seriesObjects = this
.getSeriesFromBooks(libraryItems, series, filterSeries, null, null, true, hideSingleBookSeries)
.getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries)
.filter(s => s.id != filterSeries)
const filteredLibraryItems = []
@ -218,5 +79,119 @@ module.exports = {
})
return filteredLibraryItems
},
async handleCollapseSubseries(payload, seriesId, user, library) {
const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
include: {
model: Database.bookModel,
through: {
attributes: ['sequence']
},
include: [
{
model: Database.libraryItemModel
},
{
model: Database.authorModel,
through: {
attributes: []
}
},
{
model: Database.seriesModel,
through: {
attributes: ['sequence']
}
}
]
}
})
if (!seriesWithBooks) {
payload.total = 0
return []
}
const books = seriesWithBooks.books
payload.total = books.length
let libraryItems = books.map((book) => {
const libraryItem = book.libraryItem
libraryItem.media = book
return Database.libraryItemModel.getOldLibraryItem(libraryItem)
}).filter(li => {
return user.checkCanAccessLibraryItem(li)
})
const collapsedItems = this.collapseBookSeries(libraryItems, seriesId, library.settings.hideSingleBookSeries)
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
libraryItems = collapsedItems
payload.total = libraryItems.length
}
const sortArray = [
{
asc: (li) => li.media.metadata.getSeries(seriesId).sequence
},
{ // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
asc: (li) => {
if (Database.serverSettings.sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
} else {
return li.collapsedSeries?.name || li.media.metadata.title
}
}
}
]
libraryItems = naturalSort(libraryItems).by(sortArray)
if (payload.limit) {
const startIndex = payload.page * payload.limit
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
return Promise.all(libraryItems.map(async li => {
const filteredSeries = li.media.metadata.getSeries(seriesId)
const json = li.toJSONMinified()
json.media.metadata.series = {
id: filteredSeries.id,
sequence: filteredSeries.sequence
}
if (li.collapsedSeries) {
json.collapsedSeries = {
id: li.collapsedSeries.id,
name: li.collapsedSeries.name,
nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
libraryItemIds: li.collapsedSeries.books.map(b => b.id),
numBooks: li.collapsedSeries.books.length
}
// If collapsing by series and filtering by a series, generate the list of sequences the collapsed
// series represents in the filtered series
json.collapsedSeries.seriesSequenceList =
naturalSort(li.collapsedSeries.books.filter(b => b.filterSeriesSequence).map(b => b.filterSeriesSequence)).asc()
.reduce((ranges, currentSequence) => {
let lastRange = ranges.at(-1)
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
if (isNumber) currentSequence = parseFloat(currentSequence)
if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
lastRange.end = currentSequence
}
else {
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
}
return ranges
}, [])
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
.join(', ')
}
return json
}))
}
}