From 11be49a5352a7399a0b66801a8b56484091cb881 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 15 Feb 2022 18:33:33 -0600 Subject: [PATCH] Add:Library match all books #359 --- .../modals/libraries/LibraryItem.vue | 17 +++- client/components/tables/LibrariesTable.vue | 2 + client/layouts/default.vue | 6 +- server/ApiController.js | 55 +---------- server/Server.js | 2 +- server/controllers/BookController.js | 2 +- server/controllers/LibraryController.js | 22 +---- server/providers/Audible.js | 6 +- server/scanner/LibraryScan.js | 8 +- server/scanner/Scanner.js | 96 +++++++++++++++++++ 10 files changed, 132 insertions(+), 84 deletions(-) diff --git a/client/components/modals/libraries/LibraryItem.vue b/client/components/modals/libraries/LibraryItem.vue index 37c1d692..6f6b5e8a 100644 --- a/client/components/modals/libraries/LibraryItem.vue +++ b/client/components/modals/libraries/LibraryItem.vue @@ -9,8 +9,11 @@

{{ library.name }}

- Scan + Scan Force Re-Scan + + Match Books + edit delete
@@ -59,6 +62,18 @@ export default { } }, methods: { + matchAll() { + this.$axios + .$post(`/api/libraries/${this.library.id}/matchbooks`) + .then(() => { + console.log('Starting scan for matches') + }) + .catch((error) => { + console.error('Failed', error) + var errorMsg = err.response ? err.response.data : '' + this.$toast.error(errorMsg || 'Match all failed') + }) + }, editClick() { this.$emit('edit', this.library) }, diff --git a/client/components/tables/LibrariesTable.vue b/client/components/tables/LibrariesTable.vue index 4500d08c..eaa2b01c 100644 --- a/client/components/tables/LibrariesTable.vue +++ b/client/components/tables/LibrariesTable.vue @@ -16,6 +16,8 @@

*Force Re-Scan will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.

+ +

**Match Books will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.

diff --git a/client/layouts/default.vue b/client/layouts/default.vue index c40f8eae..6012bf2d 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -205,7 +205,7 @@ export default { scanComplete(data) { console.log('Scan complete received', data) - var message = `Scan "${data.name}" complete!` + var message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!` if (data.results) { var scanResultMsgs = [] var results = data.results @@ -216,7 +216,7 @@ export default { if (!scanResultMsgs.length) message += '\nEverything was up to date' else message += '\n' + scanResultMsgs.join('\n') } else { - message = `Scan "${data.name}" was canceled` + message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled` } var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) @@ -232,7 +232,7 @@ export default { this.$root.socket.emit('cancel_scan', id) }, scanStart(data) { - data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) }) + data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) }) this.$store.commit('scanners/addUpdate', data) }, scanProgress(data) { diff --git a/server/ApiController.js b/server/ApiController.js index db166945..edb0b661 100644 --- a/server/ApiController.js +++ b/server/ApiController.js @@ -18,9 +18,10 @@ const AuthorFinder = require('./AuthorFinder') const FileSystemController = require('./controllers/FileSystemController') class ApiController { - constructor(MetadataPath, db, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) { + constructor(MetadataPath, db, auth, scanner, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) { this.db = db this.auth = auth + this.scanner = scanner this.streamManager = streamManager this.rssFeeds = rssFeeds this.downloadManager = downloadManager @@ -517,57 +518,5 @@ class ApiController { await this.cacheManager.purgeAll() res.sendStatus(200) } - - async quickMatchBook(audiobook, options = {}) { - var provider = options.provider || 'google' - var searchTitle = options.title || audiobook.book._title - var searchAuthor = options.author || audiobook.book._author - - var results = await this.bookFinder.search(provider, searchTitle, searchAuthor) - if (!results.length) { - return { - warning: `No ${provider} match found` - } - } - var matchData = results[0] - - // Update cover if not set OR overrideCover flag - var hasUpdated = false - if (matchData.cover && (!audiobook.book.cover || options.overrideCover)) { - Logger.debug(`[BookController] Updating cover "${matchData.cover}"`) - var coverResult = await this.coverController.downloadCoverFromUrl(audiobook, matchData.cover) - if (!coverResult || coverResult.error || !coverResult.cover) { - Logger.warn(`[BookController] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`) - } else { - hasUpdated = true - } - } - - // Update book details if not set OR overrideDetails flag - const detailKeysToUpdate = ['title', 'subtitle', 'author', 'narrator', 'publisher', 'publishYear', 'series', 'volumeNumber', 'asin', 'isbn'] - const updatePayload = {} - for (const key in matchData) { - if (matchData[key] && detailKeysToUpdate.includes(key) && (!audiobook.book[key] || options.overrideDetails)) { - updatePayload[key] = matchData[key] - } - } - - if (Object.keys(updatePayload).length) { - Logger.debug('[BookController] Updating details', updatePayload) - if (audiobook.update({ book: updatePayload })) { - hasUpdated = true - } - } - - if (hasUpdated) { - await this.db.updateEntity('audiobook', audiobook) - this.emitter('audiobook_updated', audiobook.toJSONExpanded()) - } - - return { - updated: hasUpdated, - audiobook: audiobook.toJSONExpanded() - } - } } module.exports = ApiController \ No newline at end of file diff --git a/server/Server.js b/server/Server.js index 3c140596..1bd5da04 100644 --- a/server/Server.js +++ b/server/Server.js @@ -55,7 +55,7 @@ class Server { this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this), this.clientEmitter.bind(this)) this.rssFeeds = new RssFeeds(this.Port, this.db) this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.Uid, this.Gid) - this.apiController = new ApiController(this.MetadataPath, this.db, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this)) + this.apiController = new ApiController(this.MetadataPath, this.db, this.auth, this.scanner, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this)) this.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath) Logger.logManager = this.logManager diff --git a/server/controllers/BookController.js b/server/controllers/BookController.js index d6e579db..5a50f638 100644 --- a/server/controllers/BookController.js +++ b/server/controllers/BookController.js @@ -272,7 +272,7 @@ class BookController { } var options = req.body || {} - var matchResult = await this.quickMatchBook(audiobook, options) + var matchResult = await this.scanner.quickMatchBook(audiobook, options) res.json(matchResult) } } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 93afe740..dc699390 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -475,28 +475,8 @@ class LibraryController { Logger.error(`[LibraryController] Non-root user attempted to match library books`, req.user) return res.sendStatus(403) } + this.scanner.matchLibraryBooks(req.library) res.sendStatus(200) - - const provider = req.library.provider || 'google' - var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id) - const resultPayload = { - library: library.toJSON(), - total: audiobooksInLibrary.length, - updated: 0 - } - - for (let i = 0; i < audiobooksInLibrary.length; i++) { - var audiobook = audiobooksInLibrary[i] - Logger.debug(`[LibraryController] matchBooks quick matching "${audiobook.title}" (${i + 1} of ${audiobooksInLibrary.length})`) - var result = await this.quickMatchBook(audiobook, { provider }) - if (result.warning) { - Logger.warn(`[LibraryController] matchBooks warning ${result.warning} for audiobook "${audiobook.title}"`) - } else if (result.updated) { - resultPayload.updated++ - } - } - - this.clientEmitter(req.usr.id, 'library-match-results', resultPayload) } middleware(req, res, next) { diff --git a/server/providers/Audible.js b/server/providers/Audible.js index 2f369937..c50da3f4 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -26,8 +26,10 @@ class Audible { } getBestImageLink(images) { - var keys = Object.keys(images); - return images[keys[keys.length - 1]]; + if (!images) return null + var keys = Object.keys(images) + if (!keys.length) return null + return images[keys[keys.length - 1]] } getPrimarySeries(series, publication_name) { diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index a7a0809d..d21574e1 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -10,6 +10,7 @@ const { getId, secondsToTimestamp } = require('../utils/index') class LibraryScan { constructor() { this.id = null + this.type = null this.libraryId = null this.libraryName = null this.folders = null @@ -46,6 +47,7 @@ class LibraryScan { get getScanEmitData() { return { id: this.libraryId, + type: this.type, name: this.libraryName, results: { added: this.resultsAdded, @@ -64,10 +66,11 @@ class LibraryScan { toJSON() { return { id: this.id, + type: this.type, libraryId: this.libraryId, libraryName: this.libraryName, folders: this.folders.map(f => f.toJSON()), - scanOptions: this.scanOptions.toJSON(), + scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null, startedAt: this.startedAt, finishedAt: this.finishedAt, elapsed: this.elapsed, @@ -77,8 +80,9 @@ class LibraryScan { } } - setData(library, scanOptions) { + setData(library, scanOptions, type = 'scan') { this.id = getId('lscan') + this.type = type this.libraryId = library.id this.libraryName = library.name this.folders = library.folders.map(folder => new Folder(folder.toJSON())) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index cf6aea17..627def2c 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -625,5 +625,101 @@ class Scanner { Logger.info(`[Scanner] Updated ${audiobooksUpdated} audiobook IDs`) } } + + async quickMatchBook(audiobook, options = {}) { + var provider = options.provider || 'google' + var searchTitle = options.title || audiobook.book._title + var searchAuthor = options.author || audiobook.book._author + + var results = await this.bookFinder.search(provider, searchTitle, searchAuthor) + if (!results.length) { + return { + warning: `No ${provider} match found` + } + } + var matchData = results[0] + + // Update cover if not set OR overrideCover flag + var hasUpdated = false + if (matchData.cover && (!audiobook.book.cover || options.overrideCover)) { + Logger.debug(`[BookController] Updating cover "${matchData.cover}"`) + var coverResult = await this.coverController.downloadCoverFromUrl(audiobook, matchData.cover) + if (!coverResult || coverResult.error || !coverResult.cover) { + Logger.warn(`[BookController] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`) + } else { + hasUpdated = true + } + } + + // Update book details if not set OR overrideDetails flag + const detailKeysToUpdate = ['title', 'subtitle', 'author', 'narrator', 'publisher', 'publishYear', 'series', 'volumeNumber', 'asin', 'isbn'] + const updatePayload = {} + for (const key in matchData) { + if (matchData[key] && detailKeysToUpdate.includes(key) && (!audiobook.book[key] || options.overrideDetails)) { + updatePayload[key] = matchData[key] + } + } + + if (Object.keys(updatePayload).length) { + Logger.debug('[BookController] Updating details', updatePayload) + if (audiobook.update({ book: updatePayload })) { + hasUpdated = true + } + } + + if (hasUpdated) { + await this.db.updateEntity('audiobook', audiobook) + this.emitter('audiobook_updated', audiobook.toJSONExpanded()) + } + + return { + updated: hasUpdated, + audiobook: audiobook.toJSONExpanded() + } + } + + async matchLibraryBooks(library) { + if (this.isLibraryScanning(library.id)) { + Logger.error(`[Scanner] Already scanning ${library.id}`) + return + } + + const provider = library.provider || 'google' + var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id) + if (!audiobooksInLibrary.length) { + return + } + + var libraryScan = new LibraryScan() + libraryScan.setData(library, null, 'match') + this.librariesScanning.push(libraryScan.getScanEmitData) + this.emitter('scan_start', libraryScan.getScanEmitData) + + Logger.info(`[Scanner] Starting library match books scan ${libraryScan.id} for ${libraryScan.libraryName}`) + + for (let i = 0; i < audiobooksInLibrary.length; i++) { + var audiobook = audiobooksInLibrary[i] + Logger.debug(`[Scanner] Quick matching "${audiobook.title}" (${i + 1} of ${audiobooksInLibrary.length})`) + var result = await this.quickMatchBook(audiobook, { provider }) + if (result.warning) { + Logger.warn(`[Scanner] Match warning ${result.warning} for audiobook "${audiobook.title}"`) + } else if (result.updated) { + libraryScan.resultsUpdated++ + } + + if (this.cancelLibraryScan[libraryScan.libraryId]) { + Logger.info(`[Scanner] Library match scan canceled for "${libraryScan.libraryName}"`) + delete this.cancelLibraryScan[libraryScan.libraryId] + var scanData = libraryScan.getScanEmitData + scanData.results = false + this.emitter('scan_complete', scanData) + this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id) + return + } + } + + this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id) + this.emitter('scan_complete', libraryScan.getScanEmitData) + } } module.exports = Scanner \ No newline at end of file