diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 259b31ae..189f2c83 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -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` diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 6bb5a401..d837bf90 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -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 diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index c252dc8b..db55521d 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -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) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c94f3ea4..a52fbb9b 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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)) // diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 104de734..6b2fb2cb 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -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 + })) } }