From 1372c24535a67b62097dd2f54e1ee9dcb427ac60 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Aug 2023 13:36:58 -0500 Subject: [PATCH] Update queries to account for user permissions --- server/controllers/LibraryController.js | 4 +- server/models/LibraryItem.js | 30 +- server/utils/queries/libraryFilters.js | 89 ++++-- .../utils/queries/libraryItemsBookFilters.js | 284 ++++++++++-------- .../queries/libraryItemsPodcastFilters.js | 54 +++- 5 files changed, 277 insertions(+), 184 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 917ea247ca..fac9df9b3a 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -207,7 +207,7 @@ class LibraryController { } payload.offset = payload.page * payload.limit - const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library, req.user.id, payload) + const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library, req.user, payload) payload.results = libraryItems payload.total = count @@ -640,7 +640,7 @@ class LibraryController { async getUserPersonalizedShelves(req, res) { const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10 const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) - const shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user.id, include, limitPerShelf) + const shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user, include, limitPerShelf) res.json(shelves) } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index d1ed223205..d84761c03d 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -403,12 +403,12 @@ module.exports = (sequelize) => { /** * Get library items using filter and sort * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {object} options * @returns {object} { libraryItems:oldLibraryItem[], count:number } */ - static async getByFilterAndSort(library, userId, options) { - const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, userId, options) + static async getByFilterAndSort(library, user, options) { + const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, user, options) return { libraryItems: libraryItems.map(li => { const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() @@ -440,18 +440,18 @@ module.exports = (sequelize) => { /** * Get home page data personalized shelves * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @returns {object[]} array of shelf objects */ - static async getPersonalizedShelves(library, userId, include, limit) { + static async getPersonalizedShelves(library, user, include, limit) { const fullStart = Date.now() // Used for testing load times const shelves = [] // "Continue Listening" shelf - const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, userId, include, limit, false) + const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) if (itemsInProgressPayload.items.length) { shelves.push({ id: 'continue-listening', @@ -467,7 +467,7 @@ module.exports = (sequelize) => { let start = Date.now() if (library.isBook) { // "Continue Reading" shelf - const ebooksInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, userId, include, limit, true) + const ebooksInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, true) if (ebooksInProgressPayload.items.length) { shelves.push({ id: 'continue-reading', @@ -482,7 +482,7 @@ module.exports = (sequelize) => { start = Date.now() // "Continue Series" shelf - const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, userId, include, limit) + const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit) if (continueSeriesPayload.libraryItems.length) { shelves.push({ id: 'continue-series', @@ -496,7 +496,7 @@ module.exports = (sequelize) => { Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } else if (library.isPodcast) { // "Newest Episodes" shelf - const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, userId, limit) + const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) if (newestEpisodesPayload.libraryItems.length) { shelves.push({ id: 'newest-episodes', @@ -512,7 +512,7 @@ module.exports = (sequelize) => { start = Date.now() // "Recently Added" shelf - const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, userId, include, limit) + const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit) if (mostRecentPayload.libraryItems.length) { shelves.push({ id: 'recently-added', @@ -528,7 +528,7 @@ module.exports = (sequelize) => { if (library.isBook) { start = Date.now() // "Recent Series" shelf - const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, include, 5) + const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5) if (seriesMostRecentPayload.series.length) { shelves.push({ id: 'recent-series', @@ -543,7 +543,7 @@ module.exports = (sequelize) => { start = Date.now() // "Discover" shelf - const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, userId, include, limit) + const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit) if (discoverLibraryItemsPayload.libraryItems.length) { shelves.push({ id: 'discover', @@ -559,7 +559,7 @@ module.exports = (sequelize) => { start = Date.now() // "Listen Again" shelf - const listenAgainPayload = await libraryFilters.getMediaFinished(library, userId, include, limit, false) + const listenAgainPayload = await libraryFilters.getMediaFinished(library, user, include, limit, false) if (listenAgainPayload.items.length) { shelves.push({ id: 'listen-again', @@ -575,7 +575,7 @@ module.exports = (sequelize) => { if (library.isBook) { start = Date.now() // "Read Again" shelf - const readAgainPayload = await libraryFilters.getMediaFinished(library, userId, include, limit, true) + const readAgainPayload = await libraryFilters.getMediaFinished(library, user, include, limit, true) if (readAgainPayload.items.length) { shelves.push({ id: 'read-again', @@ -590,7 +590,7 @@ module.exports = (sequelize) => { start = Date.now() // "Newest Authors" shelf - const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, limit) + const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit) if (newestAuthorsPayload.authors.length) { shelves.push({ id: 'newest-authors', diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 5e612184fb..2aa90d03d8 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -12,11 +12,11 @@ module.exports = { /** * Get library items using filter and sort * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {object} options * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getFilteredLibraryItems(library, userId, options) { + async getFilteredLibraryItems(library, user, options) { const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options let filterValue = null @@ -29,25 +29,25 @@ module.exports = { } if (mediaType === 'book') { - return libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) + return libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) } else { - return libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, userId, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) + return libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) } }, /** * Get library items for continue listening & continue reading shelves * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @param {boolean} ebook true if continue reading shelf * @returns {object} { items:LibraryItem[], count:number } */ - async getMediaItemsInProgress(library, userId, include, limit, ebook = false) { + async getMediaItemsInProgress(library, user, include, limit, ebook = false) { if (library.mediaType === 'book') { const filterValue = ebook ? 'ebook-in-progress' : 'audio-in-progress' - const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, 'progress', filterValue, 'progress', true, false, include, limit, 0) + const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', filterValue, 'progress', true, false, include, limit, 0) return { items: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() @@ -59,7 +59,7 @@ module.exports = { count } } else { - const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, userId, 'progress', 'in-progress', 'progress', true, limit, 0) + const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'progress', 'in-progress', 'progress', true, limit, 0) return { count, items: libraryItems.map(li => { @@ -74,14 +74,14 @@ module.exports = { /** * Get library items for most recently added shelf * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getLibraryItemsMostRecentlyAdded(library, userId, include, limit) { + async getLibraryItemsMostRecentlyAdded(library, user, include, limit) { if (library.mediaType === 'book') { - const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, null, null, 'addedAt', true, false, include, limit, 0) + const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, null, null, 'addedAt', true, false, include, limit, 0) return { libraryItems: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() @@ -96,7 +96,7 @@ module.exports = { count } } else { - const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, userId, null, null, 'addedAt', true, include, limit, 0) + const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, null, null, 'addedAt', true, include, limit, 0) return { libraryItems: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() @@ -116,13 +116,13 @@ module.exports = { /** * Get library items for continue series shelf * @param {string} library - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getLibraryItemsContinueSeries(library, userId, include, limit) { - const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library.id, userId, include, limit, 0) + async getLibraryItemsContinueSeries(library, user, include, limit) { + const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library.id, user, include, limit, 0) return { libraryItems: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() @@ -141,18 +141,18 @@ module.exports = { /** * Get library items or podcast episodes for the "Listen Again" or "Read Again" shelf * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @param {boolean} ebook true if "Read Again" shelf * @returns {object} { items:object[], count:number } */ - async getMediaFinished(library, userId, include, limit, ebook = false) { + async getMediaFinished(library, user, include, limit, ebook = false) { if (ebook && library.mediaType !== 'book') return { items: [], count: 0 } if (library.mediaType === 'book') { const filterValue = ebook ? 'ebook-finished' : 'finished' - const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, 'progress', filterValue, 'progress', true, false, include, limit, 0) + const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', filterValue, 'progress', true, false, include, limit, 0) return { items: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() @@ -164,7 +164,7 @@ module.exports = { count } } else { - const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, userId, 'progress', 'finished', 'progress', true, limit, 0) + const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, 'progress', 'finished', 'progress', true, limit, 0) return { count, items: libraryItems.map(li => { @@ -179,11 +179,12 @@ module.exports = { /** * Get series for recent series shelf * @param {oldLibrary} library + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @returns {object} { series:oldSeries[], count:number} */ - async getSeriesMostRecentlyAdded(library, include, limit) { + async getSeriesMostRecentlyAdded(library, user, include, limit) { if (library.mediaType !== 'book') return { series: [], count: 0 } const seriesIncludes = [] @@ -192,19 +193,46 @@ module.exports = { model: Database.models.feed }) } - const { rows: series, count } = await Database.models.series.findAndCountAll({ - where: { + + const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user) + + const seriesWhere = [ + { libraryId: library.id - }, + } + ] + // Handle user permissions to only include series with at least 1 book + // TODO: Simplify to a single query + if (userPermissionBookWhere.bookWhere.length) { + let 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' + } + } + seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), { + [Sequelize.Op.gt]: 0 + })) + } + + const { rows: series, count } = await Database.models.series.findAndCountAll({ + where: seriesWhere, limit, offset: 0, distinct: true, subQuery: false, + replacements: userPermissionBookWhere.replacements, include: [ { model: Database.models.bookSeries, include: { model: Database.models.book, + where: userPermissionBookWhere.bookWhere, include: { model: Database.models.libraryItem } @@ -255,10 +283,11 @@ module.exports = { * Get most recently created authors for "Newest Authors" shelf * Author must be linked to at least 1 book * @param {oldLibrary} library + * @param {oldUser} user * @param {number} limit * @returns {object} { authors:oldAuthor[], count:number } */ - async getNewestAuthors(library, limit) { + async getNewestAuthors(library, user, limit) { if (library.mediaType !== 'book') return { authors: [], count: 0 } const { rows: authors, count } = await Database.models.author.findAndCountAll({ @@ -288,15 +317,15 @@ module.exports = { /** * Get book library items for the "Discover" shelf * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @returns {object} {libraryItems:oldLibraryItem[], count:number} */ - async getLibraryItemsToDiscover(library, userId, include, limit) { + async getLibraryItemsToDiscover(library, user, include, limit) { if (library.mediaType !== 'book') return { libraryItems: [], count: 0 } - const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, userId, include, limit) + const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit) return { libraryItems: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() @@ -312,14 +341,14 @@ module.exports = { /** * Get podcast episodes most recently added * @param {oldLibrary} library - * @param {string} userId + * @param {oldUser} user * @param {number} limit * @returns {object} {libraryItems:oldLibraryItem[], count:number} */ - async getNewestPodcastEpisodes(library, userId, limit) { + async getNewestPodcastEpisodes(library, user, limit) { if (library.mediaType !== 'podcast') return { libraryItems: [], count: 0 } - const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, userId, null, null, 'createdAt', true, limit, 0) + const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredPodcastEpisodes(library.id, user, null, null, 'createdAt', true, limit, 0) return { count, libraryItems: libraryItems.map(li => { diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 8ed8c19c89..cf4618e806 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -3,6 +3,35 @@ const Database = require('../../Database') const Logger = require('../../Logger') module.exports = { + /** + * User permissions to restrict books for explicit content & tags + * @param {oldUser} user + * @returns {object} { bookWhere:Sequelize.WhereOptions, replacements:string[] } + */ + getUserPermissionBookWhereQuery(user) { + const bookWhere = [] + const replacements = {} + if (!user.canAccessExplicitContent) { + bookWhere.push({ + explicit: false + }) + } + if (!user.permissions.accessAllTags && user.itemTagsSelected.length) { + replacements['userTagsSelected'] = user.itemTagsSelected + if (user.permissions.selectedTagsNotAccessible) { + bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0)) + } else { + bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), { + [Sequelize.Op.gte]: 1 + })) + } + } + return { + bookWhere, + replacements + } + }, + /** * When collapsing series and filtering by progress * different where options are required @@ -296,6 +325,7 @@ module.exports = { /** * Get library items for book media type using filter and sort * @param {string} libraryId + * @param {oldUser} user * @param {[string]} filterGroup * @param {[string]} filterValue * @param {string} sortBy @@ -306,7 +336,7 @@ module.exports = { * @param {number} offset * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) { + async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) { // TODO: Handle collapse sub-series if (filterGroup === 'series' && collapseseries) { collapseseries = false @@ -442,14 +472,22 @@ module.exports = { model: Database.models.mediaProgress, attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'], where: { - userId + userId: user.id }, required: false }) } - const { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) + let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) + + let bookWhere = Array.isArray(mediaWhere) ? mediaWhere : [mediaWhere] + + // User permissions + const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) + replacements = { ...replacements, ...userPermissionBookWhere.replacements } + bookWhere.push(...userPermissionBookWhere.bookWhere) + // Handle collapsed series let collapseSeriesBookSeries = [] if (collapseseries) { let seriesBookWhere = null @@ -461,7 +499,7 @@ module.exports = { ['$books.authors.id$']: null } } else { - seriesBookWhere = mediaWhere + seriesBookWhere = bookWhere } const bookFindOptions = { @@ -479,9 +517,11 @@ module.exports = { } const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) if (booksToExclude.length) { - mediaWhere['id'] = { - [Sequelize.Op.notIn]: booksToExclude - } + bookWhere.push({ + id: { + [Sequelize.Op.notIn]: booksToExclude + } + }) } collapseSeriesBookSeries = bookSeriesToInclude if (!bookAttributes?.include) bookAttributes = { include: [] } @@ -496,7 +536,7 @@ module.exports = { } const { rows: books, count } = await Database.models.book.findAndCountAll({ - where: mediaWhere, + where: bookWhere, distinct: true, attributes: bookAttributes, replacements, @@ -572,51 +612,13 @@ module.exports = { * 3. Has at least 1 unfinished book * TODO: Reduce queries * @param {string} libraryId - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @param {number} offset * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getContinueSeriesLibraryItems(libraryId, userId, include, limit, offset) { - // Step 1: Get all media progress for user that belongs to a series book - const mediaProgressForUserForSeries = await Database.models.mediaProgress.findAll({ - where: { - userId - }, - include: [ - { - model: Database.models.book, - attributes: ['id', 'title'], - include: { - model: Database.models.series, - attributes: ['id'], - through: { - attributes: [] - }, - required: true - }, - required: true - } - ] - }) - - // Step 1.5: Identify the series that have at least 1 finished book and have no books in progress - let seriesToInclude = [] - let seriesToExclude = [] - for (const prog of mediaProgressForUserForSeries) { - const series = prog.mediaItem?.series || [] - for (const s of series) { - if (prog.currentTime > 0 && !prog.isFinished) { // in-progress - seriesToInclude = seriesToInclude.filter(sid => sid !== s.id) - if (!seriesToExclude.includes(s.id)) seriesToExclude.push(s.id) - } else if (prog.isFinished && !seriesToExclude.includes(s.id) && !seriesToInclude.includes(s.id)) { // finished - seriesToInclude.push(s.id) - } - } - } - - // optional include rssFeed with library item + async getContinueSeriesLibraryItems(libraryId, user, include, limit, offset) { const libraryItemIncludes = [] if (include.includes('rssfeed')) { libraryItemIncludes.push({ @@ -624,90 +626,101 @@ module.exports = { }) } - // Step 2: Get all series identified in step 1.5 and filter out series where all books are finished + const bookWhere = [] + // TODO: Permissions should also be applied to subqueries + // User permissions + const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) + bookWhere.push(...userPermissionBookWhere.bookWhere) + const { rows: series, count } = await Database.models.series.findAndCountAll({ - where: { - id: { - [Sequelize.Op.in]: seriesToInclude + where: [ + { + libraryId }, - '$bookSeries.book.mediaProgresses.isFinished$': { - [Sequelize.Op.or]: [false, null] - } + // TODO: Simplify queries + // Has at least 1 book finished + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE bs.seriesId = series.id AND mp.mediaItemId = bs.bookId AND mp.userId = :userId AND mp.isFinished = 1)`), { + [Sequelize.Op.gte]: 1 + }), + // Has at least 1 book not finished + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), { + [Sequelize.Op.gte]: 1 + }), + // Has no books in progress + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id AND mp.isFinished = 0 AND mp.currentTime > 0)`), 0) + ], + attributes: { + include: [ + [Sequelize.literal('(SELECT max(mp.updatedAt) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id)'), 'recent_progress'] + ] }, - distinct: true, - include: [ - { - model: Database.models.bookSeries, - include: { - model: Database.models.book, - include: [ - { - model: Database.models.libraryItem, - where: { - libraryId - }, - include: libraryItemIncludes - }, - { - model: Database.models.bookAuthor, - attributes: ['authorId'], - include: { - model: Database.models.author - }, - separate: true - }, - { - model: Database.models.mediaProgress, - where: { - userId - }, - required: false + replacements: { + userId: user.id, + ...userPermissionBookWhere.replacements + }, + include: { + model: Database.models.bookSeries, + attributes: ['bookId', 'sequence'], + separate: true, + subQuery: false, + order: [ + [Sequelize.literal('CAST(sequence AS INTEGER) ASC NULLS LAST')] + ], + where: { + '$book.mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, 0] + } + }, + include: { + model: Database.models.book, + where: bookWhere, + include: [ + { + model: Database.models.libraryItem, + include: libraryItemIncludes + }, + { + model: Database.models.author, + through: { + attributes: [] } - ], - required: true - }, - required: true + }, + { + model: Database.models.mediaProgress, + where: { + userId: user.id + }, + required: false + } + ] } - ], + }, order: [ - // Sort by progress most recently updated - [Database.models.bookSeries, Database.models.book, Database.models.mediaProgress, 'updatedAt', 'DESC'], + [Sequelize.literal('recent_progress DESC')] ], + distinct: true, subQuery: false, limit, offset }) - // Step 3: Map series to library items by selecting the first unfinished book in the series const libraryItems = series.map(s => { - // Natural sort sequence, nulls last - // TODO: sort in query. was unable to sort nested association with sequelize - 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' - }) - }) - - // Get first unfinished book to use - const bookSeries = s.bookSeries.find(bs => !bs.book.mediaProgresses?.[0]?.isFinished) - const libraryItem = bookSeries.book.libraryItem.toJSON() - + if (!s.bookSeries.length) return null // this is only possible if user has restricted books in series + const libraryItem = s.bookSeries[0].book.libraryItem.toJSON() + const book = s.bookSeries[0].book.toJSON() + delete book.libraryItem libraryItem.series = { id: s.id, name: s.name, - sequence: bookSeries.sequence + sequence: s.bookSeries[0].sequence } - if (libraryItem.feeds?.length) { libraryItem.rssFeed = libraryItem.feeds[0] } - - libraryItem.media = bookSeries.book + libraryItem.media = book return libraryItem - }) + }).filter(s => s) + return { libraryItems, count @@ -719,12 +732,14 @@ module.exports = { * Random selection of books that are not started * - only includes the first book of a not-started series * @param {string} libraryId - * @param {string} userId + * @param {oldUser} user * @param {string[]} include * @param {number} limit * @returns {object} {libraryItems:LibraryItem, count:number} */ - async getDiscoverLibraryItems(libraryId, userId, include, limit) { + async getDiscoverLibraryItems(libraryId, user, include, limit) { + const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) + // Step 1: Get the first book of every series that hasnt been started yet const seriesNotStarted = await Database.models.series.findAll({ where: [ @@ -734,7 +749,8 @@ module.exports = { Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId WHERE bs.seriesId = series.id AND mp.userId = :userId AND (mp.isFinished = 1 OR mp.currentTime > 0))`), 0) ], replacements: { - userId + userId: user.id, + ...userPermissionBookWhere.replacements }, attributes: ['id'], include: { @@ -742,6 +758,10 @@ module.exports = { attributes: ['bookId', 'sequence'], separate: true, required: true, + include: { + model: Database.models.book, + where: userPermissionBookWhere.bookWhere + }, order: [ [Sequelize.literal('CAST(sequence AS INTEGER) ASC NULLS LAST')] ], @@ -762,22 +782,26 @@ module.exports = { // Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly) const { rows: books, count } = await Database.models.book.findAndCountAll({ - where: { - '$mediaProgresses.isFinished$': { - [Sequelize.Op.or]: [null, 0] - }, - '$mediaProgresses.currentTime$': { - [Sequelize.Op.or]: [null, 0] - }, - [Sequelize.Op.or]: [ - Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0), - { - id: { - [Sequelize.Op.in]: booksFromSeriesToInclude + where: [ + { + '$mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, 0] + }, + '$mediaProgresses.currentTime$': { + [Sequelize.Op.or]: [null, 0] + }, + [Sequelize.Op.or]: [ + Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0), + { + id: { + [Sequelize.Op.in]: booksFromSeriesToInclude + } } - } - ] - }, + ] + }, + ...userPermissionBookWhere.bookWhere + ], + replacements: userPermissionBookWhere.replacements, include: [ { model: Database.models.libraryItem, @@ -789,7 +813,7 @@ module.exports = { { model: Database.models.mediaProgress, where: { - userId + userId: user.id }, required: false }, diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index ad16348265..fcd67febb3 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -4,6 +4,34 @@ const Database = require('../../Database') const Logger = require('../../Logger') module.exports = { + /** + * User permissions to restrict podcasts for explicit content & tags + * @param {oldUser} user + * @returns {object} { podcastWhere:Sequelize.WhereOptions, replacements:string[] } + */ + getUserPermissionPodcastWhereQuery(user) { + const podcastWhere = [] + const replacements = {} + if (!user.canAccessExplicitContent) { + podcastWhere.push({ + explicit: false + }) + } + if (!user.permissions.accessAllTags && user.itemTagsSelected.length) { + replacements['userTagsSelected'] = user.itemTagsSelected + if (user.permissions.selectedTagsNotAccessible) { + podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0)) + } else { + podcastWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), { + [Sequelize.Op.gte]: 1 + })) + } + } + return { + podcastWhere, + replacements + } + }, /** * Get where options for Podcast model @@ -64,6 +92,7 @@ module.exports = { /** * Get library items for podcast media type using filter and sort * @param {string} libraryId + * @param {oldUser} user * @param {[string]} filterGroup * @param {[string]} filterValue * @param {string} sortBy @@ -73,7 +102,7 @@ module.exports = { * @param {number} offset * @returns {object} { libraryItems:LibraryItem[], count:number } */ - async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) { + async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) { const includeRSSFeed = include.includes('rssfeed') const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete') @@ -103,11 +132,18 @@ module.exports = { podcastIncludes.push([Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), 'numEpisodesIncomplete']) } - const { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) - replacements.userId = userId + let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) + replacements.userId = user.id + + const podcastWhere = [] + if (Object.keys(mediaWhere).length) podcastWhere.push(mediaWhere) + + const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) + replacements = { ...replacements, ...userPermissionPodcastWhere.replacements } + podcastWhere.push(...userPermissionPodcastWhere.podcastWhere) const { rows: podcasts, count } = await Database.models.podcast.findAndCountAll({ - where: mediaWhere, + where: podcastWhere, replacements, distinct: true, attributes: { @@ -157,7 +193,7 @@ module.exports = { /** * Get podcast episodes filtered and sorted * @param {string} libraryId - * @param {string} userId + * @param {oldUser} user * @param {[string]} filterGroup * @param {[string]} filterValue * @param {string} sortBy @@ -166,7 +202,7 @@ module.exports = { * @param {number} offset * @returns {object} {libraryItems:LibraryItem[], count:number} */ - async getFilteredPodcastEpisodes(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, limit, offset) { + async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset) { if (sortBy === 'progress' && filterGroup !== 'progress') { Logger.warn('Cannot sort podcast episodes by progress without filtering by progress') sortBy = 'createdAt' @@ -178,7 +214,7 @@ module.exports = { podcastEpisodeIncludes.push({ model: Database.models.mediaProgress, where: { - userId + userId: user.id }, attributes: ['id', 'isFinished', 'currentTime', 'updatedAt'] }) @@ -206,11 +242,15 @@ module.exports = { podcastEpisodeOrder.push([Sequelize.literal('mediaProgresses.updatedAt'), sortDesc ? 'DESC' : 'ASC']) } + const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) + const { rows: podcastEpisodes, count } = await Database.models.podcastEpisode.findAndCountAll({ where: podcastEpisodeWhere, + replacements: userPermissionPodcastWhere.replacements, include: [ { model: Database.models.podcast, + where: userPermissionPodcastWhere.podcastWhere, include: [ { model: Database.models.libraryItem,