mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-04 10:14:36 +02:00
chore: merge and resolve
This commit is contained in:
commit
e9e9a8ba75
260 changed files with 19677 additions and 3203 deletions
|
@ -1,139 +1,176 @@
|
|||
const axios = require('axios')
|
||||
const axios = require('axios').default
|
||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class Audible {
|
||||
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'
|
||||
}
|
||||
#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(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
|
||||
}
|
||||
return updatedSequence
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||
|
||||
const series = []
|
||||
if (seriesPrimary) {
|
||||
series.push({
|
||||
series: seriesPrimary.name,
|
||||
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
|
||||
})
|
||||
}
|
||||
if (seriesSecondary) {
|
||||
series.push({
|
||||
series: seriesSecondary.name,
|
||||
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ''
|
||||
let updatedSequence = sequence.replace(/Book /, '').trim()
|
||||
if (updatedSequence.includes(' ')) {
|
||||
updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '')
|
||||
}
|
||||
if (sequence !== updatedSequence) {
|
||||
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
|
||||
}
|
||||
return updatedSequence
|
||||
const genresFiltered = genres ? genres.filter((g) => g.type == 'genre').map((g) => g.name) : []
|
||||
const tagsFiltered = genres ? genres.filter((g) => g.type == 'tag').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 ? htmlSanitizer.stripAllTags(summary) : null,
|
||||
cover: image,
|
||||
asin,
|
||||
genres: genresFiltered.length ? genresFiltered : null,
|
||||
tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
|
||||
series: series.length ? series : null,
|
||||
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
|
||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
||||
region: item.region || null,
|
||||
rating: item.rating || null,
|
||||
abridged: formatType === 'abridged'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a search title matches an ASIN. Supports lowercase letters
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isProbablyAsin(title) {
|
||||
return /^[0-9A-Za-z]{10}$/.test(title)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} asin
|
||||
* @param {string} region
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
asinSearch(asin, region, timeout = this.#responseTimeout) {
|
||||
if (!asin) return []
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
asin = encodeURIComponent(asin.toUpperCase())
|
||||
var regionQuery = region ? `?region=${region}` : ''
|
||||
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
||||
Logger.debug(`[Audible] ASIN url: ${url}`)
|
||||
return axios
|
||||
.get(url, {
|
||||
timeout
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res || !res.data || !res.data.asin) return null
|
||||
return res.data
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('[Audible] ASIN search error', error)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} author
|
||||
* @param {string} asin
|
||||
* @param {string} region
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async search(title, author, asin, region, timeout = this.#responseTimeout) {
|
||||
if (region && !this.regionMap[region]) {
|
||||
Logger.error(`[Audible] search: Invalid region ${region}`)
|
||||
region = ''
|
||||
}
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
let items
|
||||
if (asin) {
|
||||
items = [await this.asinSearch(asin, region, timeout)]
|
||||
}
|
||||
|
||||
cleanResult(item) {
|
||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||
|
||||
const series = []
|
||||
if (seriesPrimary) {
|
||||
series.push({
|
||||
series: seriesPrimary.name,
|
||||
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
|
||||
})
|
||||
}
|
||||
if (seriesSecondary) {
|
||||
series.push({
|
||||
series: seriesSecondary.name,
|
||||
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
|
||||
})
|
||||
}
|
||||
|
||||
const genresFiltered = genres ? genres.filter(g => g.type == "genre").map(g => g.name) : []
|
||||
const tagsFiltered = genres ? genres.filter(g => g.type == "tag").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 ? htmlSanitizer.stripAllTags(summary) : null,
|
||||
cover: image,
|
||||
asin,
|
||||
genres: genresFiltered.length ? genresFiltered : null,
|
||||
tags: tagsFiltered.length ? tagsFiltered.join(', ') : null,
|
||||
series: series.length ? series : null,
|
||||
language: language ? language.charAt(0).toUpperCase() + language.slice(1) : null,
|
||||
duration: runtimeLengthMin && !isNaN(runtimeLengthMin) ? Number(runtimeLengthMin) : 0,
|
||||
region: item.region || null,
|
||||
rating: item.rating || null,
|
||||
abridged: formatType === 'abridged'
|
||||
}
|
||||
if (!items && this.isProbablyAsin(title)) {
|
||||
items = [await this.asinSearch(title, region, timeout)]
|
||||
}
|
||||
|
||||
isProbablyAsin(title) {
|
||||
return /^[0-9A-Z]{10}$/.test(title)
|
||||
}
|
||||
|
||||
asinSearch(asin, region) {
|
||||
asin = encodeURIComponent(asin)
|
||||
var regionQuery = region ? `?region=${region}` : ''
|
||||
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
||||
Logger.debug(`[Audible] ASIN url: ${url}`)
|
||||
return axios.get(url).then((res) => {
|
||||
if (!res || !res.data || !res.data.asin) return null
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
Logger.error('[Audible] ASIN search error', error)
|
||||
return []
|
||||
if (!items) {
|
||||
const queryObj = {
|
||||
num_results: '10',
|
||||
products_sort_by: 'Relevance',
|
||||
title: title
|
||||
}
|
||||
if (author) queryObj.author = author
|
||||
const queryString = new URLSearchParams(queryObj).toString()
|
||||
const tld = region ? this.regionMap[region] : '.com'
|
||||
const url = `https://api.audible${tld}/1.0/catalog/products?${queryString}`
|
||||
Logger.debug(`[Audible] Search url: ${url}`)
|
||||
items = await axios
|
||||
.get(url, {
|
||||
timeout
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res?.data?.products) return null
|
||||
return Promise.all(res.data.products.map((result) => this.asinSearch(result.asin, region, timeout)))
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('[Audible] query search error', error)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
async search(title, author, asin, region) {
|
||||
if (region && !this.regionMap[region]) {
|
||||
Logger.error(`[Audible] search: Invalid region ${region}`)
|
||||
region = ''
|
||||
}
|
||||
|
||||
let items
|
||||
if (asin) {
|
||||
items = [await this.asinSearch(asin, region)]
|
||||
}
|
||||
|
||||
if (!items && this.isProbablyAsin(title)) {
|
||||
items = [await this.asinSearch(title, region)]
|
||||
}
|
||||
|
||||
if (!items) {
|
||||
const queryObj = {
|
||||
num_results: '10',
|
||||
products_sort_by: 'Relevance',
|
||||
title: title
|
||||
}
|
||||
if (author) queryObj.author = author
|
||||
const queryString = (new URLSearchParams(queryObj)).toString()
|
||||
const tld = region ? this.regionMap[region] : '.com'
|
||||
const url = `https://api.audible${tld}/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, region)))
|
||||
}).catch(error => {
|
||||
Logger.error('[Audible] query search error', error)
|
||||
return []
|
||||
})
|
||||
}
|
||||
return items ? items.map(item => this.cleanResult(item)) : []
|
||||
}
|
||||
return items?.map((item) => this.cleanResult(item)) || []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Audible
|
||||
module.exports = Audible
|
||||
|
|
|
@ -2,22 +2,32 @@ const axios = require('axios')
|
|||
const Logger = require('../Logger')
|
||||
|
||||
class AudiobookCovers {
|
||||
constructor() { }
|
||||
#responseTimeout = 30000
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} search
|
||||
* @param {number} [timeout]
|
||||
* @returns {Promise<{cover: string}[]>}
|
||||
*/
|
||||
async search(search, timeout = this.#responseTimeout) {
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
async search(search) {
|
||||
const url = `https://api.audiobookcovers.com/cover/bytext/`
|
||||
const params = new URLSearchParams([['q', search]])
|
||||
const items = await axios.get(url, { params }).then((res) => {
|
||||
if (!res || !res.data) return []
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
Logger.error('[AudiobookCovers] Cover search error', error)
|
||||
return []
|
||||
})
|
||||
return items.map(item => ({ cover: item.versions.png.original }))
|
||||
const items = await axios
|
||||
.get(url, {
|
||||
params,
|
||||
timeout
|
||||
})
|
||||
.then((res) => res?.data || [])
|
||||
.catch((error) => {
|
||||
Logger.error('[AudiobookCovers] Cover search error', error)
|
||||
return []
|
||||
})
|
||||
return items.map((item) => ({ cover: item.versions.png.original }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = AudiobookCovers
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const axios = require('axios')
|
||||
const axios = require('axios').default
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const Logger = require('../Logger')
|
||||
const Throttle = require('p-throttle')
|
||||
|
@ -15,7 +15,7 @@ class Audnexus {
|
|||
static _instance = null
|
||||
|
||||
constructor() {
|
||||
// ensures Audnexus class is singleton
|
||||
// ensures Audnexus class is singleton
|
||||
if (Audnexus._instance) {
|
||||
return Audnexus._instance
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ class Audnexus {
|
|||
// Rate limit is 100 requests per minute.
|
||||
// @see https://github.com/laxamentumtech/audnexus#-deployment-
|
||||
this.limiter = Throttle({
|
||||
// Setting the limit to 1 allows for a short pause between requests that is imperceptible to the end user.
|
||||
// Setting the limit to 1 allows for a short pause between requests that is imperceptible to the end user.
|
||||
// A larger limit will grab blocks faster and then wait for the alloted time(interval) before
|
||||
// fetching another batch, but with a discernable pause from the user perspective.
|
||||
limit: 1,
|
||||
|
@ -37,10 +37,10 @@ class Audnexus {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} region
|
||||
* @returns {Promise<{asin:string, name:string}[]>}
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} region
|
||||
* @returns {Promise<{asin:string, name:string}[]>}
|
||||
*/
|
||||
authorASINsRequest(name, region) {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
@ -60,9 +60,9 @@ class Audnexus {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} asin
|
||||
* @param {string} region
|
||||
*
|
||||
* @param {string} asin
|
||||
* @param {string} region
|
||||
* @returns {Promise<AuthorSearchObj>}
|
||||
*/
|
||||
authorRequest(asin, region) {
|
||||
|
@ -73,17 +73,17 @@ class Audnexus {
|
|||
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
|
||||
|
||||
return this._processRequest(this.limiter(() => axios.get(authorRequestUrl)))
|
||||
.then((res) => res.data)
|
||||
.catch((error) => {
|
||||
.then(res => res.data)
|
||||
.catch(error => {
|
||||
Logger.error(`[Audnexus] Author request failed for ${asin}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} asin
|
||||
* @param {string} region
|
||||
*
|
||||
* @param {string} asin
|
||||
* @param {string} region
|
||||
* @returns {Promise<AuthorSearchObj>}
|
||||
*/
|
||||
async findAuthorByASIN(asin, region) {
|
||||
|
@ -99,10 +99,10 @@ class Audnexus {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} region
|
||||
* @param {number} maxLevenshtein
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {string} region
|
||||
* @param {number} maxLevenshtein
|
||||
* @returns {Promise<AuthorSearchObj>}
|
||||
*/
|
||||
async findAuthorByName(name, region, maxLevenshtein = 3) {
|
||||
|
@ -138,8 +138,8 @@ class Audnexus {
|
|||
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}/${region}`)
|
||||
|
||||
return this._processRequest(this.limiter(() => axios.get(`${this.baseUrl}/books/${asin}/chapters?region=${region}`)))
|
||||
.then((res) => res.data)
|
||||
.catch((error) => {
|
||||
.then(res => res.data)
|
||||
.catch(error => {
|
||||
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error)
|
||||
return null
|
||||
})
|
||||
|
@ -150,8 +150,7 @@ class Audnexus {
|
|||
*/
|
||||
async _processRequest(request) {
|
||||
try {
|
||||
const response = await request()
|
||||
return response
|
||||
return await request()
|
||||
} catch (error) {
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = parseInt(error.response.headers?.['retry-after'], 10) || 5
|
||||
|
|
|
@ -1,93 +1,91 @@
|
|||
const axios = require('axios').default
|
||||
const Database = require('../Database')
|
||||
const axios = require('axios')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class CustomProviderAdapter {
|
||||
constructor() { }
|
||||
#responseTimeout = 30000
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} author
|
||||
* @param {string} providerSlug
|
||||
* @param {string} mediaType
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async search(title, author, providerSlug, mediaType) {
|
||||
const providerId = providerSlug.split('custom-')[1]
|
||||
const provider = await Database.customMetadataProviderModel.findByPk(providerId)
|
||||
constructor() {}
|
||||
|
||||
if (!provider) {
|
||||
throw new Error("Custom provider not found for the given id")
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} author
|
||||
* @param {string} isbn
|
||||
* @param {string} providerSlug
|
||||
* @param {string} mediaType
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async search(title, author, isbn, providerSlug, mediaType, timeout = this.#responseTimeout) {
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
// Setup query params
|
||||
const queryObj = {
|
||||
mediaType,
|
||||
query: title
|
||||
}
|
||||
if (author) {
|
||||
queryObj.author = author
|
||||
}
|
||||
const queryString = (new URLSearchParams(queryObj)).toString()
|
||||
const providerId = providerSlug.split('custom-')[1]
|
||||
const provider = await Database.customMetadataProviderModel.findByPk(providerId)
|
||||
|
||||
// Setup headers
|
||||
const axiosOptions = {}
|
||||
if (provider.authHeaderValue) {
|
||||
axiosOptions.headers = {
|
||||
'Authorization': provider.authHeaderValue
|
||||
}
|
||||
}
|
||||
|
||||
const matches = await axios.get(`${provider.url}/search?${queryString}}`, axiosOptions).then((res) => {
|
||||
if (!res?.data || !Array.isArray(res.data.matches)) return null
|
||||
return res.data.matches
|
||||
}).catch(error => {
|
||||
Logger.error('[CustomMetadataProvider] Search error', error)
|
||||
return []
|
||||
})
|
||||
|
||||
if (!matches) {
|
||||
throw new Error("Custom provider returned malformed response")
|
||||
}
|
||||
|
||||
// re-map keys to throw out
|
||||
return matches.map(({
|
||||
title,
|
||||
subtitle,
|
||||
author,
|
||||
narrator,
|
||||
publisher,
|
||||
publishedYear,
|
||||
description,
|
||||
cover,
|
||||
isbn,
|
||||
asin,
|
||||
genres,
|
||||
tags,
|
||||
series,
|
||||
language,
|
||||
duration
|
||||
}) => {
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
author,
|
||||
narrator,
|
||||
publisher,
|
||||
publishedYear,
|
||||
description,
|
||||
cover,
|
||||
isbn,
|
||||
asin,
|
||||
genres,
|
||||
tags: tags?.join(',') || null,
|
||||
series: series?.length ? series : null,
|
||||
language,
|
||||
duration
|
||||
}
|
||||
})
|
||||
if (!provider) {
|
||||
throw new Error('Custom provider not found for the given id')
|
||||
}
|
||||
|
||||
// Setup query params
|
||||
const queryObj = {
|
||||
mediaType,
|
||||
query: title
|
||||
}
|
||||
if (author) {
|
||||
queryObj.author = author
|
||||
}
|
||||
if (isbn) {
|
||||
queryObj.isbn = isbn
|
||||
}
|
||||
const queryString = new URLSearchParams(queryObj).toString()
|
||||
|
||||
// Setup headers
|
||||
const axiosOptions = {
|
||||
timeout
|
||||
}
|
||||
if (provider.authHeaderValue) {
|
||||
axiosOptions.headers = {
|
||||
Authorization: provider.authHeaderValue
|
||||
}
|
||||
}
|
||||
|
||||
const matches = await axios
|
||||
.get(`${provider.url}/search?${queryString}`, axiosOptions)
|
||||
.then((res) => {
|
||||
if (!res?.data || !Array.isArray(res.data.matches)) return null
|
||||
return res.data.matches
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('[CustomMetadataProvider] Search error', error)
|
||||
return []
|
||||
})
|
||||
|
||||
if (!matches) {
|
||||
throw new Error('Custom provider returned malformed response')
|
||||
}
|
||||
|
||||
// re-map keys to throw out
|
||||
return matches.map(({ title, subtitle, author, narrator, publisher, publishedYear, description, cover, isbn, asin, genres, tags, series, language, duration }) => {
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
author,
|
||||
narrator,
|
||||
publisher,
|
||||
publishedYear,
|
||||
description,
|
||||
cover,
|
||||
isbn,
|
||||
asin,
|
||||
genres,
|
||||
tags: tags?.join(',') || null,
|
||||
series: series?.length ? series : null,
|
||||
language,
|
||||
duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CustomProviderAdapter
|
||||
module.exports = CustomProviderAdapter
|
||||
|
|
|
@ -2,6 +2,7 @@ const axios = require('axios')
|
|||
const Logger = require('../Logger')
|
||||
|
||||
class FantLab {
|
||||
#responseTimeout = 30000
|
||||
// 7 - other
|
||||
// 11 - essay
|
||||
// 12 - article
|
||||
|
@ -22,28 +23,47 @@ class FantLab {
|
|||
_filterWorkType = [7, 11, 12, 22, 23, 24, 25, 26, 46, 47, 49, 51, 52, 55, 56, 57]
|
||||
_baseUrl = 'https://api.fantlab.ru'
|
||||
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {string} author'
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
**/
|
||||
async search(title, author, timeout = this.#responseTimeout) {
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
async search(title, author) {
|
||||
let searchString = encodeURIComponent(title)
|
||||
if (author) {
|
||||
searchString += encodeURIComponent(' ' + author)
|
||||
}
|
||||
const url = `${this._baseUrl}/search-works?q=${searchString}&page=1&onlymatches=1`
|
||||
Logger.debug(`[FantLab] Search url: ${url}`)
|
||||
const items = await axios.get(url).then((res) => {
|
||||
return res.data || []
|
||||
}).catch(error => {
|
||||
Logger.error('[FantLab] search error', error)
|
||||
return []
|
||||
})
|
||||
const items = await axios
|
||||
.get(url, {
|
||||
timeout
|
||||
})
|
||||
.then((res) => {
|
||||
return res.data || []
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('[FantLab] search error', error)
|
||||
return []
|
||||
})
|
||||
|
||||
return Promise.all(items.map(async item => await this.getWork(item))).then(resArray => {
|
||||
return resArray.filter(res => res)
|
||||
return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => {
|
||||
return resArray.filter((res) => res)
|
||||
})
|
||||
}
|
||||
|
||||
async getWork(item) {
|
||||
/**
|
||||
* @param {Object} item
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object>}
|
||||
**/
|
||||
async getWork(item, timeout = this.#responseTimeout) {
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
const { work_id, work_type_id } = item
|
||||
|
||||
if (this._filterWorkType.includes(work_type_id)) {
|
||||
|
@ -51,23 +71,34 @@ class FantLab {
|
|||
}
|
||||
|
||||
const url = `${this._baseUrl}/work/${work_id}/extended`
|
||||
const bookData = await axios.get(url).then((resp) => {
|
||||
return resp.data || null
|
||||
}).catch((error) => {
|
||||
Logger.error(`[FantLab] work info request for url "${url}" error`, error)
|
||||
return null
|
||||
})
|
||||
const bookData = await axios
|
||||
.get(url, {
|
||||
timeout
|
||||
})
|
||||
.then((resp) => {
|
||||
return resp.data || null
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(`[FantLab] work info request for url "${url}" error`, error)
|
||||
return null
|
||||
})
|
||||
|
||||
return this.cleanBookData(bookData)
|
||||
return this.cleanBookData(bookData, timeout)
|
||||
}
|
||||
|
||||
async cleanBookData(bookData) {
|
||||
/**
|
||||
*
|
||||
* @param {Object} bookData
|
||||
* @param {number} [timeout]
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async cleanBookData(bookData, timeout = this.#responseTimeout) {
|
||||
let { authors, work_name_alts, work_id, work_name, work_year, work_description, image, classificatory, editions_blocks } = bookData
|
||||
|
||||
const subtitle = Array.isArray(work_name_alts) ? work_name_alts[0] : null
|
||||
const authorNames = authors.map(au => (au.name || '').trim()).filter(au => au)
|
||||
const authorNames = authors.map((au) => (au.name || '').trim()).filter((au) => au)
|
||||
|
||||
const imageAndIsbn = await this.tryGetCoverFromEditions(editions_blocks)
|
||||
const imageAndIsbn = await this.tryGetCoverFromEditions(editions_blocks, timeout)
|
||||
|
||||
const imageToUse = imageAndIsbn?.imageUrl || image
|
||||
|
||||
|
@ -88,7 +119,7 @@ class FantLab {
|
|||
tryGetGenres(classificatory) {
|
||||
if (!classificatory || !classificatory.genre_group) return []
|
||||
|
||||
const genresGroup = classificatory.genre_group.find(group => group.genre_group_id == 1) // genres and subgenres
|
||||
const genresGroup = classificatory.genre_group.find((group) => group.genre_group_id == 1) // genres and subgenres
|
||||
|
||||
// genre_group_id=2 - General Characteristics
|
||||
// genre_group_id=3 - Arena
|
||||
|
@ -108,10 +139,16 @@ class FantLab {
|
|||
|
||||
tryGetSubGenres(rootGenre) {
|
||||
if (!rootGenre.genre || !rootGenre.genre.length) return []
|
||||
return rootGenre.genre.map(g => g.label).filter(g => g)
|
||||
return rootGenre.genre.map((g) => g.label).filter((g) => g)
|
||||
}
|
||||
|
||||
async tryGetCoverFromEditions(editions) {
|
||||
/**
|
||||
*
|
||||
* @param {Object} editions
|
||||
* @param {number} [timeout]
|
||||
* @returns {Promise<{imageUrl: string, isbn: string}>
|
||||
*/
|
||||
async tryGetCoverFromEditions(editions, timeout = this.#responseTimeout) {
|
||||
if (!editions) {
|
||||
return null
|
||||
}
|
||||
|
@ -129,24 +166,37 @@ class FantLab {
|
|||
const isbn = lastEdition['isbn'] || null // get only from paper edition
|
||||
|
||||
return {
|
||||
imageUrl: await this.getCoverFromEdition(editionId),
|
||||
imageUrl: await this.getCoverFromEdition(editionId, timeout),
|
||||
isbn
|
||||
}
|
||||
}
|
||||
|
||||
async getCoverFromEdition(editionId) {
|
||||
/**
|
||||
*
|
||||
* @param {number} editionId
|
||||
* @param {number} [timeout]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getCoverFromEdition(editionId, timeout = this.#responseTimeout) {
|
||||
if (!editionId) return null
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
const url = `${this._baseUrl}/edition/${editionId}`
|
||||
|
||||
const editionInfo = await axios.get(url).then((resp) => {
|
||||
return resp.data || null
|
||||
}).catch(error => {
|
||||
Logger.error(`[FantLab] search cover from edition with url "${url}" error`, error)
|
||||
return null
|
||||
})
|
||||
const editionInfo = await axios
|
||||
.get(url, {
|
||||
timeout
|
||||
})
|
||||
.then((resp) => {
|
||||
return resp.data || null
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(`[FantLab] search cover from edition with url "${url}" error`, error)
|
||||
return null
|
||||
})
|
||||
|
||||
return editionInfo?.image || null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FantLab
|
||||
module.exports = FantLab
|
||||
|
|
|
@ -2,12 +2,14 @@ const axios = require('axios')
|
|||
const Logger = require('../Logger')
|
||||
|
||||
class GoogleBooks {
|
||||
constructor() { }
|
||||
#responseTimeout = 30000
|
||||
|
||||
constructor() {}
|
||||
|
||||
extractIsbn(industryIdentifiers) {
|
||||
if (!industryIdentifiers || !industryIdentifiers.length) return null
|
||||
|
||||
var isbnObj = industryIdentifiers.find(i => i.type === 'ISBN_13') || industryIdentifiers.find(i => i.type === 'ISBN_10')
|
||||
var isbnObj = industryIdentifiers.find((i) => i.type === 'ISBN_13') || industryIdentifiers.find((i) => i.type === 'ISBN_10')
|
||||
if (isbnObj && isbnObj.identifier) return isbnObj.identifier
|
||||
return null
|
||||
}
|
||||
|
@ -38,24 +40,38 @@ class GoogleBooks {
|
|||
}
|
||||
}
|
||||
|
||||
async search(title, author) {
|
||||
/**
|
||||
* Search for a book by title and author
|
||||
* @param {string} title
|
||||
* @param {string} author
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
**/
|
||||
async search(title, author, timeout = this.#responseTimeout) {
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
title = encodeURIComponent(title)
|
||||
var queryString = `q=intitle:${title}`
|
||||
let queryString = `q=intitle:${title}`
|
||||
if (author) {
|
||||
author = encodeURIComponent(author)
|
||||
queryString += `+inauthor:${author}`
|
||||
}
|
||||
var url = `https://www.googleapis.com/books/v1/volumes?${queryString}`
|
||||
const url = `https://www.googleapis.com/books/v1/volumes?${queryString}`
|
||||
Logger.debug(`[GoogleBooks] Search url: ${url}`)
|
||||
var items = await axios.get(url).then((res) => {
|
||||
if (!res || !res.data || !res.data.items) return []
|
||||
return res.data.items
|
||||
}).catch(error => {
|
||||
Logger.error('[GoogleBooks] Volume search error', error)
|
||||
return []
|
||||
})
|
||||
return items.map(item => this.cleanResult(item))
|
||||
const items = await axios
|
||||
.get(url, {
|
||||
timeout
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res || !res.data || !res.data.items) return []
|
||||
return res.data.items
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('[GoogleBooks] Volume search error', error)
|
||||
return []
|
||||
})
|
||||
return items.map((item) => this.cleanResult(item))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GoogleBooks
|
||||
module.exports = GoogleBooks
|
||||
|
|
|
@ -1,17 +1,31 @@
|
|||
var axios = require('axios')
|
||||
const axios = require('axios').default
|
||||
|
||||
class OpenLibrary {
|
||||
#responseTimeout = 30000
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = 'https://openlibrary.org'
|
||||
}
|
||||
|
||||
get(uri) {
|
||||
return axios.get(`${this.baseUrl}/${uri}`).then((res) => {
|
||||
return res.data
|
||||
}).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
/**
|
||||
*
|
||||
* @param {string} uri
|
||||
* @param {number} timeout
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
get(uri, timeout = this.#responseTimeout) {
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
return axios
|
||||
.get(`${this.baseUrl}/${uri}`, {
|
||||
timeout
|
||||
})
|
||||
.then((res) => {
|
||||
return res.data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
async isbnLookup(isbn) {
|
||||
|
@ -33,7 +47,7 @@ class OpenLibrary {
|
|||
}
|
||||
}
|
||||
if (!worksData.covers) worksData.covers = []
|
||||
var coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
|
||||
var coverImages = worksData.covers.filter((c) => c > 0).map((c) => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
|
||||
var description = null
|
||||
if (worksData.description) {
|
||||
if (typeof worksData.description === 'string') {
|
||||
|
@ -73,27 +87,35 @@ class OpenLibrary {
|
|||
}
|
||||
|
||||
async search(query) {
|
||||
var queryString = Object.keys(query).map(key => key + '=' + query[key]).join('&')
|
||||
var queryString = Object.keys(query)
|
||||
.map((key) => key + '=' + query[key])
|
||||
.join('&')
|
||||
var lookupData = await this.get(`/search.json?${queryString}`)
|
||||
if (!lookupData) {
|
||||
return {
|
||||
errorCode: 404
|
||||
}
|
||||
}
|
||||
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
|
||||
var searchDocs = await Promise.all(lookupData.docs.map((d) => this.cleanSearchDoc(d)))
|
||||
return searchDocs
|
||||
}
|
||||
|
||||
async searchTitle(title) {
|
||||
title = encodeURIComponent(title);
|
||||
var lookupData = await this.get(`/search.json?title=${title}`)
|
||||
/**
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {number} timeout
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async searchTitle(title, timeout = this.#responseTimeout) {
|
||||
title = encodeURIComponent(title)
|
||||
var lookupData = await this.get(`/search.json?title=${title}`, timeout)
|
||||
if (!lookupData) {
|
||||
return {
|
||||
errorCode: 404
|
||||
}
|
||||
}
|
||||
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
|
||||
var searchDocs = await Promise.all(lookupData.docs.map((d) => this.cleanSearchDoc(d)))
|
||||
return searchDocs
|
||||
}
|
||||
}
|
||||
module.exports = OpenLibrary
|
||||
module.exports = OpenLibrary
|
||||
|
|
|
@ -28,19 +28,24 @@ const htmlSanitizer = require('../utils/htmlSanitizer')
|
|||
*/
|
||||
|
||||
class iTunes {
|
||||
constructor() { }
|
||||
#responseTimeout = 30000
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* @see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html
|
||||
*
|
||||
* @param {iTunesSearchParams} options
|
||||
*
|
||||
* @param {iTunesSearchParams} options
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
search(options) {
|
||||
search(options, timeout = this.#responseTimeout) {
|
||||
if (!options.term) {
|
||||
Logger.error('[iTunes] Invalid search options - no term')
|
||||
return []
|
||||
}
|
||||
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout
|
||||
|
||||
const query = {
|
||||
term: options.term,
|
||||
media: options.media,
|
||||
|
@ -49,12 +54,18 @@ class iTunes {
|
|||
limit: options.limit,
|
||||
country: options.country
|
||||
}
|
||||
return axios.get('https://itunes.apple.com/search', { params: query }).then((response) => {
|
||||
return response.data.results || []
|
||||
}).catch((error) => {
|
||||
Logger.error(`[iTunes] search request error`, error)
|
||||
return []
|
||||
})
|
||||
return axios
|
||||
.get('https://itunes.apple.com/search', {
|
||||
params: query,
|
||||
timeout
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data.results || []
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(`[iTunes] search request error`, error)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
// Example cover art: https://is1-ssl.mzstatic.com/image/thumb/Music118/v4/cb/ea/73/cbea739b-ff3b-11c4-fb93-7889fbec7390/9781598874983_cover.jpg/100x100bb.jpg
|
||||
|
@ -65,20 +76,22 @@ class iTunes {
|
|||
return data.artworkUrl600
|
||||
}
|
||||
// Should already be sorted from small to large
|
||||
var artworkSizes = Object.keys(data).filter(key => key.startsWith('artworkUrl')).map(key => {
|
||||
return {
|
||||
url: data[key],
|
||||
size: Number(key.replace('artworkUrl', ''))
|
||||
}
|
||||
})
|
||||
var artworkSizes = Object.keys(data)
|
||||
.filter((key) => key.startsWith('artworkUrl'))
|
||||
.map((key) => {
|
||||
return {
|
||||
url: data[key],
|
||||
size: Number(key.replace('artworkUrl', ''))
|
||||
}
|
||||
})
|
||||
if (!artworkSizes.length) return null
|
||||
|
||||
// Return next biggest size > 600
|
||||
var nextBestSize = artworkSizes.find(size => size.size > 600)
|
||||
var nextBestSize = artworkSizes.find((size) => size.size > 600)
|
||||
if (nextBestSize) return nextBestSize.url
|
||||
|
||||
// Find square artwork
|
||||
var squareArtwork = artworkSizes.find(size => size.url.includes(`${size.size}x${size.size}bb`))
|
||||
var squareArtwork = artworkSizes.find((size) => size.url.includes(`${size.size}x${size.size}bb`))
|
||||
|
||||
// Square cover replace with 600x600bb
|
||||
if (squareArtwork) {
|
||||
|
@ -106,15 +119,21 @@ class iTunes {
|
|||
}
|
||||
}
|
||||
|
||||
searchAudiobooks(term) {
|
||||
return this.search({ term, entity: 'audiobook', media: 'audiobook' }).then((results) => {
|
||||
/**
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
searchAudiobooks(term, timeout = this.#responseTimeout) {
|
||||
return this.search({ term, entity: 'audiobook', media: 'audiobook' }, timeout).then((results) => {
|
||||
return results.map(this.cleanAudiobook.bind(this))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} data
|
||||
*
|
||||
* @param {Object} data
|
||||
* @returns {iTunesPodcastSearchResult}
|
||||
*/
|
||||
cleanPodcast(data) {
|
||||
|
@ -136,13 +155,14 @@ class iTunes {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {{country:string}} options
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {{country:string}} options
|
||||
* @param {number} [timeout] response timeout in ms
|
||||
* @returns {Promise<iTunesPodcastSearchResult[]>}
|
||||
*/
|
||||
searchPodcasts(term, options = {}) {
|
||||
return this.search({ term, entity: 'podcast', media: 'podcast', ...options }).then((results) => {
|
||||
searchPodcasts(term, options = {}, timeout = this.#responseTimeout) {
|
||||
return this.search({ term, entity: 'podcast', media: 'podcast', ...options }, timeout).then((results) => {
|
||||
return results.map(this.cleanPodcast.bind(this))
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue