From eeaf012cdc137564c14451fb3bd544a260336732 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 30 Jul 2023 17:51:44 -0500 Subject: [PATCH] Update new library item API endpoint to handle collapse series --- client/components/app/LazyBookshelf.vue | 7 +- server/controllers/LibraryController.js | 2 +- server/models/Feed.js | 4 +- server/models/LibraryItem.js | 18 +- server/utils/queries/libraryFilters.js | 6 +- .../utils/queries/libraryItemsBookFilters.js | 211 +++++++++++++++++- 6 files changed, 232 insertions(+), 16 deletions(-) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 9206952e..056cdfea 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -313,7 +313,12 @@ export default { this.currentSFQueryString = this.buildSearchParams() } - const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName + let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName + // TODO: Temp use new library items API for everything except podcasts and collapse sub-series + if (entityPath === 'items' && !this.isPodcast && !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/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 4d2c1721..9a67a4cc 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -208,7 +208,7 @@ class LibraryController { payload.offset = payload.page * payload.limit const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library.id, req.user.id, payload) - payload.results = libraryItems.map(li => li.toJSONMinified()) + payload.results = libraryItems payload.total = count res.json(payload) diff --git a/server/models/Feed.js b/server/models/Feed.js index 87749734..a78c7723 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -17,7 +17,7 @@ module.exports = (sequelize) => { } static getOldFeed(feedExpanded) { - const episodes = feedExpanded.feedEpisodes.map((feedEpisode) => feedEpisode.getOldEpisode()) + const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) return new oldFeed({ id: feedExpanded.id, slug: feedExpanded.slug, @@ -42,7 +42,7 @@ module.exports = (sequelize) => { }, serverAddress: feedExpanded.serverAddress, feedUrl: feedExpanded.feedURL, - episodes, + episodes: episodes || [], createdAt: feedExpanded.createdAt.valueOf(), updatedAt: feedExpanded.updatedAt.valueOf() }) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index a0a066a7..7795c414 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -400,10 +400,22 @@ module.exports = (sequelize) => { }) } - static async getByFilterAndSort(libraryId, userId, { filterBy, sortBy, sortDesc, limit, offset }) { - const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, filterBy, sortBy, sortDesc, limit, offset, userId) + static async getByFilterAndSort(libraryId, userId, options) { + const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, userId, options) return { - libraryItems: libraryItems.map(ti => this.getOldLibraryItem(ti)), + libraryItems: libraryItems.map(li => { + const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() + if (li.collapsedSeries) { + oldLibraryItem.collapsedSeries = li.collapsedSeries + } + if (li.series) { + oldLibraryItem.media.metadata.series = li.series + } + if (li.rssFeed) { + oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() + } + return oldLibraryItem + }), count } } diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 2362eea8..4272b3c7 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -5,7 +5,9 @@ module.exports = { return Buffer.from(decodeURIComponent(text), 'base64').toString() }, - async getFilteredLibraryItems(libraryId, filterBy, sortBy, sortDesc, limit, offset, userId) { + async getFilteredLibraryItems(libraryId, userId, options) { + const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include } = options + let filterValue = null let filterGroup = null if (filterBy) { @@ -16,6 +18,6 @@ module.exports = { } // TODO: Handle podcast filters - return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, limit, offset) + return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 98e7c4bc..0b2d4ede 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -3,6 +3,51 @@ const Database = require('../../Database') const Logger = require('../../Logger') module.exports = { + getCollapseSeriesMediaProgressFilter(value) { + const mediaWhere = {} + if (value === 'not-finished') { + mediaWhere['$books.mediaProgresses.isFinished$'] = { + [Sequelize.Op.or]: [null, false] + } + } else if (value === 'not-started') { + mediaWhere[Sequelize.Op.and] = [ + { + '$books.mediaProgresses.currentTime$': { + [Sequelize.Op.or]: [null, 0] + } + }, + { + '$books.mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, false] + } + } + ] + } else if (value === 'finished') { + mediaWhere['$books.mediaProgresses.isFinished$'] = true + } else if (value === 'in-progress') { + mediaWhere[Sequelize.Op.and] = [ + { + [Sequelize.Op.or]: [ + { + '$books.mediaProgresses.currentTime$': { + [Sequelize.Op.gt]: 0 + } + }, + { + '$books.mediaProgresses.ebookProgress$': { + [Sequelize.Op.gt]: 0 + } + } + ] + }, + { + '$books.mediaProgresses.isFinished$': false + } + ] + } + return mediaWhere + }, + /** * Get where options for Book model * @param {string} group @@ -104,9 +149,10 @@ module.exports = { * Get sequelize order * @param {string} sortBy * @param {boolean} sortDesc + * @param {boolean} collapseseries * @returns {Sequelize.order} */ - getOrder(sortBy, sortDesc) { + getOrder(sortBy, sortDesc, collapseseries) { const dir = sortDesc ? 'DESC' : 'ASC' if (sortBy === 'addedAt') { return [[Sequelize.literal('libraryItem.createdAt'), dir]] @@ -125,15 +171,69 @@ module.exports = { } else if (sortBy === 'media.metadata.authorName') { return [['author_name', dir]] } else if (sortBy === 'media.metadata.title') { + if (collapseseries) { + return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] + } + if (global.ServerSettings.sortingIgnorePrefix) { return [['titleIgnorePrefix', dir]] } else { return [['title', dir]] } + } else if (sortBy === 'sequence') { + const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' + return [[Sequelize.literal(`\`series.bookSeries.sequence\` COLLATE NOCASE ${nullDir}`)]] } return [] }, + async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) { + const allSeries = await Database.models.series.findAll({ + attributes: [ + 'id', + 'name', + [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks'] + ], + distinct: true, + subQuery: false, + where: seriesWhere, + include: [ + { + model: Database.models.book, + attributes: ['id', 'title'], + through: { + attributes: ['id', 'seriesId', 'bookId', 'sequence'] + }, + ...bookFindOptions, + required: true + } + ], + order: [ + Sequelize.literal('`books.bookSeries.sequence` COLLATE NOCASE ASC NULLS LAST') + ] + }) + const bookSeriesToInclude = [] + const booksToInclude = [] + let booksToExclude = [] + allSeries.forEach(s => { + let found = false + for (let book of s.books) { + if (!found && !booksToInclude.includes(book.id)) { + booksToInclude.push(book.id) + bookSeriesToInclude.push({ + id: book.bookSeries.id, + numBooks: s.dataValues.numBooks + }) + booksToExclude = booksToExclude.filter(bid => bid !== book.id) + found = true + } else if (!booksToExclude.includes(book.id) && !booksToInclude.includes(book.id)) { + booksToExclude.push(book.id) + } + } + }) + return { booksToExclude, bookSeriesToInclude } + }, + /** * Get library items for book media type using filter and sort * @param {string} libraryId @@ -141,11 +241,22 @@ module.exports = { * @param {[string]} filterValue * @param {string} sortBy * @param {string} sortDesc + * @param {boolean} collapseseries + * @param {string[]} include * @param {number} limit * @param {number} offset * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, limit, offset) { + async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) { + // TODO: Handle collapse sub-series + if (filterGroup === 'series' && collapseseries) { + collapseseries = false + } + if (filterGroup !== 'series' && sortBy === 'sequence') { + sortBy = 'media.metadata.title' + } + const includeRSSFeed = include.includes('rssfeed') + // For sorting by author name an additional attribute must be added // with author names concatenated let bookAttributes = null @@ -169,10 +280,10 @@ module.exports = { let seriesInclude = { model: Database.models.bookSeries, - attributes: ['seriesId', 'sequence', 'createdAt'], + attributes: ['id', 'seriesId', 'sequence', 'createdAt'], include: { model: Database.models.series, - attributes: ['id', 'name'] + attributes: ['id', 'name', 'nameIgnorePrefix'] }, order: [ ['createdAt', 'ASC'] @@ -193,9 +304,17 @@ module.exports = { separate: true } + const sortOrder = this.getOrder(sortBy, sortDesc, collapseseries) + const libraryItemIncludes = [] const bookIncludes = [] - if (filterGroup === 'feed-open') { + if (includeRSSFeed) { + libraryItemIncludes.push({ + model: Database.models.feed, + required: filterGroup === 'feed-open' + }) + } + if (filterGroup === 'feed-open' && !includeRSSFeed) { libraryItemIncludes.push({ model: Database.models.feed, required: true @@ -243,6 +362,10 @@ module.exports = { attributes: ['sequence'] } }) + if (sortBy !== 'sequence') { + // Secondary sort by sequence + sortOrder.push([Sequelize.literal('`series.bookSeries.sequence` COLLATE NOCASE ASC NULLS LAST')]) + } } else if (filterGroup === 'issues') { libraryItemWhere[Sequelize.Op.or] = [ { @@ -263,8 +386,52 @@ module.exports = { }) } + const bookWhere = filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : {} + + let collapseSeriesBookSeries = [] + if (collapseseries) { + let seriesBookWhere = null + let seriesWhere = null + if (filterGroup === 'progress') { + seriesWhere = this.getCollapseSeriesMediaProgressFilter(filterValue) + } else if (filterGroup === 'missing' && filterValue === 'authors') { + seriesWhere = { + ['$books.authors.id$']: null + } + } else { + seriesBookWhere = bookWhere + } + + const bookFindOptions = { + where: seriesBookWhere, + include: [ + { + model: Database.models.libraryItem, + required: true, + where: libraryItemWhere, + include: libraryItemIncludes + }, + authorInclude, + ...bookIncludes + ] + } + const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) + if (booksToExclude.length) { + bookWhere['id'] = { + [Sequelize.Op.notIn]: booksToExclude + } + } + collapseSeriesBookSeries = bookSeriesToInclude + if (!bookAttributes?.include) bookAttributes = { include: [] } + if (global.ServerSettings.sortingIgnorePrefix) { + bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title']) + } else { + bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), title)`), 'display_title']) + } + } + const { rows: books, count } = await Database.models.book.findAndCountAll({ - where: filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : null, + where: bookWhere, distinct: true, attributes: bookAttributes, include: [ @@ -278,7 +445,7 @@ module.exports = { authorInclude, ...bookIncludes ], - order: this.getOrder(sortBy, sortDesc), + order: sortOrder, subQuery: false, limit, offset @@ -287,9 +454,39 @@ module.exports = { const libraryItems = books.map((bookExpanded) => { const libraryItem = bookExpanded.libraryItem.toJSON() const book = bookExpanded.toJSON() + + if (filterGroup === 'series' && book.series?.length) { + // For showing sequence on book cover when filtering for series + libraryItem.series = { + id: book.series[0].id, + name: book.series[0].name, + sequence: book.series[0].bookSeries?.sequence || null + } + } + delete book.libraryItem delete book.authors delete book.series + + // For showing details of collapsed series + if (collapseseries && book.bookSeries?.length) { + const collapsedSeries = book.bookSeries.find(bs => collapseSeriesBookSeries.some(cbs => cbs.id === bs.id)) + if (collapsedSeries) { + const collapseSeriesObj = collapseSeriesBookSeries.find(csbs => csbs.id === collapsedSeries.id) + libraryItem.collapsedSeries = { + id: collapsedSeries.series.id, + name: collapsedSeries.series.name, + nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix, + sequence: collapsedSeries.sequence, + numBooks: collapseSeriesObj?.numBooks || 0 + } + } + } + + if (libraryItem.feeds?.length) { + libraryItem.rssFeed = libraryItem.feeds[0] + } + libraryItem.media = book return libraryItem