diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 3bc98fde..509d3f24 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -317,6 +317,8 @@ export default { // 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' + } else if (entityPath === 'series') { + entityPath += '2' } const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' @@ -628,6 +630,11 @@ export default { return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed }, async init(bookshelf) { + if (this.entityName === 'series') { + this.booksPerFetch = 50 + } else { + this.booksPerFetch = 100 + } this.checkUpdateSearchParams() this.initSizeData(bookshelf) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 97696243..b9bb58bb 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -8,6 +8,7 @@ const Library = require('../objects/Library') const libraryHelpers = require('../utils/libraryHelpers') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') const libraryItemFilters = require('../utils/queries/libraryItemFilters') +const seriesFilters = require('../utils/queries/seriesFilters') const { sort, createNewSortInstance } = require('../libs/fastSort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -519,12 +520,42 @@ class LibraryController { res.sendStatus(200) } + /** + * GET: /api/libraries/:id/series2 + * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getAllSeriesForLibraryNew(req, res) { + const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) + + const payload = { + results: [], + total: 0, + 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, + minified: req.query.minified === '1', + include: include.join(',') + } + + const offset = payload.page * payload.limit + const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset) + + payload.total = count + payload.results = series + res.json(payload) + } + /** * GET: /api/libraries/:id/series * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open * - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getAllSeriesForLibrary(req, res) { const libraryItems = req.libraryItems diff --git a/server/models/Series.js b/server/models/Series.js index 243be391..576cafce 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -100,7 +100,24 @@ class Series extends Model { description: DataTypes.TEXT }, { sequelize, - modelName: 'series' + modelName: 'series', + indexes: [ + { + fields: [{ + name: 'name', + collate: 'NOCASE' + }] + }, + { + fields: [{ + name: 'nameIgnorePrefix', + collate: 'NOCASE' + }] + }, + { + fields: ['libraryId'] + } + ] }) const { library } = sequelize.models diff --git a/server/objects/entities/Series.js b/server/objects/entities/Series.js index 31ec4ca7..59987f6f 100644 --- a/server/objects/entities/Series.js +++ b/server/objects/entities/Series.js @@ -1,5 +1,5 @@ const uuidv4 = require("uuid").v4 -const { getTitleIgnorePrefix } = require('../../utils/index') +const { getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') class Series { constructor(series) { @@ -33,6 +33,7 @@ class Series { return { id: this.id, name: this.name, + nameIgnorePrefix: getTitlePrefixAtEnd(this.name), description: this.description, addedAt: this.addedAt, updatedAt: this.updatedAt, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e1287dcd..3ea25791 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -78,6 +78,7 @@ class ApiRouter { 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/series2', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibraryNew.bind(this)) this.router.get('/libraries/:id/series', LibraryController.middleware.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)) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index df6855a3..c2a30c18 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -41,11 +41,11 @@ module.exports = { /** * Get library items for continue listening & continue reading shelves - * @param {oldLibrary} library - * @param {oldUser} user + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} { items:LibraryItem[], count:number } + * @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>} */ async getMediaItemsInProgress(library, user, include, limit) { if (library.mediaType === 'book') { @@ -176,14 +176,14 @@ module.exports = { /** * Get series for recent series shelf - * @param {oldLibrary} library - * @param {oldUser} user + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} { series:oldSeries[], count:number} + * @returns {{ series:import('../../objects/entities/Series')[], count:number}} */ async getSeriesMostRecentlyAdded(library, user, include, limit) { - if (library.mediaType !== 'book') return { series: [], count: 0 } + if (!library.isBook) return { series: [], count: 0 } const seriesIncludes = [] if (include.includes('rssfeed')) { @@ -390,7 +390,7 @@ module.exports = { /** * Get filter data used in filter menus - * @param {oldLibrary} oldLibrary + * @param {import('../../objects/Library')} oldLibrary * @returns {Promise} */ async getFilterData(oldLibrary) { diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index e30b201d..2385edf9 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -6,7 +6,7 @@ module.exports = { /** * User permissions to restrict books for explicit content & tags * @param {oldUser} user - * @returns {object} { bookWhere:Sequelize.WhereOptions, replacements:string[] } + * @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }} */ getUserPermissionBookWhereQuery(user) { const bookWhere = [] diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js new file mode 100644 index 00000000..3e156a43 --- /dev/null +++ b/server/utils/queries/seriesFilters.js @@ -0,0 +1,206 @@ +const Sequelize = require('sequelize') +const Logger = require('../../Logger') +const Database = require('../../Database') +const libraryItemsBookFilters = require('./libraryItemsBookFilters') + +module.exports = { + decode(text) { + return Buffer.from(decodeURIComponent(text), 'base64').toString() + }, + + /** + * Get series filtered and sorted + * + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user + * @param {string} filterBy + * @param {string} sortBy + * @param {boolean} sortDesc + * @param {string[]} include + * @param {number} limit + * @param {number} offset + * @returns {Promise<{ series:object[], count:number }>} + */ + async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) { + let filterValue = null + let filterGroup = null + if (filterBy) { + const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages'] + const group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) + filterGroup = group || filterBy + filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null + } + + const seriesIncludes = [] + if (include.includes('rssfeed')) { + seriesIncludes.push({ + model: Database.models.feed + }) + } + + const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user) + + const seriesWhere = [ + { + libraryId: library.id + } + ] + + // Handle library setting to hide single book series + // TODO: Merge with existing query + if (library.settings.hideSingleBookSeries) { + seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), { + [Sequelize.Op.gt]: 1 + })) + } + + // Handle filters + // TODO: Simplify and break-out + let attrQuery = null + if (['genres', 'tags', 'narrators'].includes(filterGroup)) { + attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0` + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'authors') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue' + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'publishers') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.publisher = :filterValue' + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'languages') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.language = :filterValue' + userPermissionBookWhere.replacements.filterValue = filterValue + } else if (filterGroup === 'progress') { + if (filterValue === 'not-finished') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + } else if (filterValue === 'finished') { + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + } else if (filterValue === 'not-started') { + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + } else if (filterValue === 'in-progress') { + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + } + } + + // Handle user permissions to only include series with at least 1 book + // TODO: Simplify to a single query + if (userPermissionBookWhere.bookWhere.length) { + if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id' + + if (!user.canAccessExplicitContent) { + attrQuery += ' AND b.explicit = 0' + } + if (!user.permissions.accessAllTags && user.itemTagsSelected.length) { + if (user.permissions.selectedTagsNotAccessible) { + attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0' + } else { + attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0' + } + } + } + + if (attrQuery) { + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), { + [Sequelize.Op.gt]: 0 + })) + } + + const order = [] + let seriesAttributes = { + include: [] + } + + // Handle sort order + const dir = sortDesc ? 'DESC' : 'ASC' + if (sortBy === 'numBooks') { + seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']) + order.push(['numBooks', dir]) + } else if (sortBy === 'addedAt') { + order.push(['createdAt', dir]) + } else if (sortBy === 'name') { + if (global.ServerSettings.sortingIgnorePrefix) { + order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir]) + } else { + order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir]) + } + } else if (sortBy === 'totalDuration') { + seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration']) + order.push(['totalDuration', dir]) + } else if (sortBy === 'lastBookAdded') { + seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.createdAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookAdded']) + order.push(['mostRecentBookAdded', dir]) + } else if (sortBy === 'lastBookUpdated') { + seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated']) + order.push(['mostRecentBookUpdated', dir]) + } + + const { rows: series, count } = await Database.seriesModel.findAndCountAll({ + where: seriesWhere, + limit, + offset, + distinct: true, + subQuery: false, + benchmark: true, + logging: (sql, timeMs) => { + console.log(`[Query] Series filter/sort. Elapsed ${timeMs}ms`) + }, + attributes: seriesAttributes, + replacements: userPermissionBookWhere.replacements, + include: [ + { + model: Database.models.bookSeries, + include: { + model: Database.models.book, + where: userPermissionBookWhere.bookWhere, + include: [ + { + model: Database.models.libraryItem + } + ] + }, + separate: true + }, + ...seriesIncludes + ], + order + }) + + // Map series to old series + const allOldSeries = [] + for (const s of series) { + const oldSeries = s.getOldSeries().toJSON() + + if (s.dataValues.totalDuration) { + oldSeries.totalDuration = s.dataValues.totalDuration + } + + if (s.feeds?.length) { + oldSeries.rssFeed = Database.models.feed.getOldFeed(s.feeds[0]).toJSONMinified() + } + + // TODO: Sort books by sequence in query + s.bookSeries.sort((a, b) => { + if (!a.sequence) return 1 + if (!b.sequence) return -1 + return a.sequence.localeCompare(b.sequence, undefined, { + numeric: true, + sensitivity: 'base' + }) + }) + oldSeries.books = s.bookSeries.map(bs => { + const libraryItem = bs.book.libraryItem.toJSON() + delete bs.book.libraryItem + libraryItem.media = bs.book + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified() + return oldLibraryItem + }) + allOldSeries.push(oldSeries) + } + + return { + series: allOldSeries, + count + } + } +}