mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05:00 
			
		
		
		
	Match confidence calculation for audible results
This commit is contained in:
		
							parent
							
								
									387e58a714
								
							
						
					
					
						commit
						a894ceb9cf
					
				@ -7,7 +7,7 @@ const FantLab = require('../providers/FantLab')
 | 
				
			|||||||
const AudiobookCovers = require('../providers/AudiobookCovers')
 | 
					const AudiobookCovers = require('../providers/AudiobookCovers')
 | 
				
			||||||
const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
 | 
					const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
 | 
				
			||||||
const Logger = require('../Logger')
 | 
					const Logger = require('../Logger')
 | 
				
			||||||
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
 | 
					const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index')
 | 
				
			||||||
const htmlSanitizer = require('../utils/htmlSanitizer')
 | 
					const htmlSanitizer = require('../utils/htmlSanitizer')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BookFinder {
 | 
					class BookFinder {
 | 
				
			||||||
@ -385,7 +385,11 @@ class BookFinder {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    if (!title) return books
 | 
					    if (!title) return books
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance)
 | 
					    const isTitleAsin = isValidASIN(title.toUpperCase())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let actualTitleQuery = title
 | 
				
			||||||
 | 
					    let actualAuthorQuery = author
 | 
				
			||||||
 | 
					    books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!books.length && maxFuzzySearches > 0) {
 | 
					    if (!books.length && maxFuzzySearches > 0) {
 | 
				
			||||||
      // Normalize title and author
 | 
					      // Normalize title and author
 | 
				
			||||||
@ -408,19 +412,26 @@ class BookFinder {
 | 
				
			|||||||
        for (const titlePart of titleParts) titleCandidates.add(titlePart)
 | 
					        for (const titlePart of titleParts) titleCandidates.add(titlePart)
 | 
				
			||||||
        titleCandidates = titleCandidates.getCandidates()
 | 
					        titleCandidates = titleCandidates.getCandidates()
 | 
				
			||||||
        for (const titleCandidate of titleCandidates) {
 | 
					        for (const titleCandidate of titleCandidates) {
 | 
				
			||||||
          if (titleCandidate == title && authorCandidate == author) continue // We already tried this
 | 
					          if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this
 | 
				
			||||||
          if (++numFuzzySearches > maxFuzzySearches) break loop_author
 | 
					          if (++numFuzzySearches > maxFuzzySearches) break loop_author
 | 
				
			||||||
          books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
 | 
					          actualTitleQuery = titleCandidate
 | 
				
			||||||
 | 
					          actualAuthorQuery = authorCandidate
 | 
				
			||||||
 | 
					          books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
 | 
				
			||||||
          if (books.length) break loop_author
 | 
					          if (books.length) break loop_author
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (books.length) {
 | 
					    if (books.length) {
 | 
				
			||||||
      const resultsHaveDuration = provider.startsWith('audible')
 | 
					      const isAudibleProvider = provider.startsWith('audible')
 | 
				
			||||||
      if (resultsHaveDuration && libraryItem?.media?.duration) {
 | 
					      const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null
 | 
				
			||||||
        const libraryItemDurationMinutes = libraryItem.media.duration / 60
 | 
					
 | 
				
			||||||
        // If provider results have duration, sort by ascendinge duration difference from libraryItem
 | 
					      books.forEach((book) => {
 | 
				
			||||||
 | 
					        if (typeof book !== 'object' || !isAudibleProvider) return
 | 
				
			||||||
 | 
					        book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (isAudibleProvider && libraryItemDurationMinutes) {
 | 
				
			||||||
        books.sort((a, b) => {
 | 
					        books.sort((a, b) => {
 | 
				
			||||||
          const aDuration = a.duration || Number.POSITIVE_INFINITY
 | 
					          const aDuration = a.duration || Number.POSITIVE_INFINITY
 | 
				
			||||||
          const bDuration = b.duration || Number.POSITIVE_INFINITY
 | 
					          const bDuration = b.duration || Number.POSITIVE_INFINITY
 | 
				
			||||||
@ -433,6 +444,120 @@ class BookFinder {
 | 
				
			|||||||
    return books
 | 
					    return books
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Calculate match confidence score for a book
 | 
				
			||||||
 | 
					   * @param {Object} book - The book object to calculate confidence for
 | 
				
			||||||
 | 
					   * @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes
 | 
				
			||||||
 | 
					   * @param {string} actualTitleQuery - Actual title query
 | 
				
			||||||
 | 
					   * @param {string} actualAuthorQuery - Actual author query
 | 
				
			||||||
 | 
					   * @param {boolean} isTitleAsin - Whether the title is an ASIN
 | 
				
			||||||
 | 
					   * @returns {number|null} - Match confidence score or null if not applicable
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) {
 | 
				
			||||||
 | 
					    // ASIN results are always a match
 | 
				
			||||||
 | 
					    if (isTitleAsin) return 1.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let durationScore
 | 
				
			||||||
 | 
					    if (libraryItemDurationMinutes && typeof book.duration === 'number') {
 | 
				
			||||||
 | 
					      const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes)
 | 
				
			||||||
 | 
					      // Duration scores:
 | 
				
			||||||
 | 
					      // diff | score
 | 
				
			||||||
 | 
					      // 0    | 1.0
 | 
				
			||||||
 | 
					      // 1    | 1.0
 | 
				
			||||||
 | 
					      // 2    | 0.9
 | 
				
			||||||
 | 
					      // 3    | 0.8
 | 
				
			||||||
 | 
					      // 4    | 0.7
 | 
				
			||||||
 | 
					      // 5    | 0.6
 | 
				
			||||||
 | 
					      // 6    | 0.48
 | 
				
			||||||
 | 
					      // 7    | 0.36
 | 
				
			||||||
 | 
					      // 8    | 0.24
 | 
				
			||||||
 | 
					      // 9    | 0.12
 | 
				
			||||||
 | 
					      // 10   | 0.0
 | 
				
			||||||
 | 
					      if (durationDiff <= 1) {
 | 
				
			||||||
 | 
					        // Covers durationDiff = 0 for score 1.0
 | 
				
			||||||
 | 
					        durationScore = 1.0
 | 
				
			||||||
 | 
					      } else if (durationDiff <= 5) {
 | 
				
			||||||
 | 
					        // (1, 5] - Score from 1.0 down to 0.6
 | 
				
			||||||
 | 
					        // Linearly interpolates between (1, 1.0) and (5, 0.6)
 | 
				
			||||||
 | 
					        // Equation: y = 1.0 - 0.08 * x
 | 
				
			||||||
 | 
					        durationScore = 1.1 - 0.1 * durationDiff
 | 
				
			||||||
 | 
					      } else if (durationDiff <= 10) {
 | 
				
			||||||
 | 
					        // (5, 10] - Score from 0.6 down to 0.0
 | 
				
			||||||
 | 
					        // Linearly interpolates between (5, 0.6) and (10, 0.0)
 | 
				
			||||||
 | 
					        // Equation: y = 1.2 - 0.12 * x
 | 
				
			||||||
 | 
					        durationScore = 1.2 - 0.12 * durationDiff
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        // durationDiff > 10 - Score is 0.0
 | 
				
			||||||
 | 
					        durationScore = 0.0
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`)
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // Default score if library item duration or book duration is not available
 | 
				
			||||||
 | 
					      durationScore = 0.1
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => {
 | 
				
			||||||
 | 
					      const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle)
 | 
				
			||||||
 | 
					      const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : ''
 | 
				
			||||||
 | 
					      const normBookTitle = `${cleanTitle}${cleanSubtitle}`
 | 
				
			||||||
 | 
					      const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle)
 | 
				
			||||||
 | 
					      const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle)
 | 
				
			||||||
 | 
					      Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`)
 | 
				
			||||||
 | 
					      return titleSimilarity
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery)
 | 
				
			||||||
 | 
					    const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let authorScore
 | 
				
			||||||
 | 
					    const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery)
 | 
				
			||||||
 | 
					    const normBookAuthor = cleanAuthorForCompares(book.author || '')
 | 
				
			||||||
 | 
					    if (!normAuthorQuery) {
 | 
				
			||||||
 | 
					      // Original query had no author
 | 
				
			||||||
 | 
					      authorScore = 1.0 // Neutral score
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // Original query HAS an author (cleanedQueryAuthorForScore is not empty)
 | 
				
			||||||
 | 
					      if (normBookAuthor) {
 | 
				
			||||||
 | 
					        const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase())
 | 
				
			||||||
 | 
					        // Filter out empty parts that might result from ", ," or trailing/leading commas
 | 
				
			||||||
 | 
					        const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (validBookAuthorParts.length === 0) {
 | 
				
			||||||
 | 
					          // Book author string was present but effectively empty (e.g. ",,")
 | 
				
			||||||
 | 
					          // Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch.
 | 
				
			||||||
 | 
					          authorScore = 0.0
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor)
 | 
				
			||||||
 | 
					          Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`)
 | 
				
			||||||
 | 
					          if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) {
 | 
				
			||||||
 | 
					            validBookAuthorParts.forEach((part) => {
 | 
				
			||||||
 | 
					              // part is guaranteed to be non-empty here
 | 
				
			||||||
 | 
					              // cleanedQueryAuthorForScore is also guaranteed non-empty here.
 | 
				
			||||||
 | 
					              // levenshteinDistance lowercases by default, but part is already lowercased.
 | 
				
			||||||
 | 
					              const similarity = levenshteinSimilarity(normAuthorQuery, part)
 | 
				
			||||||
 | 
					              Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`)
 | 
				
			||||||
 | 
					              const currentPartScore = similarity
 | 
				
			||||||
 | 
					              maxPartScore = Math.max(maxPartScore, currentPartScore)
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          authorScore = maxPartScore
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        // Book has NO author (or not a string, or empty string)
 | 
				
			||||||
 | 
					        // Query has an author (cleanedQueryAuthorForScore is non-empty), book does not.
 | 
				
			||||||
 | 
					        authorScore = 0.0
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const W_DURATION = 0.7
 | 
				
			||||||
 | 
					    const W_TITLE = 0.2
 | 
				
			||||||
 | 
					    const W_AUTHOR = 0.1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`)
 | 
				
			||||||
 | 
					    const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore
 | 
				
			||||||
 | 
					    Logger.debug(`[BookFinder] Confidence: ${confidence}`)
 | 
				
			||||||
 | 
					    return Math.max(0, Math.min(1, confidence))
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Search for books
 | 
					   * Search for books
 | 
				
			||||||
   *
 | 
					   *
 | 
				
			||||||
@ -464,6 +589,7 @@ class BookFinder {
 | 
				
			|||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      books = await this.getGoogleBooksResults(title, author)
 | 
					      books = await this.getGoogleBooksResults(title, author)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    books.forEach((book) => {
 | 
					    books.forEach((book) => {
 | 
				
			||||||
      if (book.description) {
 | 
					      if (book.description) {
 | 
				
			||||||
        book.description = htmlSanitizer.sanitize(book.description)
 | 
					        book.description = htmlSanitizer.sanitize(book.description)
 | 
				
			||||||
@ -505,6 +631,9 @@ class BookFinder {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
module.exports = new BookFinder()
 | 
					module.exports = new BookFinder()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function hasSubtitle(title) {
 | 
				
			||||||
 | 
					  return title.includes(':') || title.includes(' - ')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
function stripSubtitle(title) {
 | 
					function stripSubtitle(title) {
 | 
				
			||||||
  if (title.includes(':')) {
 | 
					  if (title.includes(':')) {
 | 
				
			||||||
    return title.split(':')[0].trim()
 | 
					    return title.split(':')[0].trim()
 | 
				
			||||||
@ -523,12 +652,12 @@ function replaceAccentedChars(str) {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function cleanTitleForCompares(title) {
 | 
					function cleanTitleForCompares(title, keepSubtitle = false) {
 | 
				
			||||||
  if (!title) return ''
 | 
					  if (!title) return ''
 | 
				
			||||||
  title = stripRedundantSpaces(title)
 | 
					  title = stripRedundantSpaces(title)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
 | 
					  // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
 | 
				
			||||||
  let stripped = stripSubtitle(title)
 | 
					  let stripped = keepSubtitle ? title : stripSubtitle(title)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
 | 
					  // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
 | 
				
			||||||
  let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
 | 
					  let cleaned = stripped.replace(/ *\([^)]*\) */g, '')
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,12 @@ const bookFinder = require('../../../server/finders/BookFinder')
 | 
				
			|||||||
const { LogLevel } = require('../../../server/utils/constants')
 | 
					const { LogLevel } = require('../../../server/utils/constants')
 | 
				
			||||||
const Logger = require('../../../server/Logger')
 | 
					const Logger = require('../../../server/Logger')
 | 
				
			||||||
Logger.setLogLevel(LogLevel.INFO)
 | 
					Logger.setLogLevel(LogLevel.INFO)
 | 
				
			||||||
 | 
					const { levenshteinDistance } = require('../../../server/utils/index')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// levenshteinDistance is needed for manual calculation of expected scores in tests.
 | 
				
			||||||
 | 
					// Assuming it's accessible for testing purposes or we mock/replicate its basic behavior if needed.
 | 
				
			||||||
 | 
					// For now, we'll assume bookFinder.search uses it internally correctly.
 | 
				
			||||||
 | 
					// const { levenshteinDistance } = require('../../../server/utils/index') // Not used directly in test logic, but for reasoning.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe('TitleCandidates', () => {
 | 
					describe('TitleCandidates', () => {
 | 
				
			||||||
  describe('cleanAuthor non-empty', () => {
 | 
					  describe('cleanAuthor non-empty', () => {
 | 
				
			||||||
@ -326,31 +332,262 @@ describe('search', () => {
 | 
				
			|||||||
    const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
 | 
					    const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    beforeEach(() => {
 | 
					    beforeEach(() => {
 | 
				
			||||||
      runSearchStub.withArgs(t, a, provider).resolves(unsorted)
 | 
					      runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    afterEach(() => {
 | 
				
			||||||
 | 
					      sinon.restore()
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('returns results sorted by library item duration diff', async () => {
 | 
					    it('returns results sorted by library item duration diff', async () => {
 | 
				
			||||||
      expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
 | 
					      const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
 | 
				
			||||||
 | 
					      expect(result).to.deep.equal(sorted)
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('returns unsorted results if library item is null', async () => {
 | 
					    it('returns unsorted results if library item is null', async () => {
 | 
				
			||||||
      expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted)
 | 
					      const result = (await bookFinder.search(null, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
 | 
				
			||||||
 | 
					      expect(result).to.deep.equal(unsorted)
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('returns unsorted results if library item duration is undefined', async () => {
 | 
					    it('returns unsorted results if library item duration is undefined', async () => {
 | 
				
			||||||
      expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted)
 | 
					      const result = (await bookFinder.search({ media: {} }, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
 | 
				
			||||||
 | 
					      expect(result).to.deep.equal(unsorted)
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('returns unsorted results if library item media is undefined', async () => {
 | 
					    it('returns unsorted results if library item media is undefined', async () => {
 | 
				
			||||||
      expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted)
 | 
					      const result = (await bookFinder.search({}, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
 | 
				
			||||||
 | 
					      expect(result).to.deep.equal(unsorted)
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should return a result last if it has no duration', async () => {
 | 
					    it('should return a result last if it has no duration', async () => {
 | 
				
			||||||
      const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
 | 
					      const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
 | 
				
			||||||
      const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
 | 
					      const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
 | 
				
			||||||
      runSearchStub.withArgs(t, a, provider).resolves(unsorted)
 | 
					      runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
 | 
				
			||||||
 | 
					      const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
 | 
				
			||||||
 | 
					      expect(result).to.deep.equal(sorted)
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
 | 
					  describe('matchConfidence score', () => {
 | 
				
			||||||
 | 
					    const W_DURATION = 0.7
 | 
				
			||||||
 | 
					    const W_TITLE = 0.2
 | 
				
			||||||
 | 
					    const W_AUTHOR = 0.1
 | 
				
			||||||
 | 
					    const DEFAULT_DURATION_SCORE_MISSING_INFO = 0.1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const libraryItemPerfectDuration = { media: { duration: 600 } } // 10 minutes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Helper to calculate expected title/author score based on Levenshtein
 | 
				
			||||||
 | 
					    // Assumes queryPart and bookPart are already "cleaned" for length calculation consistency with BookFinder.js
 | 
				
			||||||
 | 
					    const calculateStringMatchScore = (cleanedQueryPart, cleanedBookPart) => {
 | 
				
			||||||
 | 
					      if (!cleanedQueryPart) return cleanedBookPart ? 0 : 1 // query empty: 1 if book empty, else 0
 | 
				
			||||||
 | 
					      if (!cleanedBookPart) return 0 // query non-empty, book empty: 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Use the imported levenshteinDistance. It defaults to case-insensitive, which is what we want.
 | 
				
			||||||
 | 
					      const distance = levenshteinDistance(cleanedQueryPart, cleanedBookPart)
 | 
				
			||||||
 | 
					      return Math.max(0, 1 - distance / Math.max(cleanedQueryPart.length, cleanedBookPart.length))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    beforeEach(() => {
 | 
				
			||||||
 | 
					      runSearchStub.resolves([])
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    afterEach(() => {
 | 
				
			||||||
 | 
					      sinon.restore()
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    describe('for audible provider', () => {
 | 
				
			||||||
 | 
					      const provider = 'audible'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should be 1.0 for perfect duration, title, and author match', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = 1.0 (diff 0 <= 1 min)
 | 
				
			||||||
 | 
					        // titleScore = 1.0 (exact match)
 | 
				
			||||||
 | 
					        // authorScore = 1.0 (exact match)
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score a large duration mismatch', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 21, title: 'The Great Novel', author: 'John Doe' }] // 21 min, diff = 11 min
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = 0.0
 | 
				
			||||||
 | 
					        // titleScore = 1.0
 | 
				
			||||||
 | 
					        // authorScore = 1.0
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 0.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score a medium duration mismatch', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 16, title: 'The Great Novel', author: 'John Doe' }] // 16 min, diff = 6 min
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = 1.2 - 6 * 0.12 = 0.48
 | 
				
			||||||
 | 
					        // titleScore = 1.0
 | 
				
			||||||
 | 
					        // authorScore = 1.0
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 0.48 + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score a minor duration mismatch', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 14, title: 'The Great Novel', author: 'John Doe' }] // 14 min, diff = 4 min
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = 1.1 - 4 * 0.1 = 0.7
 | 
				
			||||||
 | 
					        // titleScore = 1.0
 | 
				
			||||||
 | 
					        // authorScore = 1.0
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 0.7 + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score a tiny duration mismatch', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 11, title: 'The Great Novel', author: 'John Doe' }] // 11 min, diff = 1 min
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = 1.0
 | 
				
			||||||
 | 
					        // titleScore = 1.0
 | 
				
			||||||
 | 
					        // authorScore = 1.0
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should use default duration score if libraryItem duration is missing', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search({ media: {} }, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should use default duration score if book duration is missing', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] // No duration in book
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score a partial title match', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        // Query: 'Novel Ex', Book: 'Novel'
 | 
				
			||||||
 | 
					        // cleanTitleForCompares('Novel Ex') -> 'novel ex' (length 8)
 | 
				
			||||||
 | 
					        // cleanTitleForCompares('Novel')    -> 'novel' (length 5)
 | 
				
			||||||
 | 
					        // levenshteinDistance('novel ex', 'novel') = 3
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'Novel Ex', 'John Doe')
 | 
				
			||||||
 | 
					        const expectedTitleScore = calculateStringMatchScore('novel ex', 'novel') // 1 - (3/8) = 0.625
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * expectedTitleScore + W_AUTHOR * 1.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score a partial author match (comma-separated)', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'Jane Smith, Jon Doee' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        // Query: 'Jon Doe', Book part: 'Jon Doee'
 | 
				
			||||||
 | 
					        // cleanAuthorForCompares('Jon Doe') -> 'jon doe' (length 7)
 | 
				
			||||||
 | 
					        // book author part (already lowercased) -> 'jon doee' (length 8)
 | 
				
			||||||
 | 
					        // levenshteinDistance('jon doe', 'jon doee') = 1
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'Jon Doe')
 | 
				
			||||||
 | 
					        // For the author part 'jon doee':
 | 
				
			||||||
 | 
					        const expectedAuthorPartScore = calculateStringMatchScore('jon doe', 'jon doee') // 1 - (1/7)
 | 
				
			||||||
 | 
					        // Assuming 'jane smith' gives a lower or 0 score, max score will be from 'jon doee'
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * expectedAuthorPartScore
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should give authorScore 0 if query has author but book does not', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: null }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // authorScore = 0.0
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should give authorScore 1.0 if query has no author', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', '') // Empty author
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('handles book author string that is only commas correctly (score 0)', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: ',, ,, ,' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        // cleanedQueryAuthorForScore = "john doe"
 | 
				
			||||||
 | 
					        // book.author leads to validBookAuthorParts being empty.
 | 
				
			||||||
 | 
					        // authorScore = 0.0
 | 
				
			||||||
 | 
					        const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should return 1.0 for ASIN results', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'B000F28ZJ4', null)
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should return 1.0 when author matches one of the book authors', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should return 1.0 when author query and multiple book authors are the same', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score against a book with a subtitle when the query has a subtitle', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel: A Novel', 'John Doe')
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it('should correctly score against a book with a subtitle when the query does not have a subtitle', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      describe('after fuzzy searches', () => {
 | 
				
			||||||
 | 
					        it('should return 1.0 for a title candidate match', async () => {
 | 
				
			||||||
 | 
					          const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					          runSearchStub.resolves([])
 | 
				
			||||||
 | 
					          runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
 | 
				
			||||||
 | 
					          const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel - A Novel', 'John Doe')
 | 
				
			||||||
 | 
					          expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        it('should return 1.0 for an author candidate match', async () => {
 | 
				
			||||||
 | 
					          const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					          runSearchStub.resolves([])
 | 
				
			||||||
 | 
					          runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
 | 
				
			||||||
 | 
					          const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
 | 
				
			||||||
 | 
					          expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    describe('for non-audible provider (e.g., google)', () => {
 | 
				
			||||||
 | 
					      const provider = 'google'
 | 
				
			||||||
 | 
					      it('should have not have matchConfidence', async () => {
 | 
				
			||||||
 | 
					        const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }]
 | 
				
			||||||
 | 
					        runSearchStub.resolves(bookResults)
 | 
				
			||||||
 | 
					        const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
 | 
				
			||||||
 | 
					        expect(results[0]).to.not.have.property('matchConfidence')
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user