From 6d6e8613cfd6023493bd559aa3e317313712a295 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 13 Aug 2023 17:45:53 -0500 Subject: [PATCH] Update library API endpoints to load library items from db --- server/Database.js | 14 + server/controllers/LibraryController.js | 280 +++++++++++++++----- server/controllers/LibraryItemController.js | 4 +- server/controllers/MiscController.js | 32 +-- server/models/Author.js | 19 +- server/objects/Library.js | 12 +- server/routers/ApiRouter.js | 65 +++-- server/utils/queries/libraryItemFilters.js | 40 +++ 8 files changed, 352 insertions(+), 114 deletions(-) diff --git a/server/Database.js b/server/Database.js index 3f0fdf79..3c5f0461 100644 --- a/server/Database.js +++ b/server/Database.js @@ -467,6 +467,20 @@ class Database { } } } + + removeNarratorFromFilterData(narrator) { + for (const libraryId in this.libraryFilterData) { + this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator) + } + } + + addNarratorToFilterData(narrator) { + for (const libraryId in this.libraryFilterData) { + if (!this.libraryFilterData[libraryId].narrators.includes(narrator)) { + this.libraryFilterData[libraryId].narrators.push(narrator) + } + } + } } module.exports = new Database() \ No newline at end of file diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 11f545ce..70c63708 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1,3 +1,4 @@ +const Sequelize = require('sequelize') const Path = require('path') const fs = require('../libs/fsExtra') const filePerms = require('../utils/filePerms') @@ -6,6 +7,7 @@ const SocketAuthority = require('../SocketAuthority') const Library = require('../objects/Library') const libraryHelpers = require('../utils/libraryHelpers') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') +const libraryItemFilters = require('../utils/queries/libraryItemFilters') const { sort, createNewSortInstance } = require('../libs/fastSort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -134,6 +136,40 @@ class LibraryController { await filePerms.setDefault(path) } } + + // Handle removing folders + for (const folder of library.folders) { + if (!req.body.folders.some(f => f.id === folder.id)) { + // Remove library items in folder + const libraryItemsInFolder = await Database.models.libraryItem.findAll({ + where: { + libraryFolderId: folder.id + }, + attributes: ['id', 'mediaId', 'mediaType'], + include: [ + { + model: Database.models.podcast, + attributes: ['id'], + include: { + model: Database.models.podcastEpisode, + attributes: ['id'] + } + } + ] + }) + Logger.info(`[LibraryController] Removed folder "${folder.fullPath}" from library "${library.name}" with ${libraryItemsInFolder.length} library items`) + for (const libraryItem of libraryItemsInFolder) { + let mediaItemIds = [] + if (library.isPodcast) { + mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id) + } else { + mediaItemIds.push(libraryItem.mediaId) + } + Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.fullPath}"`) + await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + } + } + } } const hasUpdates = library.update(req.body) @@ -145,14 +181,6 @@ class LibraryController { // Update auto scan cron this.cronManager.updateLibraryScanCron(library) - // Remove libraryItems no longer in library - const itemsToRemove = Database.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path)) - if (itemsToRemove.length) { - Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`) - for (let i = 0; i < itemsToRemove.length; i++) { - await this.handleDeleteLibraryItem(itemsToRemove[i]) - } - } await Database.updateLibrary(library) // Only emit to users with access to library @@ -183,10 +211,32 @@ class LibraryController { } // Remove items in this library - const libraryItems = Database.libraryItems.filter(li => li.libraryId === library.id) - Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`) - for (let i = 0; i < libraryItems.length; i++) { - await this.handleDeleteLibraryItem(libraryItems[i]) + const libraryItemsInLibrary = await Database.models.libraryItem.findAll({ + where: { + libraryId: library.id + }, + attributes: ['id', 'mediaId', 'mediaType'], + include: [ + { + model: Database.models.podcast, + attributes: ['id'], + include: { + model: Database.models.podcastEpisode, + attributes: ['id'] + } + } + ] + }) + Logger.info(`[LibraryController] Removing ${libraryItemsInLibrary.length} library items in library "${library.name}"`) + for (const libraryItem of libraryItemsInLibrary) { + let mediaItemIds = [] + if (library.isPodcast) { + mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id) + } else { + mediaItemIds.push(libraryItem.mediaId) + } + Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${library.name}"`) + await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) } const libraryJson = library.toJSON() @@ -270,16 +320,6 @@ class LibraryController { if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) { libraryItems = collapsedItems - - // Get accurate total entities - // let uniqueEntities = new Set() - // libraryItems.forEach((item) => { - // if (item.collapsedSeries) { - // item.collapsedSeries.books.forEach(book => uniqueEntities.add(book.id)) - // } else { - // uniqueEntities.add(item.id) - // } - // }) payload.total = libraryItems.length } } @@ -428,8 +468,37 @@ class LibraryController { res.json(payload) } + /** + * DELETE: /libraries/:id/issues + * Remove all library items missing or invalid + * @param {*} req + * @param {*} res + */ async removeLibraryItemsWithIssues(req, res) { - const libraryItemsWithIssues = req.libraryItems.filter(li => li.hasIssues) + const libraryItemsWithIssues = await Database.models.libraryItem.findAll({ + where: { + [Sequelize.Op.or]: [ + { + isMissing: true + }, + { + isInvalid: true + } + ] + }, + attributes: ['id', 'mediaId', 'mediaType'], + include: [ + { + model: Database.models.podcast, + attributes: ['id'], + include: { + model: Database.models.podcastEpisode, + attributes: ['id'] + } + } + ] + }) + if (!libraryItemsWithIssues.length) { Logger.warn(`[LibraryController] No library items have issues`) return res.sendStatus(200) @@ -437,8 +506,14 @@ class LibraryController { Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) for (const libraryItem of libraryItemsWithIssues) { - Logger.info(`[LibraryController] Removing library item "${libraryItem.media.metadata.title}"`) - await this.handleDeleteLibraryItem(libraryItem) + let mediaItemIds = [] + if (library.isPodcast) { + mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id) + } else { + mediaItemIds.push(libraryItem.mediaId) + } + Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) + await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) } res.sendStatus(200) @@ -633,6 +708,7 @@ class LibraryController { /** * GET: /api/libraries/:id/personalized + * TODO: remove after personalized2 is ready * @param {*} req * @param {*} res */ @@ -780,54 +856,97 @@ class LibraryController { res.json(stats) } + /** + * GET: /api/libraries/:id/authors + * Get authors for library + * @param {*} req + * @param {*} res + */ async getAuthors(req, res) { - const authors = {} - req.libraryItems.forEach((li) => { - if (li.media.metadata.authors && li.media.metadata.authors.length) { - li.media.metadata.authors.forEach((au) => { - if (!authors[au.id]) { - const _author = Database.authors.find(_au => _au.id === au.id) - if (_author) { - authors[au.id] = _author.toJSON() - authors[au.id].numBooks = 1 - } - } else { - authors[au.id].numBooks++ - } - }) - } + const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user) + const authors = await Database.models.author.findAll({ + where: { + libraryId: req.library.id + }, + replacements, + include: { + model: Database.models.book, + attributes: ['id', 'tags', 'explicit'], + where: bookWhere, + required: true, + through: { + attributes: [] + } + }, + order: [ + [Sequelize.literal('name COLLATE NOCASE'), 'ASC'] + ] }) + const oldAuthors = [] + + for (const author of authors) { + const oldAuthor = author.getOldAuthor().toJSON() + oldAuthor.numBooks = author.books.length + oldAuthors.push(oldAuthor) + } + res.json({ - authors: naturalSort(Object.values(authors)).asc(au => au.name) + authors: oldAuthors }) } + /** + * GET: /api/libraries/:id/narrators + * @param {*} req + * @param {*} res + */ async getNarrators(req, res) { - const narrators = {} - req.libraryItems.forEach((li) => { - if (li.media.metadata.narrators?.length) { - li.media.metadata.narrators.forEach((n) => { - if (typeof n !== 'string') { - Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${li.media.metadata.title}"`) - } else if (!narrators[n]) { - narrators[n] = { - id: encodeURIComponent(Buffer.from(n).toString('base64')), - name: n, - numBooks: 1 - } - } else { - narrators[n].numBooks++ - } - }) - } + // Get all books with narrators + const booksWithNarrators = await Database.models.book.findAll({ + where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('narrators')), { + [Sequelize.Op.gt]: 0 + }), + include: { + model: Database.models.libraryItem, + attributes: ['id', 'libraryId'], + where: { + libraryId: req.library.id + } + }, + attributes: ['id', 'narrators'] }) + const narrators = {} + for (const book of booksWithNarrators) { + book.narrators.forEach(n => { + if (typeof n !== 'string') { + Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${book.title}"`) + } else if (!narrators[n]) { + narrators[n] = { + id: encodeURIComponent(Buffer.from(n).toString('base64')), + name: n, + numBooks: 1 + } + } else { + narrators[n].numBooks++ + } + }) + } + res.json({ narrators: naturalSort(Object.values(narrators)).asc(n => n.name) }) } + /** + * PATCH: /api/libraries/:id/narrators/:narratorId + * Update narrator name + * :narratorId is base64 encoded name + * req.body { name } + * @param {*} req + * @param {*} res + */ async updateNarrator(req, res) { if (!req.user.canUpdate) { Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to update narrator`) @@ -840,15 +959,27 @@ class LibraryController { return res.status(400).send('Invalid request payload. Name not specified.') } + // Update filter data + Database.removeNarratorFromFilterData(narratorName) + Database.addNarratorToFilterData(updatedName) + const itemsUpdated = [] - for (const libraryItem of req.libraryItems) { - if (libraryItem.media.metadata.updateNarrator(narratorName, updatedName)) { - itemsUpdated.push(libraryItem) + + const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName]) + + for (const libraryItem of itemsWithNarrator) { + libraryItem.media.narrators = libraryItem.media.narrators.filter(n => n !== narratorName) + if (!libraryItem.media.narrators.includes(updatedName)) { + libraryItem.media.narrators.push(updatedName) } + await libraryItem.media.update({ + narrators: libraryItem.media.narrators + }) + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem) + itemsUpdated.push(oldLibraryItem) } if (itemsUpdated.length) { - await Database.updateBulkBooks(itemsUpdated.map(i => i.media)) SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) } @@ -857,6 +988,13 @@ class LibraryController { }) } + /** + * DELETE: /api/libraries/:id/narrators/:narratorId + * Remove narrator + * :narratorId is base64 encoded name + * @param {*} req + * @param {*} res + */ async removeNarrator(req, res) { if (!req.user.canUpdate) { Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to remove narrator`) @@ -865,15 +1003,23 @@ class LibraryController { const narratorName = libraryHelpers.decode(req.params.narratorId) + // Update filter data + Database.removeNarratorFromFilterData(narratorName) + const itemsUpdated = [] - for (const libraryItem of req.libraryItems) { - if (libraryItem.media.metadata.removeNarrator(narratorName)) { - itemsUpdated.push(libraryItem) - } + + const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName]) + + for (const libraryItem of itemsWithNarrator) { + libraryItem.media.narrators = libraryItem.media.narrators.filter(n => n !== narratorName) + await libraryItem.media.update({ + narrators: libraryItem.media.narrators + }) + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem) + itemsUpdated.push(oldLibraryItem) } if (itemsUpdated.length) { - await Database.updateBulkBooks(itemsUpdated.map(i => i.media)) SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 2aa361f1..bdfc9322 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -71,7 +71,7 @@ class LibraryItemController { async delete(req, res) { const hardDelete = req.query.hard == 1 // Delete from file system const libraryItemPath = req.libraryItem.path - await this.handleDeleteLibraryItem(req.libraryItem) + await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, [req.libraryItem.media.id]) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { @@ -324,7 +324,7 @@ class LibraryItemController { for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path Logger.info(`[LibraryItemController] Deleting Library Item "${libraryItem.media.metadata.title}"`) - await this.handleDeleteLibraryItem(libraryItem) + await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, [libraryItem.media.id]) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index e796cc03..603a9976 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -235,19 +235,18 @@ class MiscController { const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag]) for (const libraryItem of libraryItemsWithTag) { - let existingTags = libraryItem.media.tags - if (existingTags.includes(newTag)) { + if (libraryItem.media.tags.includes(newTag)) { tagMerged = true // new tag is an existing tag so this is a merge } - if (existingTags.includes(tag)) { - existingTags = existingTags.filter(t => t !== tag) // Remove old tag - if (!existingTags.includes(newTag)) { - existingTags.push(newTag) + if (libraryItem.media.tags.includes(tag)) { + libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) // Remove old tag + if (!libraryItem.media.tags.includes(newTag)) { + libraryItem.media.tags.push(newTag) } Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${libraryItem.media.title}"`) await libraryItem.media.update({ - tags: existingTags + tags: libraryItem.media.tags }) const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) @@ -286,8 +285,9 @@ class MiscController { // Remove tag from items for (const libraryItem of libraryItemsWithTag) { Logger.debug(`[MiscController] Remove tag "${tag}" from item "${libraryItem.media.title}"`) + libraryItem.media.tags = libraryItem.media.tags.filter(t => t !== tag) await libraryItem.media.update({ - tags: libraryItem.media.tags.filter(t => t !== tag) + tags: libraryItem.media.tags }) const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) @@ -369,19 +369,18 @@ class MiscController { const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre]) for (const libraryItem of libraryItemsWithGenre) { - let existingGenres = libraryItem.media.genres - if (existingGenres.includes(newGenre)) { + if (libraryItem.media.genres.includes(newGenre)) { genreMerged = true // new genre is an existing genre so this is a merge } - if (existingGenres.includes(genre)) { - existingGenres = existingGenres.filter(t => t !== genre) // Remove old genre - if (!existingGenres.includes(newGenre)) { - existingGenres.push(newGenre) + if (libraryItem.media.genres.includes(genre)) { + libraryItem.media.genres = libraryItem.media.genres.filter(t => t !== genre) // Remove old genre + if (!libraryItem.media.genres.includes(newGenre)) { + libraryItem.media.genres.push(newGenre) } Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${libraryItem.media.title}"`) await libraryItem.media.update({ - genres: existingGenres + genres: libraryItem.media.genres }) const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) @@ -420,8 +419,9 @@ class MiscController { // Remove genre from items for (const libraryItem of libraryItemsWithGenre) { Logger.debug(`[MiscController] Remove genre "${genre}" from item "${libraryItem.media.title}"`) + libraryItem.media.genres = libraryItem.media.genres.filter(g => g !== genre) await libraryItem.media.update({ - genres: libraryItem.media.genres.filter(g => g !== genre) + genres: libraryItem.media.genres }) const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) diff --git a/server/models/Author.js b/server/models/Author.js index da6189e4..58a4a870 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -75,7 +75,24 @@ module.exports = (sequelize) => { imagePath: DataTypes.STRING }, { sequelize, - modelName: 'author' + modelName: 'author', + indexes: [ + { + fields: [{ + name: 'name', + collate: 'NOCASE' + }] + }, + { + fields: [{ + name: 'lastFirst', + collate: 'NOCASE' + }] + }, + { + fields: ['libraryId'] + } + ] }) const { library } = sequelize.models diff --git a/server/objects/Library.js b/server/objects/Library.js index 17c87734..c5ef216a 100644 --- a/server/objects/Library.js +++ b/server/objects/Library.js @@ -116,9 +116,9 @@ class Library { } update(payload) { - var hasUpdates = false + let hasUpdates = false - var keysToCheck = ['name', 'provider', 'mediaType', 'icon'] + const keysToCheck = ['name', 'provider', 'mediaType', 'icon'] keysToCheck.forEach((key) => { if (payload[key] && payload[key] !== this[key]) { this[key] = payload[key] @@ -135,18 +135,18 @@ class Library { hasUpdates = true } if (payload.folders) { - var newFolders = payload.folders.filter(f => !f.id) - var removedFolders = this.folders.filter(f => !payload.folders.find(_f => _f.id === f.id)) + const newFolders = payload.folders.filter(f => !f.id) + const removedFolders = this.folders.filter(f => !payload.folders.some(_f => _f.id === f.id)) if (removedFolders.length) { - var removedFolderIds = removedFolders.map(f => f.id) + const removedFolderIds = removedFolders.map(f => f.id) this.folders = this.folders.filter(f => !removedFolderIds.includes(f.id)) } if (newFolders.length) { newFolders.forEach((folderData) => { folderData.libraryId = this.id - var newFolder = new Folder() + const newFolder = new Folder() newFolder.setData(folderData) this.folders.push(newFolder) }) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index aa8c03a7..5499713a 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -71,8 +71,8 @@ class ApiRouter { this.router.post('/libraries', LibraryController.create.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this)) this.router.get('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.findOne.bind(this)) - this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this)) - this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this)) + this.router.patch('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.update.bind(this)) + this.router.delete('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.delete.bind(this)) this.router.get('/libraries/:id/items2', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryItemsNew.bind(this)) this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this)) @@ -87,10 +87,10 @@ class ApiRouter { 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/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) - this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) - this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this)) - this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this)) - this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this)) + this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this)) + this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this)) + this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this)) + this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this)) this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this)) this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) @@ -380,24 +380,41 @@ class ApiRouter { return json } - async handleDeleteLibraryItem(libraryItem) { + /** + * Remove library item and associated entities + * @param {string} mediaType + * @param {string} libraryItemId + * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId + */ + async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { // Remove media progress for this library item from all users const users = await Database.models.user.getOldUsers() for (const user of users) { - for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItem.id)) { + for (const mediaProgress of user.getAllMediaProgressForLibraryItem(libraryItemId)) { await Database.removeMediaProgress(mediaProgress.id) } } // TODO: Remove open sessions for library item - let mediaItemIds = [] - if (libraryItem.isBook) { - // Check remove empty series - await this.checkRemoveEmptySeries(libraryItem.media.metadata.series, libraryItem.id) - mediaItemIds.push(libraryItem.media.id) - } else if (libraryItem.isPodcast) { - mediaItemIds.push(...libraryItem.media.episodes.map(ep => ep.id)) + // Remove series if empty + if (mediaType === 'book') { + const bookSeries = await Database.models.bookSeries.findAll({ + where: { + bookId: mediaItemIds[0] + }, + include: { + model: Database.models.series, + include: { + model: Database.models.book + } + } + }) + for (const bs of bookSeries) { + if (bs.series.books.length === 1) { + await this.removeEmptySeries(bs.series) + } + } } // remove item from playlists @@ -433,23 +450,21 @@ class ApiRouter { } // Close rss feed - remove from db and emit socket event - await this.rssFeedManager.closeFeedForEntityId(libraryItem.id) + await this.rssFeedManager.closeFeedForEntityId(libraryItemId) // purge cover cache - if (libraryItem.media.coverPath) { - await this.cacheManager.purgeCoverCache(libraryItem.id) - } + await this.cacheManager.purgeCoverCache(libraryItemId) - const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItem.id) + const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) if (await fs.pathExists(itemMetadataPath)) { Logger.debug(`[ApiRouter] Removing item metadata path "${itemMetadataPath}"`) await fs.remove(itemMetadataPath) } - await Database.removeLibraryItem(libraryItem.id) + await Database.removeLibraryItem(libraryItemId) SocketAuthority.emitter('item_removed', { - id: libraryItem.id + id: libraryItemId }) } @@ -468,6 +483,12 @@ class ApiRouter { } } + 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) + } + async getUserListeningSessionsHelper(userId) { const userSessions = await Database.getPlaybackSessions({ userId }) return userSessions.sort((a, b) => b.updatedAt - a.updatedAt) diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index 7fe8fbeb..b20916ca 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -122,5 +122,45 @@ module.exports = { libraryItems.push(libraryItem) } return libraryItems + }, + + /** + * Get all library items that have narrators + * @param {string[]} narrators + * @returns {Promise} + */ + async getAllLibraryItemsWithNarrators(narrators) { + const libraryItems = [] + const booksWithGenre = await Database.models.book.findAll({ + where: Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(narrators) WHERE json_valid(narrators) AND json_each.value IN (:narrators))`), { + [Sequelize.Op.gte]: 1 + }), + replacements: { + narrators + }, + include: [ + { + model: Database.models.libraryItem + }, + { + model: Database.models.author, + through: { + attributes: [] + } + }, + { + model: Database.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }) + for (const book of booksWithGenre) { + const libraryItem = book.libraryItem + libraryItem.media = book + libraryItems.push(libraryItem) + } + return libraryItems } } \ No newline at end of file