diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index ce227c2811..bf59619997 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -54,8 +54,7 @@ export default { options: { provider: undefined, overrideDetails: true, - overrideCover: true, - overrideDefaults: true + overrideCover: true } } }, @@ -99,8 +98,8 @@ export default { init() { // If we don't have a set provider (first open of dialog) or we've switched library, set // the selected provider to the current library default provider - if (!this.options.provider || this.options.lastUsedLibrary != this.currentLibraryId) { - this.options.lastUsedLibrary = this.currentLibraryId + if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) { + this.lastUsedLibrary = this.currentLibraryId this.options.provider = this.libraryProvider } }, diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 6e7fbea30d..f42a023d4c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1217,7 +1217,7 @@ class LibraryController { Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`) return res.sendStatus(403) } - Scanner.matchLibraryItems(req.library) + Scanner.matchLibraryItems(this, req.library) res.sendStatus(200) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f2a4383e2d..17c7be8387 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -456,10 +456,24 @@ class LibraryItemController { * @param {Response} res */ async match(req, res) { - var libraryItem = req.libraryItem + const libraryItem = req.libraryItem + const reqBody = req.body || {} - var options = req.body || {} - var matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options) + const options = {} + const matchOptions = ['provider', 'title', 'author', 'isbn', 'asin'] + for (const key of matchOptions) { + if (reqBody[key] && typeof reqBody[key] === 'string') { + options[key] = reqBody[key] + } + } + if (reqBody.overrideCover !== undefined) { + options.overrideCover = !!reqBody.overrideCover + } + if (reqBody.overrideDetails !== undefined) { + options.overrideDetails = !!reqBody.overrideDetails + } + + var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) res.json(matchResult) } @@ -642,7 +656,6 @@ class LibraryItemController { let itemsUpdated = 0 let itemsUnmatched = 0 - const options = req.body.options || {} if (!req.body.libraryItemIds?.length) { return res.sendStatus(400) } @@ -656,8 +669,20 @@ class LibraryItemController { res.sendStatus(200) + const reqBodyOptions = req.body.options || {} + const options = {} + if (reqBodyOptions.provider && typeof reqBodyOptions.provider === 'string') { + options.provider = reqBodyOptions.provider + } + if (reqBodyOptions.overrideCover !== undefined) { + options.overrideCover = !!reqBodyOptions.overrideCover + } + if (reqBodyOptions.overrideDetails !== undefined) { + options.overrideDetails = !!reqBodyOptions.overrideDetails + } + for (const libraryItem of libraryItems) { - const matchResult = await Scanner.quickMatchLibraryItem(libraryItem, options) + const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) if (matchResult.updated) { itemsUpdated++ } else if (matchResult.warning) { diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 03d3c167d1..cedf0dfb81 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -342,7 +342,6 @@ class RssFeedManager { } }) if (!feed) { - Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`) return false } return this.handleCloseFeed(feed) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index cfdeb1402a..942c4d0298 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -13,36 +13,58 @@ const LibraryScanner = require('./LibraryScanner') const CoverManager = require('../managers/CoverManager') const TaskManager = require('../managers/TaskManager') +/** + * @typedef QuickMatchOptions + * @property {string} [provider] + * @property {string} [title] + * @property {string} [author] + * @property {string} [isbn] - This override is currently unused in Abs clients + * @property {string} [asin] - This override is currently unused in Abs clients + * @property {boolean} [overrideCover] + * @property {boolean} [overrideDetails] + */ + class Scanner { constructor() {} - async quickMatchLibraryItem(libraryItem, options = {}) { - var provider = options.provider || 'google' - var searchTitle = options.title || libraryItem.media.metadata.title - var searchAuthor = options.author || libraryItem.media.metadata.authorName - var overrideDefaults = options.overrideDefaults || false + /** + * + * @param {import('../routers/ApiRouter')} apiRouterCtx + * @param {import('../objects/LibraryItem')} libraryItem + * @param {QuickMatchOptions} options + * @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>} + */ + async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) { + const provider = options.provider || 'google' + const searchTitle = options.title || libraryItem.media.metadata.title + const searchAuthor = options.author || libraryItem.media.metadata.authorName - // Set to override existing metadata if scannerPreferMatchedMetadata setting is true and - // the overrideDefaults option is not set or set to false. - if (overrideDefaults == false && Database.serverSettings.scannerPreferMatchedMetadata) { + // If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override + if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) { options.overrideCover = true options.overrideDetails = true } - var updatePayload = {} - var hasUpdated = false + let updatePayload = {} + let hasUpdated = false + + let existingAuthors = [] // Used for checking if authors or series are now empty + let existingSeries = [] if (libraryItem.isBook) { - var searchISBN = options.isbn || libraryItem.media.metadata.isbn - var searchASIN = options.asin || libraryItem.media.metadata.asin + existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id) + existingSeries = libraryItem.media.metadata.series.map((s) => s.id) + + const searchISBN = options.isbn || libraryItem.media.metadata.isbn + const searchASIN = options.asin || libraryItem.media.metadata.asin - var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) + const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) if (!results.length) { return { warning: `No ${provider} match found` } } - var matchData = results[0] + const matchData = results[0] // Update cover if not set OR overrideCover flag if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { @@ -58,13 +80,13 @@ class Scanner { updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) } else if (libraryItem.isPodcast) { // Podcast quick match - var results = await PodcastFinder.search(searchTitle) + const results = await PodcastFinder.search(searchTitle) if (!results.length) { return { warning: `No ${provider} match found` } } - var matchData = results[0] + const matchData = results[0] // Update cover if not set OR overrideCover flag if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { @@ -95,6 +117,19 @@ class Scanner { await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + + // Check if any authors or series are now empty and should be removed + if (libraryItem.isBook) { + const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid)) + const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid)) + + if (authorsRemoved.length) { + await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved) + } + if (seriesRemoved.length) { + await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved) + } + } } return { @@ -149,6 +184,13 @@ class Scanner { return updatePayload } + /** + * + * @param {import('../objects/LibraryItem')} libraryItem + * @param {*} matchData + * @param {QuickMatchOptions} options + * @returns + */ async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) { // Update media metadata if not set OR overrideDetails flag const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn'] @@ -307,12 +349,13 @@ class Scanner { /** * Quick match library items * + * @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../models/Library')} library * @param {import('../objects/LibraryItem')[]} libraryItems * @param {LibraryScan} libraryScan * @returns {Promise} false if scan canceled */ - async matchLibraryItemsChunk(library, libraryItems, libraryScan) { + async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) { for (let i = 0; i < libraryItems.length; i++) { const libraryItem = libraryItems[i] @@ -327,7 +370,7 @@ class Scanner { } Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`) - const result = await this.quickMatchLibraryItem(libraryItem, { provider: library.provider }) + const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider }) if (result.warning) { Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`) } else if (result.updated) { @@ -346,9 +389,10 @@ class Scanner { /** * Quick match all library items for library * + * @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../models/Library')} library */ - async matchLibraryItems(library) { + async matchLibraryItems(apiRouterCtx, library) { if (library.mediaType === 'podcast') { Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`) return @@ -388,7 +432,7 @@ class Scanner { hasMoreChunks = libraryItems.length === limit let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li)) - const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan) + const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, oldLibraryItems, libraryScan) if (!shouldContinue) { isCanceled = true break