diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 87aa0a71..17f49752 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -16,6 +16,7 @@

{{ $getString('LabelByAuthor', [book.author]) }}

{{ $strings.LabelNarrators }}: {{ book.narrator }}

{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}

+

{{ $strings.LabelMatchConfidence }}: {{ book.matchConfidence.toFixed(3) }}

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f6288912..8c48c7ef 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -425,6 +425,7 @@ "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLowestPriority": "Lowest Priority", + "LabelMatchConfidence": "Match Confidence", "LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.", diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8fde7bc4..2d7b57f1 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -7,7 +7,7 @@ const FantLab = require('../providers/FantLab') const AudiobookCovers = require('../providers/AudiobookCovers') const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const Logger = require('../Logger') -const { levenshteinDistance, escapeRegExp } = require('../utils/index') +const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index') const htmlSanitizer = require('../utils/htmlSanitizer') class BookFinder { @@ -385,7 +385,11 @@ class BookFinder { 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) { // Normalize title and author @@ -408,19 +412,26 @@ class BookFinder { for (const titlePart of titleParts) titleCandidates.add(titlePart) titleCandidates = titleCandidates.getCandidates() 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 - 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) { - const resultsHaveDuration = provider.startsWith('audible') - if (resultsHaveDuration && libraryItem?.media?.duration) { - const libraryItemDurationMinutes = libraryItem.media.duration / 60 - // If provider results have duration, sort by ascendinge duration difference from libraryItem + const isAudibleProvider = provider.startsWith('audible') + const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null + + 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) => { const aDuration = a.duration || Number.POSITIVE_INFINITY const bDuration = b.duration || Number.POSITIVE_INFINITY @@ -433,6 +444,120 @@ class BookFinder { 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 * @@ -464,6 +589,7 @@ class BookFinder { } else { books = await this.getGoogleBooksResults(title, author) } + books.forEach((book) => { if (book.description) { book.description = htmlSanitizer.sanitize(book.description) @@ -505,6 +631,9 @@ class BookFinder { } module.exports = new BookFinder() +function hasSubtitle(title) { + return title.includes(':') || title.includes(' - ') +} function stripSubtitle(title) { if (title.includes(':')) { return title.split(':')[0].trim() @@ -523,12 +652,12 @@ function replaceAccentedChars(str) { } } -function cleanTitleForCompares(title) { +function cleanTitleForCompares(title, keepSubtitle = false) { if (!title) return '' title = stripRedundantSpaces(title) // 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") let cleaned = stripped.replace(/ *\([^)]*\) */g, '') diff --git a/server/utils/index.js b/server/utils/index.js index 9f7d961c..36962027 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -34,6 +34,14 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { } module.exports.levenshteinDistance = levenshteinDistance +const levenshteinSimilarity = (str1, str2, caseSensitive = false) => { + const distance = levenshteinDistance(str1, str2, caseSensitive) + const maxLength = Math.max(str1.length, str2.length) + if (maxLength === 0) return 1 + return 1 - distance / maxLength +} +module.exports.levenshteinSimilarity = levenshteinSimilarity + module.exports.isObject = (val) => { return val !== null && typeof val === 'object' } diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index c986cc98..6578ca82 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -5,6 +5,12 @@ const bookFinder = require('../../../server/finders/BookFinder') const { LogLevel } = require('../../../server/utils/constants') const Logger = require('../../../server/Logger') 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('cleanAuthor non-empty', () => { @@ -326,31 +332,262 @@ describe('search', () => { const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }] 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 () => { - 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 () => { - 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 () => { - 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 () => { - 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 () => { const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] 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') + }) }) }) })