mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge pull request #2188 from jfrazx/fix/match-authors-429
fix: HTTP/429 when requesting authors information, resolves #1570
This commit is contained in:
		
						commit
						fcd74ae17b
					
				
							
								
								
									
										1673
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1673
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,6 @@ const CoverManager = require('../managers/CoverManager')
 | 
			
		||||
const LibraryItem = require('../objects/LibraryItem')
 | 
			
		||||
 | 
			
		||||
class PodcastController {
 | 
			
		||||
 | 
			
		||||
  async create(req, res) {
 | 
			
		||||
    if (!req.user.isAdminOrUp) {
 | 
			
		||||
      Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
 | 
			
		||||
@ -28,7 +27,7 @@ class PodcastController {
 | 
			
		||||
      return res.status(404).send('Library not found')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const folder = library.folders.find(fold => fold.id === payload.folderId)
 | 
			
		||||
    const folder = library.folders.find((fold) => fold.id === payload.folderId)
 | 
			
		||||
    if (!folder) {
 | 
			
		||||
      Logger.error(`[PodcastController] Create: Folder not found "${payload.folderId}"`)
 | 
			
		||||
      return res.status(404).send('Folder not found')
 | 
			
		||||
@ -37,20 +36,24 @@ class PodcastController {
 | 
			
		||||
    const podcastPath = filePathToPOSIX(payload.path)
 | 
			
		||||
 | 
			
		||||
    // Check if a library item with this podcast folder exists already
 | 
			
		||||
    const existingLibraryItem = (await Database.libraryItemModel.count({
 | 
			
		||||
      where: {
 | 
			
		||||
        path: podcastPath
 | 
			
		||||
      }
 | 
			
		||||
    })) > 0
 | 
			
		||||
    const existingLibraryItem =
 | 
			
		||||
      (await Database.libraryItemModel.count({
 | 
			
		||||
        where: {
 | 
			
		||||
          path: podcastPath
 | 
			
		||||
        }
 | 
			
		||||
      })) > 0
 | 
			
		||||
    if (existingLibraryItem) {
 | 
			
		||||
      Logger.error(`[PodcastController] Podcast already exists at path "${podcastPath}"`)
 | 
			
		||||
      return res.status(400).send('Podcast already exists')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
 | 
			
		||||
      Logger.error(`[PodcastController] Failed to ensure podcast dir "${podcastPath}"`, error)
 | 
			
		||||
      return false
 | 
			
		||||
    })
 | 
			
		||||
    const success = await fs
 | 
			
		||||
      .ensureDir(podcastPath)
 | 
			
		||||
      .then(() => true)
 | 
			
		||||
      .catch((error) => {
 | 
			
		||||
        Logger.error(`[PodcastController] Failed to ensure podcast dir "${podcastPath}"`, error)
 | 
			
		||||
        return false
 | 
			
		||||
      })
 | 
			
		||||
    if (!success) return res.status(400).send('Invalid podcast path')
 | 
			
		||||
 | 
			
		||||
    const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
 | 
			
		||||
@ -105,12 +108,12 @@ class PodcastController {
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * POST: /api/podcasts/feed
 | 
			
		||||
   * 
 | 
			
		||||
   *
 | 
			
		||||
   * @typedef getPodcastFeedReqBody
 | 
			
		||||
   * @property {string} rssFeed
 | 
			
		||||
   * 
 | 
			
		||||
   * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req 
 | 
			
		||||
   * @param {import('express').Response} res 
 | 
			
		||||
   *
 | 
			
		||||
   * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req
 | 
			
		||||
   * @param {import('express').Response} res
 | 
			
		||||
   */
 | 
			
		||||
  async getPodcastFeed(req, res) {
 | 
			
		||||
    if (!req.user.isAdminOrUp) {
 | 
			
		||||
@ -178,7 +181,7 @@ class PodcastController {
 | 
			
		||||
 | 
			
		||||
    var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
 | 
			
		||||
    res.json({
 | 
			
		||||
      downloads: downloadsInQueue.map(d => d.toJSONForClient())
 | 
			
		||||
      downloads: downloadsInQueue.map((d) => d.toJSONForClient())
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -189,8 +192,8 @@ class PodcastController {
 | 
			
		||||
      return res.status(500).send('Podcast does not have an RSS feed URL')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var searchTitle = req.query.title
 | 
			
		||||
    if (!searchTitle) {
 | 
			
		||||
    const searchTitle = req.query.title
 | 
			
		||||
    if (!searchTitle || typeof searchTitle !== 'string') {
 | 
			
		||||
      return res.sendStatus(500)
 | 
			
		||||
    }
 | 
			
		||||
    const episodes = await findMatchingEpisodes(rssFeedUrl, searchTitle)
 | 
			
		||||
@ -254,7 +257,7 @@ class PodcastController {
 | 
			
		||||
    const episodeId = req.params.episodeId
 | 
			
		||||
    const libraryItem = req.libraryItem
 | 
			
		||||
 | 
			
		||||
    const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
 | 
			
		||||
    const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
 | 
			
		||||
    if (!episode) {
 | 
			
		||||
      Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
@ -269,7 +272,7 @@ class PodcastController {
 | 
			
		||||
    const libraryItem = req.libraryItem
 | 
			
		||||
    const hardDelete = req.query.hard === '1'
 | 
			
		||||
 | 
			
		||||
    const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
 | 
			
		||||
    const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
 | 
			
		||||
    if (!episode) {
 | 
			
		||||
      Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
@ -278,11 +281,14 @@ class PodcastController {
 | 
			
		||||
    if (hardDelete) {
 | 
			
		||||
      const audioFile = episode.audioFile
 | 
			
		||||
      // TODO: this will trigger the watcher. should maybe handle this gracefully
 | 
			
		||||
      await fs.remove(audioFile.metadata.path).then(() => {
 | 
			
		||||
        Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
 | 
			
		||||
      }).catch((error) => {
 | 
			
		||||
        Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
 | 
			
		||||
      })
 | 
			
		||||
      await fs
 | 
			
		||||
        .remove(audioFile.metadata.path)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Remove episode from Podcast and library file
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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) {
 | 
			
		||||
@ -78,4 +82,4 @@ class SearchController {
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = new SearchController()
 | 
			
		||||
module.exports = new SearchController()
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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)]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -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 []
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -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,
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
const Path = require('path')
 | 
			
		||||
const uuid = require('uuid')
 | 
			
		||||
const Logger = require('../Logger')
 | 
			
		||||
const { parseString } = require("xml2js")
 | 
			
		||||
const { parseString } = require('xml2js')
 | 
			
		||||
const areEquivalent = require('./areEquivalent')
 | 
			
		||||
 | 
			
		||||
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
 | 
			
		||||
@ -11,8 +11,9 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
 | 
			
		||||
    str1 = str1.toLowerCase()
 | 
			
		||||
    str2 = str2.toLowerCase()
 | 
			
		||||
  }
 | 
			
		||||
  const track = Array(str2.length + 1).fill(null).map(() =>
 | 
			
		||||
    Array(str1.length + 1).fill(null))
 | 
			
		||||
  const track = Array(str2.length + 1)
 | 
			
		||||
    .fill(null)
 | 
			
		||||
    .map(() => Array(str1.length + 1).fill(null))
 | 
			
		||||
  for (let i = 0; i <= str1.length; i += 1) {
 | 
			
		||||
    track[0][i] = i
 | 
			
		||||
  }
 | 
			
		||||
@ -25,7 +26,7 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
 | 
			
		||||
      track[j][i] = Math.min(
 | 
			
		||||
        track[j][i - 1] + 1, // deletion
 | 
			
		||||
        track[j - 1][i] + 1, // insertion
 | 
			
		||||
        track[j - 1][i - 1] + indicator, // substitution
 | 
			
		||||
        track[j - 1][i - 1] + indicator // substitution
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@ -138,7 +139,10 @@ module.exports.toNumber = (val, fallback = 0) => {
 | 
			
		||||
module.exports.cleanStringForSearch = (str) => {
 | 
			
		||||
  if (!str) return ''
 | 
			
		||||
  // Remove ' . ` " ,
 | 
			
		||||
  return str.toLowerCase().replace(/[\'\.\`\",]/g, '').trim()
 | 
			
		||||
  return str
 | 
			
		||||
    .toLowerCase()
 | 
			
		||||
    .replace(/[\'\.\`\",]/g, '')
 | 
			
		||||
    .trim()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getTitleParts = (title) => {
 | 
			
		||||
@ -156,7 +160,7 @@ const getTitleParts = (title) => {
 | 
			
		||||
/**
 | 
			
		||||
 * Remove sortingPrefixes from title
 | 
			
		||||
 * @example "The Good Book" => "Good Book"
 | 
			
		||||
 * @param {string} title 
 | 
			
		||||
 * @param {string} title
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.getTitleIgnorePrefix = (title) => {
 | 
			
		||||
@ -164,9 +168,9 @@ module.exports.getTitleIgnorePrefix = (title) => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Put sorting prefix at the end of title 
 | 
			
		||||
 * Put sorting prefix at the end of title
 | 
			
		||||
 * @example "The Good Book" => "Good Book, The"
 | 
			
		||||
 * @param {string} title 
 | 
			
		||||
 * @param {string} title
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.getTitlePrefixAtEnd = (title) => {
 | 
			
		||||
@ -178,8 +182,8 @@ module.exports.getTitlePrefixAtEnd = (title) => {
 | 
			
		||||
 * to lower case for only ascii characters
 | 
			
		||||
 * used to handle sqlite that doesnt support unicode lower
 | 
			
		||||
 * @see https://github.com/advplyr/audiobookshelf/issues/2187
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {string} str 
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} str
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.asciiOnlyToLowerCase = (str) => {
 | 
			
		||||
@ -200,8 +204,8 @@ module.exports.asciiOnlyToLowerCase = (str) => {
 | 
			
		||||
/**
 | 
			
		||||
 * Escape string used in RegExp
 | 
			
		||||
 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {string} str 
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} str
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.escapeRegExp = (str) => {
 | 
			
		||||
@ -211,8 +215,8 @@ module.exports.escapeRegExp = (str) => {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate url string with URL class
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {string} rawUrl 
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} rawUrl
 | 
			
		||||
 * @returns {string} null if invalid
 | 
			
		||||
 */
 | 
			
		||||
module.exports.validateUrl = (rawUrl) => {
 | 
			
		||||
@ -227,11 +231,22 @@ module.exports.validateUrl = (rawUrl) => {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a string is a valid UUID
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {string} str 
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} str
 | 
			
		||||
 * @returns {boolean}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.isUUID = (str) => {
 | 
			
		||||
  if (!str || typeof str !== 'string') return false
 | 
			
		||||
  return uuid.validate(str)
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a string is a valid ASIN
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} str
 | 
			
		||||
 * @returns {boolean}
 | 
			
		||||
 */
 | 
			
		||||
module.exports.isValidASIN = (str) => {
 | 
			
		||||
  if (!str || typeof str !== 'string') return false
 | 
			
		||||
  return /^[A-Z0-9]{10}$/.test(str)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user