diff --git a/client/store/scanners.js b/client/store/scanners.js index 2d3d465c..a0c740b4 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -12,42 +12,82 @@ export const state = () => ({ text: 'iTunes', value: 'itunes' }, + { + text: 'AudiMeta.com (Audible)', + value: 'audimeta.us' + }, { text: 'Audible.com', value: 'audible' }, + { + text: 'AudiMeta.ca (Audible)', + value: 'audimeta.ca' + }, { text: 'Audible.ca', value: 'audible.ca' }, + { + text: 'AudiMeta.co.uk (Audible)', + value: 'audimeta.uk' + }, { text: 'Audible.co.uk', value: 'audible.uk' }, + { + text: 'AudiMeta.com.au (Audible)', + value: 'audimeta.au' + }, { text: 'Audible.com.au', value: 'audible.au' }, + { + text: 'AudiMeta.fr (Audible)', + value: 'audimeta.fr' + }, { text: 'Audible.fr', value: 'audible.fr' }, + { + text: 'AudiMeta.de (Audible)', + value: 'audimeta.de' + }, { text: 'Audible.de', value: 'audible.de' }, + { + text: 'AudiMeta.jp (Audible)', + value: 'audimeta.jp' + }, { text: 'Audible.co.jp', value: 'audible.jp' }, + { + text: 'AudiMeta.it (Audible)', + value: 'audimeta.it' + }, { text: 'Audible.it', value: 'audible.it' }, + { + text: 'AudiMeta.co.in (Audible)', + value: 'audimeta.in' + }, { text: 'Audible.co.in', value: 'audible.in' }, + { + text: 'AudiMeta.es (Audible)', + value: 'audimeta.es' + }, { text: 'Audible.es', value: 'audible.es' @@ -72,11 +112,11 @@ export const state = () => ({ }) export const getters = { - checkBookProviderExists: state => (providerValue) => { - return state.providers.some(p => p.value === providerValue) + checkBookProviderExists: (state) => (providerValue) => { + return state.providers.some((p) => p.value === providerValue) }, - checkPodcastProviderExists: state => (providerValue) => { - return state.podcastProviders.some(p => p.value === providerValue) + checkPodcastProviderExists: (state) => (providerValue) => { + return state.podcastProviders.some((p) => p.value === providerValue) } } @@ -85,13 +125,13 @@ export const actions = {} export const mutations = { addCustomMetadataProvider(state, provider) { if (provider.mediaType === 'book') { - if (state.providers.some(p => p.value === provider.slug)) return + if (state.providers.some((p) => p.value === provider.slug)) return state.providers.push({ text: provider.name, value: provider.slug }) } else { - if (state.podcastProviders.some(p => p.value === provider.slug)) return + if (state.podcastProviders.some((p) => p.value === provider.slug)) return state.podcastProviders.push({ text: provider.name, value: provider.slug @@ -100,9 +140,9 @@ export const mutations = { }, removeCustomMetadataProvider(state, provider) { if (provider.mediaType === 'book') { - state.providers = state.providers.filter(p => p.value !== provider.slug) + state.providers = state.providers.filter((p) => p.value !== provider.slug) } else { - state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug) + state.podcastProviders = state.podcastProviders.filter((p) => p.value !== provider.slug) } }, setCustomMetadataProviders(state, providers) { @@ -123,4 +163,4 @@ export const mutations = { // Podcast providers not supported yet } } -} \ No newline at end of file +} diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8fde7bc4..de1614b3 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -9,6 +9,7 @@ const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const Logger = require('../Logger') const { levenshteinDistance, escapeRegExp } = require('../utils/index') const htmlSanitizer = require('../utils/htmlSanitizer') +const AudiMeta = require('../providers/AudiMeta') class BookFinder { #providerResponseTimeout = 30000 @@ -17,13 +18,14 @@ class BookFinder { this.openLibrary = new OpenLibrary() this.googleBooks = new GoogleBooks() this.audible = new Audible() + this.audiMeta = new AudiMeta() this.iTunesApi = new iTunes() this.audnexus = new Audnexus() this.fantLab = new FantLab() this.audiobookCovers = new AudiobookCovers() this.customProviderAdapter = new CustomProviderAdapter() - this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es'] + this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audimeta.us', 'audible', 'audimeta.ca', 'audible.ca', 'audimeta.uk', 'audible.uk', 'audimeta.au', 'audible.au', 'audimeta.fr', 'audible.fr', 'audimeta.de', 'audible.de', 'audimeta.jp', 'audible.jp', 'audimeta.it', 'audible.it', 'audimeta.in', 'audible.in', 'audimeta.es', 'audible.es'] this.verbose = false } @@ -194,6 +196,24 @@ class BookFinder { return books } + /** + * @param {string} title + * @param {string} author + * @param {string} asin + * @param {string} provider + * @returns {Promise} + */ + async getAudiMetaResults(title, author, asin, provider) { + // Ensure provider is a string (See CodeQL) even though it should be a string anyway + const providerStr = (typeof provider === 'string' ? provider : Array.isArray(provider) ? provider[0]?.toString() || '' : '').toString() + + const region = providerStr.includes('.') ? providerStr.split('.').pop() : '' + const books = await this.audiMeta.search(title, author, asin, region, this.#providerResponseTimeout) + if (this.verbose) Logger.debug(`Audible Book Search Results: ${books.length || 0}`) + if (!books) return [] + return books + } + /** * * @param {string} title @@ -453,6 +473,8 @@ class BookFinder { books = await this.getGoogleBooksResults(title, author) } else if (provider.startsWith('audible')) { books = await this.getAudibleResults(title, author, asin, provider) + } else if (provider.startsWith('audimeta')) { + books = await this.getAudiMetaResults(title, author, asin, provider) } else if (provider === 'itunes') { books = await this.getiTunesAudiobooksResults(title) } else if (provider === 'openlibrary') { diff --git a/server/providers/AudiMeta.js b/server/providers/AudiMeta.js new file mode 100644 index 00000000..0bce6d1b --- /dev/null +++ b/server/providers/AudiMeta.js @@ -0,0 +1,163 @@ +const axios = require('axios').default +const Logger = require('../Logger') +const { isValidASIN } = require('../utils/index') + +class AudiMeta { + #responseTimeout = 30000 + + constructor() { + this.regionMap = { + us: '.com', + ca: '.ca', + uk: '.co.uk', + au: '.com.au', + fr: '.fr', + de: '.de', + jp: '.co.jp', + it: '.it', + in: '.in', + es: '.es' + } + } + + /** + * Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation" + * @see https://github.com/advplyr/audiobookshelf/issues/2380 + * @see https://github.com/advplyr/audiobookshelf/issues/1339 + * + * @param {string} seriesName + * @param {string} sequence + * @returns {string} + */ + cleanSeriesSequence(seriesName, sequence) { + if (!sequence) return '' + // match any number with optional decimal (e.g, 1 or 1.5 or .5) + let numberFound = sequence.match(/\.\d+|\d+(?:\.\d+)?/) + let updatedSequence = numberFound ? numberFound[0] : sequence + if (sequence !== updatedSequence) { + Logger.debug(`[AudiMeta] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`) + } + return updatedSequence + } + + cleanResult(item) { + const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, imageUrl, genres, series, language, lengthMinutes, bookFormat } = item + + const seriesList = [] + + series.forEach((s) => { + seriesList.push({ + series: s.name, + sequence: this.cleanSeriesSequence(s.name, (s.position || '').toString()) + }) + }) + + // Tags and Genres are flipped for AudiMeta + const genresFiltered = genres ? genres.filter((g) => g.type == 'Tags').map((g) => g.name) : [] + const tagsFiltered = genres ? genres.filter((g) => g.type == 'Genres').map((g) => g.name) : [] + + return { + title, + subtitle: subtitle || null, + author: authors ? authors.map(({ name }) => name).join(', ') : null, + narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null, + publisher: publisherName, + publishedYear: releaseDate ? releaseDate.split('-')[0] : null, + description: summary || null, + cover: imageUrl, + asin, + genres: genresFiltered.length ? genresFiltered : null, + tags: tagsFiltered.length ? tagsFiltered.join(', ') : null, + series: seriesList.length ? seriesList : null, + language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null, + duration: lengthMinutes && !isNaN(lengthMinutes) ? Number(lengthMinutes) : 0, + region: item.region || null, + rating: item.rating || null, + abridged: bookFormat === 'abridged' + } + } + + /** + * + * @param {string} asin + * @param {string} region + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + asinSearch(asin, region, timeout = this.#responseTimeout) { + if (!asin) return null + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + + asin = encodeURIComponent(asin.toUpperCase()) + let regionQuery = region ? `?region=${region}` : '' + let url = `https://audimeta.de/book/${asin}${regionQuery}` + Logger.debug(`[AudiMeta] ASIN url: ${url}`) + return axios + .get(url, { + timeout + }) + .then((res) => { + if (!res?.data?.asin) return null + return res.data + }) + .catch((error) => { + Logger.error('[Audible] ASIN search error', error) + return null + }) + } + + /** + * + * @param {string} title + * @param {string} author + * @param {string} asin + * @param {string} region + * @param {number} [timeout] response timeout in ms + * @returns {Promise} + */ + async search(title, author, asin, region, timeout = this.#responseTimeout) { + if (region && !this.regionMap[region]) { + Logger.error(`[AudiMeta] search: Invalid region ${region}`) + region = '' + } + if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout + + let items = [] + if (asin && isValidASIN(asin.toUpperCase())) { + const item = await this.asinSearch(asin.toUpperCase(), region, timeout) + if (item) items.push(item) + } + + if (!items.length && isValidASIN(title.toUpperCase())) { + const item = await this.asinSearch(title.toUpperCase(), region, timeout) + if (item) items.push(item) + } + + if (!items.length) { + const queryObj = { + title: title, + region: region, + limit: '10' + } + if (author) queryObj.author = author + const queryString = new URLSearchParams(queryObj).toString() + + const url = `https://audimeta.de/search?${queryString}` + Logger.debug(`[AudiMeta] Search url: ${url}`) + items = await axios + .get(url, { + timeout + }) + .then((res) => { + return res.data + }) + .catch((error) => { + Logger.error('[AudiMeta] query search error', error) + return [] + }) + } + return items.filter(Boolean).map((item) => this.cleanResult(item)) || [] + } +} + +module.exports = AudiMeta