From 9d7d4c69020d6fc4b4da2948266c33ac56976217 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Aug 2023 14:40:36 -0500 Subject: [PATCH 01/12] Update filterData for authors/series when added/removed --- client/layouts/default.vue | 7 ++ client/store/libraries.js | 4 ++ server/Database.js | 70 +++++++++++++++++++ server/controllers/AuthorController.js | 2 + server/controllers/LibraryController.js | 6 +- server/controllers/LibraryItemController.js | 2 +- server/models/Author.js | 9 +++ server/models/Series.js | 9 +++ server/routers/ApiRouter.js | 28 +++++++- server/scanner/Scanner.js | 14 +++- server/utils/queries/libraryFilters.js | 8 +-- .../utils/queries/libraryItemsBookFilters.js | 6 +- 12 files changed, 152 insertions(+), 13 deletions(-) diff --git a/client/layouts/default.vue b/client/layouts/default.vue index d27936e2..805fb8a1 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -343,6 +343,10 @@ export default { } this.$store.commit('libraries/removeCollection', collection) }, + seriesRemoved({ id, libraryId }) { + if (this.currentLibraryId !== libraryId) return + this.$store.commit('libraries/removeSeriesFromFilterData', id) + }, playlistAdded(playlist) { if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return this.$store.commit('libraries/addUpdateUserPlaylist', playlist) @@ -442,6 +446,9 @@ export default { this.socket.on('collection_updated', this.collectionUpdated) this.socket.on('collection_removed', this.collectionRemoved) + // Series Listeners + this.socket.on('series_removed', this.seriesRemoved) + // User Playlist Listeners this.socket.on('playlist_added', this.playlistAdded) this.socket.on('playlist_updated', this.playlistUpdated) diff --git a/client/store/libraries.js b/client/store/libraries.js index e0151626..fd8af4ae 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -234,6 +234,10 @@ export const mutations = { setNumUserPlaylists(state, numUserPlaylists) { state.numUserPlaylists = numUserPlaylists }, + removeSeriesFromFilterData(state, seriesId) { + if (!seriesId || !state.filterData) return + state.filterData.series = state.filterData.series.filter(se => se.id !== seriesId) + }, updateFilterDataWithItem(state, libraryItem) { if (!libraryItem || !state.filterData) return if (state.currentLibraryId !== libraryItem.libraryId) return diff --git a/server/Database.js b/server/Database.js index ebe6d4c7..303e2d4a 100644 --- a/server/Database.js +++ b/server/Database.js @@ -34,6 +34,16 @@ class Database { return this.sequelize?.models || {} } + /** @type {typeof import('./models/Author')} */ + get authorModel() { + return this.models.author + } + + /** @type {typeof import('./models/Series')} */ + get seriesModel() { + return this.models.series + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) @@ -481,6 +491,66 @@ class Database { } } } + + removeSeriesFromFilterData(libraryId, seriesId) { + if (!this.libraryFilterData[libraryId]) return + this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId) + } + + addSeriesToFilterData(libraryId, seriesName, seriesId) { + if (!this.libraryFilterData[libraryId]) return + // Check if series is already added + if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return + this.libraryFilterData[libraryId].series.push({ + id: seriesId, + name: seriesName + }) + } + + removeAuthorFromFilterData(libraryId, authorId) { + if (!this.libraryFilterData[libraryId]) return + this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId) + } + + addAuthorToFilterData(libraryId, authorName, authorId) { + if (!this.libraryFilterData[libraryId]) return + // Check if author is already added + if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return + this.libraryFilterData[libraryId].authors.push({ + id: authorId, + name: authorName + }) + } + + /** + * Used when updating items to make sure author id exists + * If library filter data is set then use that for check + * otherwise lookup in db + * @param {string} libraryId + * @param {string} authorId + * @returns {Promise} + */ + async checkAuthorExists(libraryId, authorId) { + if (!this.libraryFilterData[libraryId]) { + return this.authorModel.checkExistsById(authorId) + } + return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId) + } + + /** + * Used when updating items to make sure series id exists + * If library filter data is set then use that for check + * otherwise lookup in db + * @param {string} libraryId + * @param {string} seriesId + * @returns {Promise} + */ + async checkSeriesExists(libraryId, seriesId) { + if (!this.libraryFilterData[libraryId]) { + return this.seriesModel.checkExistsById(seriesId) + } + return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId) + } } module.exports = new Database() \ No newline at end of file diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 5133d1cb..56cb59a9 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -113,6 +113,8 @@ class AuthorController { // Remove old author await Database.removeAuthor(req.author.id) SocketAuthority.emitter('author_removed', req.author.toJSON()) + // Update filter data + Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) // Send updated num books for merged author const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 70c63708..97696243 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -859,12 +859,12 @@ class LibraryController { /** * GET: /api/libraries/:id/authors * Get authors for library - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getAuthors(req, res) { const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user) - const authors = await Database.models.author.findAll({ + const authors = await Database.authorModel.findAll({ where: { libraryId: req.library.id }, diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 2667128b..d5fa20bc 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -124,7 +124,7 @@ class LibraryItemController { // Book specific - Get all series being removed from this item let seriesRemoved = [] if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id) + const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || [] seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) } diff --git a/server/models/Author.js b/server/models/Author.js index 88dc3eea..9eeda5bf 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -83,6 +83,15 @@ class Author extends Model { }) } + /** + * Check if author exists + * @param {string} authorId + * @returns {Promise} + */ + static async checkExistsById(authorId) { + return (await this.count({ where: { id: authorId } })) > 0 + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/Series.js b/server/models/Series.js index f4cdbffe..243be391 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -75,6 +75,15 @@ class Series extends Model { }) } + /** + * Check if series exists + * @param {string} seriesId + * @returns {Promise} + */ + static async checkExistsById(seriesId) { + return (await this.count({ where: { id: seriesId } })) > 0 + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 69179006..e1287dcd 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -472,10 +472,20 @@ class ApiRouter { } } + /** + * Remove an empty series & close an open RSS feed + * @param {import('../models/Series')} series + */ async removeEmptySeries(series) { await this.rssFeedManager.closeFeedForEntityId(series.id) Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) await Database.removeSeries(series.id) + // Remove series from library filter data + Database.removeSeriesFromFilterData(series.libraryId, series.id) + SocketAuthority.emitter('series_removed', { + id: series.id, + libraryId: series.libraryId + }) } async getUserListeningSessionsHelper(userId) { @@ -546,7 +556,7 @@ class ApiRouter { const mediaMetadata = mediaPayload.metadata // Create new authors if in payload - if (mediaMetadata.authors && mediaMetadata.authors.length) { + if (mediaMetadata.authors?.length) { const newAuthors = [] for (let i = 0; i < mediaMetadata.authors.length; i++) { const authorName = (mediaMetadata.authors[i].name || '').trim() @@ -555,6 +565,12 @@ class ApiRouter { continue } + // Ensure the ID for the author exists + if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) { + Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`) + mediaMetadata.authors[i].id = null + } + if (!mediaMetadata.authors[i].id || mediaMetadata.authors[i].id.startsWith('new')) { let author = Database.authors.find(au => au.libraryId === libraryId && au.checkNameEquals(authorName)) if (!author) { @@ -562,6 +578,8 @@ class ApiRouter { author.setData(mediaMetadata.authors[i], libraryId) Logger.debug(`[ApiRouter] Created new author "${author.name}"`) newAuthors.push(author) + // Update filter data + Database.addAuthorToFilterData(libraryId, author.name, author.id) } // Update ID in original payload @@ -584,6 +602,12 @@ class ApiRouter { continue } + // Ensure the ID for the series exists + if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) { + Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`) + mediaMetadata.series[i].id = null + } + if (!mediaMetadata.series[i].id || mediaMetadata.series[i].id.startsWith('new')) { let seriesItem = Database.series.find(se => se.libraryId === libraryId && se.checkNameEquals(seriesName)) if (!seriesItem) { @@ -591,6 +615,8 @@ class ApiRouter { seriesItem.setData(mediaMetadata.series[i], libraryId) Logger.debug(`[ApiRouter] Created new series "${seriesItem.name}"`) newSeries.push(seriesItem) + // Update filter data + Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id) } // Update ID in original payload diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index dfa26924..559cbcbd 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -486,6 +486,8 @@ class Scanner { _author = new Author() _author.setData(tempMinAuthor, libraryItem.libraryId) newAuthors.push(_author) + // Update filter data + Database.addAuthorToFilterData(libraryItem.libraryId, _author.name, _author.id) } return { @@ -502,11 +504,17 @@ class Scanner { const newSeries = [] libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => { let _series = Database.series.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) - if (!_series) _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series + if (!_series) { + // Check new unsaved series + _series = newSeries.find(se => se.libraryId === libraryItem.libraryId && se.checkNameEquals(tempMinSeries.name)) + } + if (!_series) { // Must create new series _series = new Series() _series.setData(tempMinSeries, libraryItem.libraryId) newSeries.push(_series) + // Update filter data + Database.addSeriesToFilterData(libraryItem.libraryId, _series.name, _series.id) } return { id: _series.id, @@ -924,6 +932,8 @@ class Scanner { author.setData({ name: authorName }, libraryItem.libraryId) await Database.createAuthor(author) SocketAuthority.emitter('author_added', author.toJSON()) + // Update filter data + Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) } authorPayload.push(author.toJSONMinimal()) } @@ -940,6 +950,8 @@ class Scanner { seriesItem = new Series() seriesItem.setData({ name: seriesMatchItem.series }, libraryItem.libraryId) await Database.createSeries(seriesItem) + // Update filter data + Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id) SocketAuthority.emitter('series_added', seriesItem.toJSON()) } seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence)) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 27e1b933..df6855a3 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -221,7 +221,7 @@ module.exports = { })) } - const { rows: series, count } = await Database.models.series.findAndCountAll({ + const { rows: series, count } = await Database.seriesModel.findAndCountAll({ where: seriesWhere, limit, offset: 0, @@ -291,7 +291,7 @@ module.exports = { async getNewestAuthors(library, user, limit) { if (library.mediaType !== 'book') return { authors: [], count: 0 } - const { rows: authors, count } = await Database.models.author.findAndCountAll({ + const { rows: authors, count } = await Database.authorModel.findAndCountAll({ where: { libraryId: library.id, createdAt: { @@ -461,7 +461,7 @@ module.exports = { if (book.language) data.languages.add(book.language) } - const series = await Database.models.series.findAll({ + const series = await Database.seriesModel.findAll({ where: { libraryId: oldLibrary.id }, @@ -469,7 +469,7 @@ module.exports = { }) series.forEach((s) => data.series.push({ id: s.id, name: s.name })) - const authors = await Database.models.author.findAll({ + const authors = await Database.authorModel.findAll({ where: { libraryId: oldLibrary.id }, diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 7495dce6..e30b201d 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -278,7 +278,7 @@ module.exports = { * @returns {object} { booksToExclude, bookSeriesToInclude } */ async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) { - const allSeries = await Database.models.series.findAll({ + const allSeries = await Database.seriesModel.findAll({ attributes: [ 'id', 'name', @@ -642,7 +642,7 @@ module.exports = { const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) bookWhere.push(...userPermissionBookWhere.bookWhere) - const { rows: series, count } = await Database.models.series.findAndCountAll({ + const { rows: series, count } = await Database.seriesModel.findAndCountAll({ where: [ { libraryId @@ -751,7 +751,7 @@ module.exports = { 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({ + const seriesNotStarted = await Database.seriesModel.findAll({ where: [ { libraryId From 4e4a976050cd8dc108366d524a11ad8e88e8ff29 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Aug 2023 17:08:34 -0500 Subject: [PATCH 02/12] Update get library series api endpoint to load from db --- client/components/app/LazyBookshelf.vue | 7 + server/controllers/LibraryController.js | 35 ++- server/models/Series.js | 19 +- server/objects/entities/Series.js | 3 +- server/routers/ApiRouter.js | 1 + server/utils/queries/libraryFilters.js | 16 +- .../utils/queries/libraryItemsBookFilters.js | 2 +- server/utils/queries/seriesFilters.js | 206 ++++++++++++++++++ 8 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 server/utils/queries/seriesFilters.js 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 + } + } +} From b334d40998325dbcaefb30503a811fdb7ea4e928 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 18 Aug 2023 17:12:15 -0500 Subject: [PATCH 03/12] Update library routes to middlewareNew --- server/routers/ApiRouter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3ea25791..8b5138b0 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -92,8 +92,8 @@ class ApiRouter { 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.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/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.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)) From c77cead9ae01f0e1e72b7d03574a2160df94b55b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 13:59:22 -0500 Subject: [PATCH 04/12] Update search endpoints to search db directly --- client/components/cards/NarratorCard.vue | 2 +- client/components/controls/GlobalSearch.vue | 2 +- server/Database.js | 15 ++ server/controllers/LibraryController.js | 82 ++------ server/routers/ApiRouter.js | 2 +- server/utils/queries/libraryItemFilters.js | 19 +- .../utils/queries/libraryItemsBookFilters.js | 199 +++++++++++++++++- .../queries/libraryItemsPodcastFilters.js | 99 +++++++++ 8 files changed, 343 insertions(+), 77 deletions(-) diff --git a/client/components/cards/NarratorCard.vue b/client/components/cards/NarratorCard.vue index 7b8848cc..e1b4840f 100644 --- a/client/components/cards/NarratorCard.vue +++ b/client/components/cards/NarratorCard.vue @@ -36,7 +36,7 @@ export default { return this.narrator?.name || '' }, numBooks() { - return this.narrator?.books?.length || 0 + return this.narrator?.numBooks || this.narrator?.books?.length || 0 }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index a731dbcf..0c5ba41b 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -103,7 +103,7 @@ export default { return this.$store.state.libraries.currentLibraryId }, totalResults() { - return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length } }, methods: { diff --git a/server/Database.js b/server/Database.js index 303e2d4a..faec70b8 100644 --- a/server/Database.js +++ b/server/Database.js @@ -44,6 +44,21 @@ class Database { return this.models.series } + /** @type {typeof import('./models/Book')} */ + get bookModel() { + return this.models.book + } + + /** @type {typeof import('./models/Podcast')} */ + get podcastModel() { + return this.models.podcast + } + + /** @type {typeof import('./models/LibraryItem')} */ + get libraryItemModel() { + return this.models.libraryItem + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index b9bb58bb..97f9c981 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -790,80 +790,22 @@ class LibraryController { }) } - // GET: Global library search - search(req, res) { + /** + * GET: /api/libraries/:id/search + * Search library items with query + * ?q=search + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async search(req, res) { if (!req.query.q) { return res.status(400).send('No query string') } - const libraryItems = req.libraryItems - const maxResults = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 + const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 + const query = req.query.q.trim().toLowerCase() - const itemMatches = [] - const authorMatches = {} - const narratorMatches = {} - const seriesMatches = {} - const tagMatches = {} - - libraryItems.forEach((li) => { - const queryResult = li.searchQuery(req.query.q) - if (queryResult.matchKey) { - itemMatches.push({ - libraryItem: li.toJSONExpanded(), - matchKey: queryResult.matchKey, - matchText: queryResult.matchText - }) - } - if (queryResult.series?.length) { - queryResult.series.forEach((se) => { - if (!seriesMatches[se.id]) { - const _series = Database.series.find(_se => _se.id === se.id) - if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] } - } else { - seriesMatches[se.id].books.push(li.toJSON()) - } - }) - } - if (queryResult.authors?.length) { - queryResult.authors.forEach((au) => { - if (!authorMatches[au.id]) { - const _author = Database.authors.find(_au => _au.id === au.id) - if (_author) { - authorMatches[au.id] = _author.toJSON() - authorMatches[au.id].numBooks = 1 - } - } else { - authorMatches[au.id].numBooks++ - } - }) - } - if (queryResult.tags?.length) { - queryResult.tags.forEach((tag) => { - if (!tagMatches[tag]) { - tagMatches[tag] = { name: tag, books: [li.toJSON()] } - } else { - tagMatches[tag].books.push(li.toJSON()) - } - }) - } - if (queryResult.narrators?.length) { - queryResult.narrators.forEach((narrator) => { - if (!narratorMatches[narrator]) { - narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] } - } else { - narratorMatches[narrator].books.push(li.toJSON()) - } - }) - } - }) - const itemKey = req.library.mediaType - const results = { - [itemKey]: itemMatches.slice(0, maxResults), - tags: Object.values(tagMatches).slice(0, maxResults), - authors: Object.values(authorMatches).slice(0, maxResults), - series: Object.values(seriesMatches).slice(0, maxResults), - narrators: Object.values(narratorMatches).slice(0, maxResults) - } - res.json(results) + const matches = await libraryItemFilters.search(req.library, query, limit) + res.json(matches) } async stats(req, res) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8b5138b0..c0ff10a0 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -86,7 +86,7 @@ class ApiRouter { this.router.get('/libraries/:id/personalized2', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this)) this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.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/search', LibraryController.middlewareNew.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.middlewareNew.bind(this), LibraryController.getAuthors.bind(this)) this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this)) diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index b20916ca..51bb4131 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -1,5 +1,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') +const libraryItemsBookFilters = require('./libraryItemsBookFilters') +const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters') module.exports = { /** @@ -127,7 +129,7 @@ module.exports = { /** * Get all library items that have narrators * @param {string[]} narrators - * @returns {Promise} + * @returns {Promise} */ async getAllLibraryItemsWithNarrators(narrators) { const libraryItems = [] @@ -162,5 +164,20 @@ module.exports = { libraryItems.push(libraryItem) } return libraryItems + }, + + /** + * Search library items + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}} + */ + search(oldLibrary, query, limit) { + if (oldLibrary.isBook) { + return libraryItemsBookFilters.search(oldLibrary, query, limit, 0) + } else { + return libraryItemsPodcastFilters.search(oldLibrary, query, limit, 0) + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 2385edf9..21ef5efa 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -918,12 +918,205 @@ module.exports = { /** * Get library items for series - * @param {oldSeries} oldSeries - * @param {[oldUser]} oldUser - * @returns {Promise} + * @param {import('../../objects/entities/Series')} oldSeries + * @param {import('../../objects/user/User')} [oldUser] + * @returns {Promise} */ async getLibraryItemsForSeries(oldSeries, oldUser) { const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null) return libraryItems.map(li => Database.models.libraryItem.getOldLibraryItem(li)) + }, + + /** + * Search books, authors, series + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @param {number} offset + * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}} + */ + async search(oldLibrary, query, limit, offset) { + // Search title, subtitle, asin, isbn + const books = await Database.bookModel.findAll({ + where: { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + subtitle: { + [Sequelize.Op.substring]: query + } + }, + { + asin: { + [Sequelize.Op.substring]: query + } + }, + { + isbn: { + [Sequelize.Op.substring]: query + } + } + ] + }, + include: [ + { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + }, + { + model: Database.models.bookSeries, + include: { + model: Database.seriesModel + }, + separate: true + }, + { + model: Database.models.bookAuthor, + include: { + model: Database.authorModel + }, + separate: true + } + ], + subQuery: false, + distinct: true, + limit, + offset + }) + + const itemMatches = [] + + for (const book of books) { + const libraryItem = book.libraryItem + delete book.libraryItem + libraryItem.media = book + + let matchText = null + let matchKey = null + for (const key of ['title', 'subtitle', 'asin', 'isbn']) { + if (book[key]?.toLowerCase().includes(query)) { + matchText = book[key] + matchKey = key + break + } + } + + if (matchKey) { + itemMatches.push({ + matchText, + matchKey, + libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + } + } + + // Search narrators + const narratorMatches = [] + const [narratorResults] = await Database.sequelize.query(`SELECT value, count(*) AS numBooks FROM books b, libraryItems li, json_each(b.narrators) WHERE json_valid(b.narrators) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of narratorResults) { + narratorMatches.push({ + name: row.value, + numBooks: row.numBooks + }) + } + + // Search tags + const tagMatches = [] + const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.tags) WHERE json_valid(b.tags) AND json_each.value LIKE :query AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of tagResults) { + tagMatches.push({ + name: row.value, + numItems: row.numItems + }) + } + + // Search series + const allSeries = await Database.seriesModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId: oldLibrary.id + }, + include: { + separate: true, + model: Database.models.bookSeries, + include: { + model: Database.bookModel, + include: { + model: Database.libraryItemModel + } + } + }, + subQuery: false, + distinct: true, + limit, + offset + }) + const seriesMatches = [] + for (const series of allSeries) { + const books = series.bookSeries.map((bs) => { + const libraryItem = bs.book.libraryItem + libraryItem.media = bs.book + return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON() + }) + seriesMatches.push({ + series: series.getOldSeries().toJSON(), + books + }) + } + + // Search authors + const authors = await Database.authorModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId: oldLibrary.id + }, + attributes: { + include: [ + [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks'] + ] + }, + limit, + offset + }) + const authorMatches = [] + for (const author of authors) { + const oldAuthor = author.getOldAuthor().toJSON() + oldAuthor.numBooks = author.dataValues.numBooks + authorMatches.push(oldAuthor) + } + + return { + book: itemMatches, + narrators: narratorMatches, + tags: tagMatches, + series: seriesMatches, + authors: authorMatches + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6462200b..7dc87a60 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -291,5 +291,104 @@ module.exports = { libraryItems, count } + }, + + /** + * Search podcasts + * @param {import('../../objects/Library')} oldLibrary + * @param {string} query + * @param {number} limit + * @param {number} offset + * @returns {{podcast:object[], tags:object[]}} + */ + async search(oldLibrary, query, limit, offset) { + // Search title, author, itunesId, itunesArtistId + const podcasts = await Database.podcastModel.findAll({ + where: { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + author: { + [Sequelize.Op.substring]: query + } + }, + { + itunesId: { + [Sequelize.Op.substring]: query + } + }, + { + itunesArtistId: { + [Sequelize.Op.substring]: query + } + } + ] + }, + include: [ + { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + } + ], + subQuery: false, + distinct: true, + limit, + offset + }) + + const itemMatches = [] + + for (const podcast of podcasts) { + const libraryItem = podcast.libraryItem + delete podcast.libraryItem + libraryItem.media = podcast + + let matchText = null + let matchKey = null + for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) { + if (podcast[key]?.toLowerCase().includes(query)) { + matchText = podcast[key] + matchKey = key + break + } + } + + if (matchKey) { + itemMatches.push({ + matchText, + matchKey, + libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + } + } + + // Search tags + const tagMatches = [] + const [tagResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.tags) WHERE json_valid(p.tags) AND json_each.value LIKE :query AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value LIMIT :limit OFFSET :offset;`, { + replacements: { + query: `%${query}%`, + libraryId: oldLibrary.id, + limit, + offset + }, + raw: true + }) + for (const row of tagResults) { + tagMatches.push({ + name: row.value, + numItems: row.numItems + }) + } + + return { + podcast: itemMatches, + tags: tagMatches + } } } \ No newline at end of file From f21d69339f476582c6fa2a92aac40898293aa2be Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 14:11:34 -0500 Subject: [PATCH 05/12] Update search query to use user permissions --- server/controllers/LibraryController.js | 2 +- server/utils/queries/libraryItemFilters.js | 7 ++- .../utils/queries/libraryItemsBookFilters.js | 59 +++++++++++-------- .../queries/libraryItemsPodcastFilters.js | 58 ++++++++++-------- 4 files changed, 71 insertions(+), 55 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 97f9c981..ea5876b3 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -804,7 +804,7 @@ class LibraryController { const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 const query = req.query.q.trim().toLowerCase() - const matches = await libraryItemFilters.search(req.library, query, limit) + const matches = await libraryItemFilters.search(req.user, req.library, query, limit) res.json(matches) } diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index 51bb4131..e7ffe701 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -168,16 +168,17 @@ module.exports = { /** * Search library items + * @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/Library')} oldLibrary * @param {string} query * @param {number} limit * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[], podcast:object[]}} */ - search(oldLibrary, query, limit) { + search(oldUser, oldLibrary, query, limit) { if (oldLibrary.isBook) { - return libraryItemsBookFilters.search(oldLibrary, query, limit, 0) + return libraryItemsBookFilters.search(oldUser, oldLibrary, query, limit, 0) } else { - return libraryItemsPodcastFilters.search(oldLibrary, query, limit, 0) + return libraryItemsPodcastFilters.search(oldUser, oldLibrary, query, limit, 0) } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 21ef5efa..1a7fcbdc 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -5,7 +5,7 @@ const Logger = require('../../Logger') module.exports = { /** * User permissions to restrict books for explicit content & tags - * @param {oldUser} user + * @param {import('../../objects/user/User')} user * @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }} */ getUserPermissionBookWhereQuery(user) { @@ -929,39 +929,46 @@ module.exports = { /** * Search books, authors, series + * @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/Library')} oldLibrary * @param {string} query * @param {number} limit * @param {number} offset * @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}} */ - async search(oldLibrary, query, limit, offset) { + async search(oldUser, oldLibrary, query, limit, offset) { + const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(oldUser) + // Search title, subtitle, asin, isbn const books = await Database.bookModel.findAll({ - where: { - [Sequelize.Op.or]: [ - { - title: { - [Sequelize.Op.substring]: query + where: [ + { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + subtitle: { + [Sequelize.Op.substring]: query + } + }, + { + asin: { + [Sequelize.Op.substring]: query + } + }, + { + isbn: { + [Sequelize.Op.substring]: query + } } - }, - { - subtitle: { - [Sequelize.Op.substring]: query - } - }, - { - asin: { - [Sequelize.Op.substring]: query - } - }, - { - isbn: { - [Sequelize.Op.substring]: query - } - } - ] - }, + ] + }, + ...userPermissionBookWhere.bookWhere + ], + replacements: userPermissionBookWhere.replacements, include: [ { model: Database.libraryItemModel, @@ -1060,11 +1067,13 @@ module.exports = { }, libraryId: oldLibrary.id }, + replacements: userPermissionBookWhere.replacements, include: { separate: true, model: Database.models.bookSeries, include: { model: Database.bookModel, + where: userPermissionBookWhere.bookWhere, include: { model: Database.libraryItemModel } diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 7dc87a60..b13125ba 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -6,8 +6,8 @@ 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[] } + * @param {import('../../objects/user/User')} user + * @returns {{ podcastWhere:Sequelize.WhereOptions, replacements:object }} */ getUserPermissionPodcastWhereQuery(user) { const podcastWhere = [] @@ -295,39 +295,45 @@ module.exports = { /** * Search podcasts + * @param {import('../../objects/user/User')} oldUser * @param {import('../../objects/Library')} oldLibrary * @param {string} query * @param {number} limit * @param {number} offset * @returns {{podcast:object[], tags:object[]}} */ - async search(oldLibrary, query, limit, offset) { + async search(oldUser, oldLibrary, query, limit, offset) { + const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) // Search title, author, itunesId, itunesArtistId const podcasts = await Database.podcastModel.findAll({ - where: { - [Sequelize.Op.or]: [ - { - title: { - [Sequelize.Op.substring]: query + where: [ + { + [Sequelize.Op.or]: [ + { + title: { + [Sequelize.Op.substring]: query + } + }, + { + author: { + [Sequelize.Op.substring]: query + } + }, + { + itunesId: { + [Sequelize.Op.substring]: query + } + }, + { + itunesArtistId: { + [Sequelize.Op.substring]: query + } } - }, - { - author: { - [Sequelize.Op.substring]: query - } - }, - { - itunesId: { - [Sequelize.Op.substring]: query - } - }, - { - itunesArtistId: { - [Sequelize.Op.substring]: query - } - } - ] - }, + ] + }, + ...userPermissionPodcastWhere.podcastWhere + ], + replacements: userPermissionPodcastWhere.replacements, include: [ { model: Database.libraryItemModel, From 8d451217a36a0d90ec50d30cd5febbcdfacd3428 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 14:49:06 -0500 Subject: [PATCH 06/12] Update recent-episodes API route to load from db --- server/Database.js | 10 +++ server/controllers/LibraryController.js | 35 +++-------- server/models/Podcast.js | 2 +- server/models/PodcastEpisode.js | 9 ++- server/routers/ApiRouter.js | 2 +- .../queries/libraryItemsPodcastFilters.js | 61 ++++++++++++++++++- 6 files changed, 88 insertions(+), 31 deletions(-) diff --git a/server/Database.js b/server/Database.js index faec70b8..1d9bbaa3 100644 --- a/server/Database.js +++ b/server/Database.js @@ -59,6 +59,16 @@ class Database { return this.models.libraryItem } + /** @type {typeof import('./models/PodcastEpisode')} */ + get podcastEpisodeModel() { + return this.models.podcastEpisode + } + + /** @type {typeof import('./models/MediaProgress')} */ + get mediaProgressModel() { + return this.models.mediaProgress + } + async checkHasDb() { if (!await fs.pathExists(this.dbPath)) { Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index ea5876b3..8d2cbc80 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -16,6 +16,7 @@ const naturalSort = createNewSortInstance({ const Database = require('../Database') const libraryFilters = require('../utils/queries/libraryFilters') +const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') class LibraryController { constructor() { } @@ -1024,7 +1025,12 @@ class LibraryController { Logger.info('[LibraryController] Scan complete') } - // GET: api/libraries/:id/recent-episode + /** + * GET: /api/libraries/:id/recent-episodes + * Used for latest page + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async getRecentEpisodes(req, res) { if (!req.library.isPodcast) { return res.sendStatus(404) @@ -1032,35 +1038,12 @@ class LibraryController { const payload = { episodes: [], - 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, } - var allUnfinishedEpisodes = [] - for (const libraryItem of req.libraryItems) { - const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => { - const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id) - return !userProgress || !userProgress.isFinished - }).map(_ep => { - const ep = _ep.toJSONExpanded() - ep.podcast = libraryItem.media.toJSONMinified() - ep.libraryItemId = libraryItem.id - ep.libraryId = libraryItem.libraryId - return ep - }) - allUnfinishedEpisodes.push(...unfinishedEpisodes) - } - - payload.total = allUnfinishedEpisodes.length - - allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt) - - if (payload.limit) { - var startIndex = payload.page * payload.limit - allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit) - } - payload.episodes = allUnfinishedEpisodes + const offset = payload.page * payload.limit + payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset) res.json(payload) } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 5f75ee3c..06b410cf 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -54,7 +54,7 @@ class Podcast extends Model { static getOldPodcast(libraryItemExpanded) { const podcastExpanded = libraryItemExpanded.media - const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) + const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) return { id: podcastExpanded.id, libraryItemId: libraryItemExpanded.id, diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 6aff7866..2626ee0f 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -1,4 +1,5 @@ const { DataTypes, Model } = require('sequelize') +const oldPodcastEpisode = require('../objects/entities/PodcastEpisode') class PodcastEpisode extends Model { constructor(values, options) { @@ -44,6 +45,10 @@ class PodcastEpisode extends Model { this.updatedAt } + /** + * @param {string} libraryItemId + * @returns {oldPodcastEpisode} + */ getOldPodcastEpisode(libraryItemId = null) { let enclosure = null if (this.enclosureURL) { @@ -53,7 +58,7 @@ class PodcastEpisode extends Model { length: this.enclosureSize !== null ? String(this.enclosureSize) : null } } - return { + return new oldPodcastEpisode({ libraryItemId: libraryItemId || null, podcastId: this.podcastId, id: this.id, @@ -72,7 +77,7 @@ class PodcastEpisode extends Model { publishedAt: this.publishedAt?.valueOf() || null, addedAt: this.createdAt.valueOf(), updatedAt: this.updatedAt.valueOf() - } + }) } static createFromOld(oldEpisode) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c0ff10a0..e95cf1c3 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -94,7 +94,7 @@ class ApiRouter { 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.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) + this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.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/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index b13125ba..e14718e5 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -283,7 +283,7 @@ module.exports = { const podcast = ep.podcast.toJSON() delete podcast.libraryItem libraryItem.media = podcast - libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id) + libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON() return libraryItem }) @@ -396,5 +396,64 @@ module.exports = { podcast: itemMatches, tags: tagMatches } + }, + + /** + * Most recent podcast episodes not finished + * @param {import('../../objects/user/User')} oldUser + * @param {import('../../objects/Library')} oldLibrary + * @param {number} limit + * @param {number} offset + * @returns {Promise} + */ + async getRecentEpisodes(oldUser, oldLibrary, limit, offset) { + const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(oldUser) + + const episodes = await Database.podcastEpisodeModel.findAll({ + where: { + '$mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, false] + } + }, + replacements: userPermissionPodcastWhere.replacements, + include: [ + { + model: Database.podcastModel, + where: userPermissionPodcastWhere.podcastWhere, + required: true, + include: { + model: Database.libraryItemModel, + where: { + libraryId: oldLibrary.id + } + } + }, + { + model: Database.mediaProgressModel, + where: { + userId: oldUser.id + }, + required: false + } + ], + order: [ + ['publishedAt', 'DESC'] + ], + subQuery: false, + limit, + offset + }) + + const episodeResults = episodes.map((ep) => { + const libraryItem = ep.podcast.libraryItem + libraryItem.media = ep.podcast + const oldPodcast = Database.podcastModel.getOldPodcast(libraryItem) + const oldPodcastEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSONExpanded() + oldPodcastEpisode.podcast = oldPodcast + oldPodcastEpisode.libraryId = libraryItem.libraryId + return oldPodcastEpisode + }) + + return episodeResults } } \ No newline at end of file From ff0d6326d3ba5beb00bd7e47e1258135ccfdd6b7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 15:19:27 -0500 Subject: [PATCH 07/12] Update OPML api route to load podcasts from db --- server/controllers/LibraryController.js | 24 +++++++++++++++++-- server/managers/PodcastManager.js | 9 +++++-- server/routers/ApiRouter.js | 2 +- server/utils/generators/opmlGenerator.js | 30 ++++++++++++++---------- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 8d2cbc80..b9cf29a8 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1047,8 +1047,28 @@ class LibraryController { res.json(payload) } - getOPMLFile(req, res) { - const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems) + /** + * GET: /api/libraries/:id/opml + * Get OPML file for a podcast library + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getOPMLFile(req, res) { + const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user) + const podcasts = await Database.podcastModel.findAll({ + attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'], + where: userPermissionPodcastWhere.podcastWhere, + replacements: userPermissionPodcastWhere.replacements, + include: { + model: Database.libraryItemModel, + attributes: ['id', 'libraryId'], + where: { + libraryId: req.library.id + } + } + }) + + const opmlText = this.podcastManager.generateOPMLFileText(podcasts) res.type('application/xml') res.send(opmlText) } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 743cb60b..b99a4c7a 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -372,8 +372,13 @@ class PodcastManager { } } - generateOPMLFileText(libraryItems) { - return opmlGenerator.generate(libraryItems) + /** + * OPML file string for podcasts in a library + * @param {import('../models/Podcast')[]} podcasts + * @returns {string} XML string + */ + generateOPMLFileText(podcasts) { + return opmlGenerator.generate(podcasts) } getDownloadQueueDetails(libraryId = null) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e95cf1c3..fff3bc91 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -95,7 +95,7 @@ class ApiRouter { 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.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) + this.router.get('/libraries/:id/opml', LibraryController.middlewareNew.bind(this), LibraryController.getOPMLFile.bind(this)) this.router.post('/libraries/order', LibraryController.reorder.bind(this)) // diff --git a/server/utils/generators/opmlGenerator.js b/server/utils/generators/opmlGenerator.js index 60d3d450..8cc3f7fb 100644 --- a/server/utils/generators/opmlGenerator.js +++ b/server/utils/generators/opmlGenerator.js @@ -1,23 +1,29 @@ const xml = require('../../libs/xml') -module.exports.generate = (libraryItems, indent = true) => { +/** + * Generate OPML file string for podcasts in a library + * @param {import('../../models/Podcast')[]} podcasts + * @param {boolean} [indent=true] + * @returns {string} + */ +module.exports.generate = (podcasts, indent = true) => { const bodyItems = [] - libraryItems.forEach((item) => { - if (!item.media.metadata.feedUrl) return + podcasts.forEach((podcast) => { + if (!podcast.feedURL) return const feedAttributes = { type: 'rss', - text: item.media.metadata.title, - title: item.media.metadata.title, - xmlUrl: item.media.metadata.feedUrl + text: podcast.title, + title: podcast.title, + xmlUrl: podcast.feedURL } - if (item.media.metadata.description) { - feedAttributes.description = item.media.metadata.description + if (podcast.description) { + feedAttributes.description = podcast.description } - if (item.media.metadata.itunesPageUrl) { - feedAttributes.htmlUrl = item.media.metadata.itunesPageUrl + if (podcast.itunesPageUrl) { + feedAttributes.htmlUrl = podcast.itunesPageUrl } - if (item.media.metadata.language) { - feedAttributes.language = item.media.metadata.language + if (podcast.language) { + feedAttributes.language = podcast.language } bodyItems.push({ outline: { From 332078e6c12c66d44a5e17f316450a136f13aba6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 19 Aug 2023 16:53:33 -0500 Subject: [PATCH 08/12] Update library stats API route to load from db --- client/components/stats/PreviewIcons.vue | 18 ++-- client/pages/config/library-stats.vue | 22 +++-- server/controllers/LibraryController.js | 54 +++++++---- server/routers/ApiRouter.js | 2 +- server/utils/queries/authorFilters.js | 69 ++++++++++++++ server/utils/queries/libraryItemFilters.js | 36 ++++++++ .../utils/queries/libraryItemsBookFilters.js | 89 ++++++++++++++----- .../queries/libraryItemsPodcastFilters.js | 70 +++++++++++++++ 8 files changed, 308 insertions(+), 52 deletions(-) create mode 100644 server/utils/queries/authorFilters.js diff --git a/client/components/stats/PreviewIcons.vue b/client/components/stats/PreviewIcons.vue index 488eae49..7bda889b 100644 --- a/client/components/stats/PreviewIcons.vue +++ b/client/components/stats/PreviewIcons.vue @@ -18,7 +18,7 @@ -
+
@@ -58,26 +58,32 @@ export default { return {} }, computed: { + currentLibraryMediaType() { + return this.$store.getters['libraries/getCurrentLibraryMediaType'] + }, + isBookLibrary() { + return this.currentLibraryMediaType === 'book' + }, user() { return this.$store.state.user.user }, totalItems() { - return this.libraryStats ? this.libraryStats.totalItems : 0 + return this.libraryStats?.totalItems || 0 }, totalAuthors() { - return this.libraryStats ? this.libraryStats.totalAuthors : 0 + return this.libraryStats?.totalAuthors || 0 }, numAudioTracks() { - return this.libraryStats ? this.libraryStats.numAudioTracks : 0 + return this.libraryStats?.numAudioTracks || 0 }, totalDuration() { - return this.libraryStats ? this.libraryStats.totalDuration : 0 + return this.libraryStats?.totalDuration || 0 }, totalHours() { return Math.round(this.totalDuration / (60 * 60)) }, totalSizePretty() { - var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0 + var totalSize = this.libraryStats?.totalSize || 0 return this.$bytesPretty(totalSize, 1) }, totalSizeNum() { diff --git a/client/pages/config/library-stats.vue b/client/pages/config/library-stats.vue index cc3dbf8e..1a95c630 100644 --- a/client/pages/config/library-stats.vue +++ b/client/pages/config/library-stats.vue @@ -22,7 +22,7 @@
-
+

{{ $strings.HeaderStatsTop10Authors }}

{{ $strings.MessageNoAuthors }}