Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: HTTP/429 when requesting authors information, resolves #1570 #2188

Merged
merged 11 commits into from
Jun 9, 2024
1,681 changes: 773 additions & 908 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"node-tone": "^1.0.1",
"nodemailer": "^6.9.13",
"openid-client": "^5.6.1",
"p-throttle": "^4.1.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"sequelize": "^6.35.2",
Expand Down
4 changes: 2 additions & 2 deletions server/controllers/AuthorController.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')

const { reqSupportsWebp } = require('../utils/index')
const { reqSupportsWebp, isValidASIN } = require('../utils/index')

const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
Expand Down Expand Up @@ -252,7 +252,7 @@ class AuthorController {
async match(req, res) {
let authorData = null
const region = req.body.region || 'us'
if (req.body.asin) {
if (req.body.asin && isValidASIN(req.body.asin.toUpperCase?.())) {
authorData = await AuthorFinder.findAuthorByASIN(req.body.asin, region)
} else {
authorData = await AuthorFinder.findAuthorByName(req.body.q, region)
Expand Down
18 changes: 11 additions & 7 deletions server/controllers/SearchController.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
const Logger = require("../Logger")
const Logger = require('../Logger')
const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder')
const AuthorFinder = require('../finders/AuthorFinder')
const MusicFinder = require('../finders/MusicFinder')
const Database = require("../Database")
const Database = require('../Database')
const { isValidASIN } = require('../utils')

class SearchController {
constructor() { }
constructor() {}

async findBooks(req, res) {
const id = req.query.id
Expand Down Expand Up @@ -37,9 +38,9 @@ class SearchController {

/**
* Find podcast RSS feeds given a term
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async findPodcasts(req, res) {
const term = req.query.term
Expand All @@ -63,6 +64,9 @@ class SearchController {

async findChapters(req, res) {
const asin = req.query.asin
if (!isValidASIN(asin.toUpperCase())) {
return res.json({ error: 'Invalid ASIN' })
}
const region = (req.query.region || 'us').toLowerCase()
const chapterData = await BookFinder.findChapters(asin, region)
if (!chapterData) {
Expand All @@ -78,4 +82,4 @@ class SearchController {
})
}
}
module.exports = new SearchController()
module.exports = new SearchController()
15 changes: 3 additions & 12 deletions server/providers/Audible.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const axios = require('axios').default
const htmlSanitizer = require('../utils/htmlSanitizer')
const Logger = require('../Logger')
const { isValidASIN } = require('../utils/index')

class Audible {
#responseTimeout = 30000
Expand Down Expand Up @@ -81,16 +82,6 @@ class Audible {
}
}

/**
* 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
Expand Down Expand Up @@ -137,11 +128,11 @@ class Audible {
if (!timeout || isNaN(timeout)) timeout = this.#responseTimeout

let items
if (asin) {
if (asin && isValidASIN(asin.toUpperCase())) {
items = [await this.asinSearch(asin, region, timeout)]
}

if (!items && this.isProbablyAsin(title)) {
if (!items && isValidASIN(title.toUpperCase())) {
items = [await this.asinSearch(title, region, timeout)]
}

Expand Down
115 changes: 87 additions & 28 deletions server/providers/Audnexus.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const axios = require('axios').default
const { levenshteinDistance } = require('../utils/index')
const Throttle = require('p-throttle')
const Logger = require('../Logger')
const { levenshteinDistance } = require('../utils/index')
const { isValidASIN } = require('../utils/index')

/**
* @typedef AuthorSearchObj
Expand All @@ -11,8 +13,28 @@ const Logger = require('../Logger')
*/

class Audnexus {
static _instance = null

constructor() {
// ensures Audnexus class is singleton
if (Audnexus._instance) {
return Audnexus._instance
}

this.baseUrl = 'https://api.audnex.us'

// 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.
// 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,
strict: true,
interval: 150
})

Audnexus._instance = this
}

/**
Expand All @@ -24,14 +46,14 @@ class Audnexus {
authorASINsRequest(name, region) {
const searchParams = new URLSearchParams()
searchParams.set('name', name)

if (region) searchParams.set('region', region)

const authorRequestUrl = `${this.baseUrl}/authors?${searchParams.toString()}`
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
return axios
.get(authorRequestUrl)
.then((res) => {
return res.data || []
})

return this._processRequest(this.limiter(() => axios.get(authorRequestUrl)))
.then((res) => res.data || [])
.catch((error) => {
Logger.error(`[Audnexus] Author ASIN request failed for ${name}`, error)
return []
Expand All @@ -45,15 +67,20 @@ class Audnexus {
* @returns {Promise<AuthorSearchObj>}
*/
authorRequest(asin, region) {
asin = encodeURIComponent(asin)
const regionQuery = region ? `?region=${region}` : ''
const authorRequestUrl = `${this.baseUrl}/authors/${asin}${regionQuery}`
if (!isValidASIN(asin?.toUpperCase?.())) {
Logger.error(`[Audnexus] Invalid ASIN ${asin}`)
return null
}

asin = encodeURIComponent(asin.toUpperCase())

const authorRequestUrl = new URL(`${this.baseUrl}/authors/${asin}`)
if (region) authorRequestUrl.searchParams.set('region', region)

Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
return axios
.get(authorRequestUrl)
.then((res) => {
return res.data
})

return this._processRequest(this.limiter(() => axios.get(authorRequestUrl.toString())))
.then((res) => res.data)
.catch((error) => {
Logger.error(`[Audnexus] Author request failed for ${asin}`, error)
return null
Expand All @@ -68,15 +95,15 @@ class Audnexus {
*/
async findAuthorByASIN(asin, region) {
const author = await this.authorRequest(asin, region)
if (!author) {
return null
}
return {
asin: author.asin,
description: author.description,
image: author.image || null,
name: author.name
}

return author
? {
asin: author.asin,
description: author.description,
image: author.image || null,
name: author.name
}
: null
}

/**
Expand All @@ -97,13 +124,16 @@ class Audnexus {
closestMatch = authorAsinObj
}
})

if (!closestMatch || closestMatch.levenshteinDistance > maxLevenshtein) {
return null
}

const author = await this.authorRequest(closestMatch.asin)
if (!author) {
return null
}

return {
asin: author.asin,
description: author.description,
Expand All @@ -112,17 +142,46 @@ class Audnexus {
}
}

/**
*
* @param {string} asin
* @param {string} region
* @returns {Promise<Object>}
*/
getChaptersByASIN(asin, region) {
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}/${region}`)
return axios
.get(`${this.baseUrl}/books/${asin}/chapters?region=${region}`)
.then((res) => {
return res.data
})

asin = encodeURIComponent(asin.toUpperCase())
const chaptersRequestUrl = new URL(`${this.baseUrl}/books/${asin}/chapters`)
if (region) chaptersRequestUrl.searchParams.set('region', region)

return this._processRequest(this.limiter(() => axios.get(chaptersRequestUrl.toString())))
.then((res) => res.data)
.catch((error) => {
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}/${region}`, error)
return null
})
}

/**
* Internal method to process requests and retry if rate limit is exceeded.
*/
async _processRequest(request) {
try {
return await request()
} catch (error) {
if (error.response?.status === 429) {
const retryAfter = parseInt(error.response.headers?.['retry-after'], 10) || 5

Logger.warn(`[Audnexus] Rate limit exceeded. Retrying in ${retryAfter} seconds.`)
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000))

return this._processRequest(request)
}

throw error
}
}
}

module.exports = Audnexus
Loading
Loading