diff --git a/client/components/modals/EditSeriesInputInnerModal.vue b/client/components/modals/EditSeriesInputInnerModal.vue new file mode 100644 index 00000000..dd993841 --- /dev/null +++ b/client/components/modals/EditSeriesInputInnerModal.vue @@ -0,0 +1,120 @@ + + + \ No newline at end of file diff --git a/client/components/modals/Modal.vue b/client/components/modals/Modal.vue index 1dfd2d71..5f289141 100644 --- a/client/components/modals/Modal.vue +++ b/client/components/modals/Modal.vue @@ -104,6 +104,7 @@ export default { } }, hotkey(action) { + if (this.$store.state.innerModalOpen) return if (action === this.$hotkeys.Modal.CLOSE) { this.show = false } diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 22b68cee..8d057a4b 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -1,5 +1,5 @@ @@ -97,8 +76,6 @@ export default { }, data() { return { - selectedSeries: {}, - showSeriesForm: false, details: { title: null, subtitle: null, @@ -146,24 +123,6 @@ export default { }, filterData() { return this.$store.state.libraries.filterData || {} - }, - existingSeriesNames() { - // Only show series names not already selected - var alreadySelectedSeriesIds = this.details.series.map((se) => se.id) - return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name) - }, - seriesItems: { - get() { - return this.details.series.map((se) => { - return { - displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name, - ...se - } - }) - }, - set(val) { - this.details.series = val - } } }, methods: { @@ -214,50 +173,6 @@ export default { this.$refs.tagsSelect.forceBlur() } }, - cancelSeriesForm() { - this.showSeriesForm = false - }, - editSeriesItem(series) { - var _series = this.details.series.find((se) => se.id === series.id) - if (!_series) return - this.selectedSeries = { - ..._series - } - this.showSeriesForm = true - }, - addNewSeries() { - this.selectedSeries = { - id: `new-${Date.now()}`, - name: '', - sequence: '' - } - this.showSeriesForm = true - }, - submitSeriesForm() { - if (!this.selectedSeries.name) { - this.$toast.error('Must enter a series') - return - } - if (this.$refs.newSeriesSelect) { - this.$refs.newSeriesSelect.blur() - } - var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id) - - var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase()) - if (existingSeriesIndex < 0 && seriesSameName) { - this.selectedSeries.id = seriesSameName.id - } - - if (existingSeriesIndex >= 0) { - this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries }) - } else { - this.details.series.push({ - ...this.selectedSeries - }) - } - - this.showSeriesForm = false - }, stringArrayEqual(array1, array2) { // return false if different if (array1.length !== array2.length) return false diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue new file mode 100644 index 00000000..d57b3272 --- /dev/null +++ b/client/components/widgets/SeriesInputWidget.vue @@ -0,0 +1,111 @@ + + + \ No newline at end of file diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 87a5d969..a037ac71 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -113,6 +113,16 @@ +
+ + +

+ Scanner prefer matched metadata + info_outlined +

+
+
+
@@ -226,6 +236,7 @@ export default { experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.', scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart', scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names', + scannerPreferMatchedMetadata: 'Matched data will overide book details when using Quick Match', scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names', scannerParseSubtitle: 'Extract subtitles from audiobook folder names.
Subtitle must be seperated by " - "
i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"', sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"', diff --git a/client/store/index.js b/client/store/index.js index 2b9a70ea..00af664b 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -20,6 +20,7 @@ export const state = () => ({ backups: [], bookshelfBookIds: [], openModal: null, + innerModalOpen: false, selectedBookshelfTexture: '/textures/wood_default.jpg', lastBookshelfScrollData: {} }) @@ -177,6 +178,9 @@ export const mutations = { setOpenModal(state, val) { state.openModal = val }, + setInnerModalOpen(state, val) { + state.innerModalOpen = val + }, setBookshelfTexture(state, val) { state.selectedBookshelfTexture = val } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 9bd34c75..0499e4c0 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -51,7 +51,6 @@ class LibraryItemController { var hasUpdates = libraryItem.update(req.body) if (hasUpdates) { - // Turn on podcast auto download cron if not already on if (libraryItem.mediaType == 'podcast' && req.body.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) { this.podcastManager.schedulePodcastEpisodeCron() diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 60645747..60b39838 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -166,14 +166,14 @@ class BookFinder { return this.iTunesApi.searchAudiobooks(title) } - async getAudibleResults(title, author) { - var books = await this.audible.search(title, author); + async getAudibleResults(title, author, asin) { + var books = await this.audible.search(title, author, asin); if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`) if (!books) return [] return books } - async search(provider, title, author, options = {}) { + async search(provider, title, author, isbn, asin, options = {}) { var books = [] var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4 var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4 @@ -182,7 +182,7 @@ class BookFinder { if (provider === 'google') { return this.getGoogleBooksResults(title, author) } else if (provider === 'audible') { - return this.getAudibleResults(title, author) + return this.getAudibleResults(title, author, asin) } else if (provider === 'itunes') { return this.getiTunesAudiobooksResults(title, author) } else if (provider === 'libgen') { diff --git a/server/objects/entities/Series.js b/server/objects/entities/Series.js index 18f3b4a3..9cb38a0b 100644 --- a/server/objects/entities/Series.js +++ b/server/objects/entities/Series.js @@ -48,7 +48,7 @@ class Series { } checkNameEquals(name) { - if (!name) return false + if (!name || !this.name) return false return this.name.toLowerCase() == name.toLowerCase().trim() } } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index bea7b6ed..286e15c0 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -11,7 +11,8 @@ class ServerSettings { this.scannerCoverProvider = 'google' this.scannerPreferAudioMetadata = false this.scannerPreferOpfMetadata = false - this.scannerDisableWatcher = false + this.scannerPreferMatchedMetadata = false + this.scannerDisableWatcher = false // Metadata - choose to store inside users library item folder this.storeCoverWithItem = false @@ -62,6 +63,7 @@ class ServerSettings { this.scannerParseSubtitle = settings.scannerParseSubtitle this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata + this.scannerPreferMatchedMetadata = !!settings.scannerPreferMatchedMetadata this.scannerDisableWatcher = !!settings.scannerDisableWatcher this.storeCoverWithItem = !!settings.storeCoverWithItem @@ -107,6 +109,7 @@ class ServerSettings { scannerParseSubtitle: this.scannerParseSubtitle, scannerPreferAudioMetadata: this.scannerPreferAudioMetadata, scannerPreferOpfMetadata: this.scannerPreferOpfMetadata, + scannerPreferMatchedMetadata: this.scannerPreferMatchedMetadata, scannerDisableWatcher: this.scannerDisableWatcher, storeCoverWithItem: this.storeCoverWithItem, storeMetadataWithItem: this.storeMetadataWithItem, diff --git a/server/providers/Audible.js b/server/providers/Audible.js index 441e0dcd..6934cebb 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -6,83 +6,79 @@ class Audible { constructor() { } cleanResult(item) { - var { title, subtitle, asin, authors, narrators, publisher_name, publisher_summary, release_date, series, product_images, publication_name } = item; + var { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language } = item - var primarySeries = this.getPrimarySeries(series, publication_name); + var series = [] + if (seriesPrimary) series.push(seriesPrimary) + if (seriesSecondary) series.push(seriesSecondary) + + var genresFiltered = genres ? genres.filter(g => g.type == "genre") : [] + var tagsFiltered = genres ? genres.filter(g => g.type == "tag") : [] return { title, subtitle: subtitle || null, author: authors ? authors.map(({ name }) => name).join(', ') : null, narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null, - publisher: publisher_name, - publishedYear: release_date ? release_date.split('-')[0] : null, - description: publisher_summary ? htmlSanitizer.stripAllTags(publisher_summary) : null, - cover: this.getBestImageLink(product_images), + publisher: publisherName, + publishedYear: releaseDate ? releaseDate.split('-')[0] : null, + description: summary ? htmlSanitizer.stripAllTags(summary) : null, + cover: image, asin, - series: primarySeries ? primarySeries.title : null, - volumeNumber: primarySeries ? primarySeries.sequence : null + genres: genresFiltered.length > 0 ? genresFiltered.map(({ name }) => name).join(', ') : null, + tags: tagsFiltered.length > 0 ? tagsFiltered.map(({ name }) => name).join(', ') : null, + series: series != [] ? series.map(({ name, position }) => ({ series: name, volumeNumber: position })) : null, + language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null } } - getBestImageLink(images) { - if (!images) return null - var keys = Object.keys(images) - if (!keys.length) return null - return images[keys[keys.length - 1]] - } - - getPrimarySeries(series, publication_name) { - return (series && series.length > 0) ? series.find((s) => s.title == publication_name) || series[0] : null - } - isProbablyAsin(title) { return /^[0-9A-Z]{10}$/.test(title) } asinSearch(asin) { - var queryObj = { - response_groups: 'rating,series,contributors,product_desc,media,product_extended_attrs', - image_sizes: '500,1024,2000' - }; - var queryString = (new URLSearchParams(queryObj)).toString(); asin = encodeURIComponent(asin); - var url = `https://api.audible.com/1.0/catalog/products/${asin}?${queryString}` + var url = `https://api.audnex.us/books/${asin}` Logger.debug(`[Audible] ASIN url: ${url}`) return axios.get(url).then((res) => { - if (!res || !res.data || !res.data.product || !res.data.product.authors) return [] - return [res.data.product] + if (!res || !res.data || !res.data.asin) return null + return res.data }).catch(error => { - Logger.error('[Audible] search error', error) + Logger.error('[Audible] ASIN search error', error) return [] }) } - async search(title, author) { - if (this.isProbablyAsin(title)) { - var items = await this.asinSearch(title) - if (items.length > 0) return items.map(item => this.cleanResult(item)) + async search(title, author, asin) { + var items + if (asin) { + items = [await this.asinSearch(asin)] } - var queryObj = { - response_groups: 'rating,series,contributors,product_desc,media,product_extended_attrs', - image_sizes: '500,1024,2000', - num_results: '25', - products_sort_by: 'Relevance', - title: title - }; - if (author) queryObj.author = author - var queryString = (new URLSearchParams(queryObj)).toString(); - var url = `https://api.audible.com/1.0/catalog/products?${queryString}` - Logger.debug(`[Audible] Search url: ${url}`) - var items = await axios.get(url).then((res) => { - if (!res || !res.data || !res.data.products) return [] - return res.data.products - }).catch(error => { - Logger.error('[Audible] search error', error) - return [] - }) - return items.map(item => this.cleanResult(item)) + if (!items && this.isProbablyAsin(title)) { + items = [await this.asinSearch(title)] + } + + if (!items) { + var queryObj = { + num_results: '10', + products_sort_by: 'Relevance', + title: title + }; + if (author) queryObj.author = author + var queryString = (new URLSearchParams(queryObj)).toString(); + var url = `https://api.audible.com/1.0/catalog/products?${queryString}` + Logger.debug(`[Audible] Search url: ${url}`) + items = await axios.get(url).then((res) => { + if (!res || !res.data || !res.data.products) return null + return Promise.all(res.data.products.map(result => this.asinSearch(result.asin))) + }).catch(error => { + Logger.error('[Audible] query search error', error) + return [] + }) + } + + return items ? items.map(item => this.cleanResult(item)) : [] } } diff --git a/server/scanner/ScanOptions.js b/server/scanner/ScanOptions.js index ccc6ca2f..3d9d4556 100644 --- a/server/scanner/ScanOptions.js +++ b/server/scanner/ScanOptions.js @@ -8,6 +8,7 @@ class ScanOptions { this.storeCoverWithItem = false this.preferAudioMetadata = false this.preferOpfMetadata = false + this.preferMatchedMetadata = false if (options) { this.construct(options) @@ -32,7 +33,8 @@ class ScanOptions { findCovers: this.findCovers, storeCoverWithItem: this.storeCoverWithItem, preferAudioMetadata: this.preferAudioMetadata, - preferOpfMetadata: this.preferOpfMetadata + preferOpfMetadata: this.preferOpfMetadata, + preferMatchedMetadata: this.preferMatchedMetadata } } @@ -44,6 +46,7 @@ class ScanOptions { this.storeCoverWithItem = serverSettings.storeCoverWithItem this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata + this.scannerPreferMatchedMetadata = serverSettings.scannerPreferMatchedMetadata } } module.exports = ScanOptions \ No newline at end of file diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 74e181eb..aa8582be 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -640,8 +640,10 @@ class Scanner { var provider = options.provider || 'google' var searchTitle = options.title || libraryItem.media.metadata.title var searchAuthor = options.author || libraryItem.media.metadata.authorName + var searchISBN = options.isbn || libraryItem.media.metadata.isbn + var searchASIN = options.asin || libraryItem.media.metadata.asin - var results = await this.bookFinder.search(provider, searchTitle, searchAuthor) + var results = await this.bookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN) if (!results.length) { return { warning: `No ${provider} match found` @@ -649,6 +651,12 @@ class Scanner { } var matchData = results[0] + // Set to override existing metadata if scannerPreferMatchedMetadata setting is true + if(this.db.serverSettings.scannerPreferMatchedMetadata) { + options.overrideCover = true + options.overrideDetails = true + } + // Update cover if not set OR overrideCover flag var hasUpdated = false if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { @@ -662,47 +670,68 @@ class Scanner { } // Update media metadata if not set OR overrideDetails flag - const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'asin', 'isbn'] + const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn'] const updatePayload = {} + updatePayload.metadata = {} for (const key in matchData) { if (matchData[key] && detailKeysToUpdate.includes(key)) { if (key === 'narrator') { if ((!libraryItem.media.metadata.narratorName || options.overrideDetails)) { - updatePayload.narrators = [matchData[key]] + updatePayload.metadata.narrators = matchData[key].split(',') + } + } else if (key === 'genres') { + if ((!libraryItem.media.metadata.genres || options.overrideDetails)) { + updatePayload.metadata[key] = matchData[key].split(',') + } + } else if (key === 'tags') { + if ((!libraryItem.media.tags || options.overrideDetails)) { + updatePayload[key] = matchData[key].split(',') } } else if ((!libraryItem.media.metadata[key] || options.overrideDetails)) { - updatePayload[key] = matchData[key] + updatePayload.metadata[key] = matchData[key] } } } // Add or set author if not set - if (matchData.author && !libraryItem.media.metadata.authorName) { - var author = this.db.authors.find(au => au.checkNameEquals(matchData.author)) - if (!author) { - author = new Author() - author.setData({ name: matchData.author }) - await this.db.insertEntity('author', author) - this.emitter('author_added', author) + if (matchData.author && !libraryItem.media.metadata.authorName || options.overrideDetails) { + if(!Array.isArray(matchData.author)) matchData.author = [matchData.author] + const authorPayload = [] + for (let index = 0; index < matchData.author.length; index++) { + const authorName = matchData.author[index] + var author = this.db.authors.find(au => au.checkNameEquals(authorName)) + if (!author) { + author = new Author() + author.setData({ name: authorName }) + await this.db.insertEntity('author', author) + this.emitter('author_added', author) + } + authorPayload.push(author.toJSONMinimal()) } - updatePayload.authors = [author.toJSONMinimal()] + updatePayload.metadata.authors = authorPayload } // Add or set series if not set - if (matchData.series && !libraryItem.media.metadata.seriesName) { - var seriesItem = this.db.series.find(au => au.checkNameEquals(matchData.series)) - if (!seriesItem) { - seriesItem = new Series() - seriesItem.setData({ name: matchData.series }) - await this.db.insertEntity('series', seriesItem) - this.emitter('series_added', seriesItem) + if (matchData.series && !libraryItem.media.metadata.seriesName || options.overrideDetails) { + if(!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, volumeNumber: matchData.volumeNumber }] + const seriesPayload = [] + for (let index = 0; index < matchData.series.length; index++) { + const seriesMatchItem = matchData.series[index] + var seriesItem = this.db.series.find(au => au.checkNameEquals(seriesMatchItem.series)) + if (!seriesItem) { + seriesItem = new Series() + seriesItem.setData({ name: seriesMatchItem.series }) + await this.db.insertEntity('series', seriesItem) + this.emitter('series_added', seriesItem) + } + seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.volumeNumber)) } - updatePayload.series = [seriesItem.toJSONMinimal(matchData.volumeNumber)] + updatePayload.metadata.series = seriesPayload } if (Object.keys(updatePayload).length) { Logger.debug('[Scanner] Updating details', updatePayload) - if (libraryItem.media.update({ metadata: updatePayload })) { + if (libraryItem.media.update(updatePayload)) { hasUpdated = true } }