2023-11-22 19:00:11 +02:00
const sinon = require ( 'sinon' )
const chai = require ( 'chai' )
const expect = chai . expect
const bookFinder = require ( '../../../server/finders/BookFinder' )
const { LogLevel } = require ( '../../../server/utils/constants' )
const Logger = require ( '../../../server/Logger' )
Logger . setLogLevel ( LogLevel . INFO )
2025-05-21 10:25:42 +03:00
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.
2023-11-22 19:00:11 +02:00
describe ( 'TitleCandidates' , ( ) => {
describe ( 'cleanAuthor non-empty' , ( ) => {
let titleCandidates
const cleanAuthor = 'leo tolstoy'
beforeEach ( ( ) => {
titleCandidates = new bookFinder . constructor . TitleCandidates ( cleanAuthor )
} )
describe ( 'no adds' , ( ) => {
it ( 'returns no candidates' , ( ) => {
expect ( titleCandidates . getCandidates ( ) ) . to . deep . equal ( [ ] )
} )
} )
describe ( 'single add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'adds candidate' , 'anna karenina' , [ 'anna karenina' ] ] ,
[ 'adds lowercased candidate' , 'ANNA KARENINA' , [ 'anna karenina' ] ] ,
[ 'adds candidate, removing redundant spaces' , 'anna karenina' , [ 'anna karenina' ] ] ,
[ 'adds candidate, removing author' , ` anna karenina by ${ cleanAuthor } ` , [ 'anna karenina' ] ] ,
[ 'does not add empty candidate after removing author' , cleanAuthor , [ ] ] ,
[ 'adds candidate, removing subtitle' , 'anna karenina: subtitle' , [ 'anna karenina' ] ] ,
2026-02-06 14:31:09 -05:00
[ 'adds candidate, not stripping subtitle for bare colon in title' , '10:04' , [ '10:04' ] ] ,
[ 'adds candidate, not stripping subtitle for colon between words without space' , 'making the mission:impossible movies' , [ 'making the mission:impossible movies' ] ] ,
2023-11-22 19:00:11 +02:00
[ 'adds candidate + variant, removing "by ..."' , 'anna karenina by arnold schwarzenegger' , [ 'anna karenina' , 'anna karenina by arnold schwarzenegger' ] ] ,
2026-02-06 14:31:09 -05:00
[ 'adds candidate + variant, removing "by ..." when title has bare colon' , '10:04 by ben lerner' , [ '10:04' , '10:04 by ben lerner' ] ] ,
2023-11-22 19:00:11 +02:00
[ 'adds candidate + variant, removing bitrate' , 'anna karenina 64kbps' , [ 'anna karenina' , 'anna karenina 64kbps' ] ] ,
[ 'adds candidate + variant, removing edition 1' , 'anna karenina 2nd edition' , [ 'anna karenina' , 'anna karenina 2nd edition' ] ] ,
[ 'adds candidate + variant, removing edition 2' , 'anna karenina 4th ed.' , [ 'anna karenina' , 'anna karenina 4th ed.' ] ] ,
[ 'adds candidate + variant, removing fie type' , 'anna karenina.mp3' , [ 'anna karenina' , 'anna karenina.mp3' ] ] ,
[ 'adds candidate + variant, removing "a novel"' , 'anna karenina a novel' , [ 'anna karenina' , 'anna karenina a novel' ] ] ,
2023-11-30 21:15:25 +02:00
[ 'adds candidate + variant, removing "abridged"' , 'abridged anna karenina' , [ 'anna karenina' , 'abridged anna karenina' ] ] ,
[ 'adds candidate + variant, removing "unabridged"' , 'anna karenina unabridged' , [ 'anna karenina' , 'anna karenina unabridged' ] ] ,
2023-11-22 19:00:11 +02:00
[ 'adds candidate + variant, removing preceding/trailing numbers' , '1 anna karenina 2' , [ 'anna karenina' , '1 anna karenina 2' ] ] ,
[ 'does not add empty candidate' , '' , [ ] ] ,
[ 'does not add spaces-only candidate' , ' ' , [ ] ] ,
2024-09-11 21:40:31 +03:00
[ 'does not add empty variant' , '1984' , [ '1984' ] ]
] . forEach ( ( [ name , title , expected ] ) =>
it ( name , ( ) => {
titleCandidates . add ( title )
expect ( titleCandidates . getCandidates ( ) ) . to . deep . equal ( expected )
} )
)
2023-11-22 19:00:11 +02:00
} )
describe ( 'multiple adds' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'demotes digits-only candidates' , [ '01' , 'anna karenina' ] , [ 'anna karenina' , '01' ] ] ,
[ 'promotes transformed variants' , [ 'title1 1' , 'title2 1' ] , [ 'title1' , 'title2' , 'title1 1' , 'title2 1' ] ] ,
[ 'orders by position' , [ 'title2' , 'title1' ] , [ 'title2' , 'title1' ] ] ,
2024-09-11 21:40:31 +03:00
[ 'dedupes candidates' , [ 'title1' , 'title1' ] , [ 'title1' ] ]
] . forEach ( ( [ name , titles , expected ] ) =>
it ( name , ( ) => {
for ( const title of titles ) titleCandidates . add ( title )
expect ( titleCandidates . getCandidates ( ) ) . to . deep . equal ( expected )
} )
)
2023-11-22 19:00:11 +02:00
} )
} )
describe ( 'cleanAuthor empty' , ( ) => {
let titleCandidates
let cleanAuthor = ''
beforeEach ( ( ) => {
titleCandidates = new bookFinder . constructor . TitleCandidates ( cleanAuthor )
} )
describe ( 'single add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ 'adds a candidate' , 'leo tolstoy' , [ 'leo tolstoy' ] ] ] . forEach ( ( [ name , title , expected ] ) =>
it ( name , ( ) => {
titleCandidates . add ( title )
expect ( titleCandidates . getCandidates ( ) ) . to . deep . equal ( expected )
} )
)
2023-11-22 19:00:11 +02:00
} )
} )
} )
describe ( 'AuthorCandidates' , ( ) => {
let authorCandidates
const audnexus = {
2024-09-11 21:40:31 +03:00
authorASINsRequest : sinon . stub ( ) . resolves ( [ { name : 'Leo Tolstoy' } , { name : 'Nikolai Gogol' } , { name : 'J. K. Rowling' } ] )
2023-11-22 19:00:11 +02:00
}
describe ( 'cleanAuthor is null' , ( ) => {
beforeEach ( ( ) => {
authorCandidates = new bookFinder . constructor . AuthorCandidates ( null , audnexus )
} )
describe ( 'no adds' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ 'returns empty author candidate' , [ ] ] ] . forEach ( ( [ name , expected ] ) =>
it ( name , async ( ) => {
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
describe ( 'single add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'adds recognized candidate' , 'nikolai gogol' , [ 'nikolai gogol' ] ] ,
[ 'does not add unrecognized candidate' , 'fyodor dostoevsky' , [ ] ] ,
[ 'adds recognized author if candidate is a superstring' , 'dr. nikolai gogol' , [ 'nikolai gogol' ] ] ,
[ 'adds candidate if it is a substring of recognized author' , 'gogol' , [ 'gogol' ] ] ,
[ 'adds recognized author if edit distance from candidate is small' , 'nicolai gogol' , [ 'nikolai gogol' ] ] ,
[ 'does not add candidate if edit distance from any recognized author is large' , 'nikolai google' , [ ] ] ,
[ 'adds normalized recognized candidate (contains redundant spaces)' , 'nikolai gogol' , [ 'nikolai gogol' ] ] ,
2023-11-30 21:17:13 +02:00
[ 'adds normalized recognized candidate (et al removed)' , 'nikolai gogol et al.' , [ 'nikolai gogol' ] ] ,
2024-09-11 21:40:31 +03:00
[ 'adds normalized recognized candidate (normalized initials)' , 'j.k. rowling' , [ 'j. k. rowling' ] ]
] . forEach ( ( [ name , author , expected ] ) =>
it ( name , async ( ) => {
authorCandidates . add ( author )
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
describe ( 'multi add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'adds recognized author candidates' , [ 'nikolai gogol' , 'leo tolstoy' ] , [ 'nikolai gogol' , 'leo tolstoy' ] ] ,
2024-09-11 21:40:31 +03:00
[ 'dedupes author candidates' , [ 'nikolai gogol' , 'nikolai gogol' ] , [ 'nikolai gogol' ] ]
] . forEach ( ( [ name , authors , expected ] ) =>
it ( name , async ( ) => {
for ( const author of authors ) authorCandidates . add ( author )
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
} )
describe ( 'cleanAuthor is a recognized author' , ( ) => {
const cleanAuthor = 'leo tolstoy'
beforeEach ( ( ) => {
authorCandidates = new bookFinder . constructor . AuthorCandidates ( cleanAuthor , audnexus )
} )
describe ( 'no adds' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ 'adds cleanAuthor as candidate' , [ cleanAuthor ] ] ] . forEach ( ( [ name , expected ] ) =>
it ( name , async ( ) => {
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
describe ( 'single add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'adds recognized candidate' , 'nikolai gogol' , [ cleanAuthor , 'nikolai gogol' ] ] ,
2024-09-11 21:40:31 +03:00
[ 'does not add candidate if it is a dupe of cleanAuthor' , cleanAuthor , [ cleanAuthor ] ]
] . forEach ( ( [ name , author , expected ] ) =>
it ( name , async ( ) => {
authorCandidates . add ( author )
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
} )
describe ( 'cleanAuthor is an unrecognized author' , ( ) => {
const cleanAuthor = 'Fyodor Dostoevsky'
beforeEach ( ( ) => {
authorCandidates = new bookFinder . constructor . AuthorCandidates ( cleanAuthor , audnexus )
} )
describe ( 'no adds' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ 'adds cleanAuthor as candidate' , [ cleanAuthor ] ] ] . forEach ( ( [ name , expected ] ) =>
it ( name , async ( ) => {
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
describe ( 'single add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'adds recognized candidate and removes cleanAuthor' , 'nikolai gogol' , [ 'nikolai gogol' ] ] ,
2024-09-11 21:40:31 +03:00
[ 'does not add unrecognized candidate' , 'jackie chan' , [ cleanAuthor ] ]
] . forEach ( ( [ name , author , expected ] ) =>
it ( name , async ( ) => {
authorCandidates . add ( author )
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
} )
describe ( 'cleanAuthor is unrecognized and dirty' , ( ) => {
describe ( 'no adds' , ( ) => {
2024-09-11 21:40:31 +03:00
; [
2023-11-22 19:00:11 +02:00
[ 'adds aggressively cleaned cleanAuthor' , 'fyodor dostoevsky, translated by jackie chan' , [ 'fyodor dostoevsky' ] ] ,
2024-09-11 21:40:31 +03:00
[ 'adds cleanAuthor if aggresively cleaned cleanAuthor is empty' , ', jackie chan' , [ ', jackie chan' ] ]
] . forEach ( ( [ name , cleanAuthor , expected ] ) =>
it ( name , async ( ) => {
authorCandidates = new bookFinder . constructor . AuthorCandidates ( cleanAuthor , audnexus )
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
describe ( 'single add' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ 'adds recognized candidate and removes cleanAuthor' , 'fyodor dostoevsky, translated by jackie chan' , 'nikolai gogol' , [ 'nikolai gogol' ] ] ] . forEach ( ( [ name , cleanAuthor , author , expected ] ) =>
it ( name , async ( ) => {
authorCandidates = new bookFinder . constructor . AuthorCandidates ( cleanAuthor , audnexus )
authorCandidates . add ( author )
expect ( await authorCandidates . getCandidates ( ) ) . to . deep . equal ( [ ... expected , '' ] )
} )
)
2023-11-22 19:00:11 +02:00
} )
} )
} )
describe ( 'search' , ( ) => {
const t = 'title'
const a = 'author'
const u = 'unrecognized'
const r = [ 'book' ]
2024-09-11 21:40:31 +03:00
let runSearchStub
let audnexusStub
2023-11-22 19:00:11 +02:00
beforeEach ( ( ) => {
2024-09-11 21:40:31 +03:00
runSearchStub = sinon . stub ( bookFinder , 'runSearch' )
runSearchStub . resolves ( [ ] )
runSearchStub . withArgs ( t , a ) . resolves ( r )
runSearchStub . withArgs ( t , u ) . resolves ( r )
audnexusStub = sinon . stub ( bookFinder . audnexus , 'authorASINsRequest' )
audnexusStub . resolves ( [ { name : a } ] )
} )
afterEach ( ( ) => {
sinon . restore ( )
2023-11-22 19:00:11 +02:00
} )
describe ( 'search title is empty' , ( ) => {
it ( 'returns empty result' , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , '' , a ) ) . to . deep . equal ( [ ] )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 0 )
} )
} )
describe ( 'search title is a recognized title and search author is a recognized author' , ( ) => {
it ( 'returns non-empty result (no fuzzy searches)' , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , t , a ) ) . to . deep . equal ( r )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 1 )
} )
} )
describe ( 'search title contains recognized title and search author is a recognized author' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ ` ${ t } - ` ] , [ ` ${ t } - ${ a } ` ] , [ ` ${ a } - ${ t } ` ] , [ ` ${ t } - ${ a } ` ] , [ ` ${ t } - ${ a } ` ] , [ ` ${ t } ${ a } ` ] , [ ` ${ a } - ${ t } (unabridged) ` ] , [ ` ${ a } - ${ t } (subtitle) - mp3 ` ] , [ ` ${ t } {narrator} - series-01 64kbps 10:00:00 ` ] , [ ` ${ a } - ${ t } (2006) narrated by narrator [unabridged] ` ] , [ ` ${ t } - ${ a } 2022 mp3 ` ] , [ ` 01 ${ t } ` ] , [ ` 2022_ ${ t } _HQ ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ a } ') returns non-empty result (with 1 fuzzy search) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , a ) ) . to . deep . equal ( r )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 2 )
} )
2024-09-11 21:40:31 +03:00
} )
; [ [ ` s-01 - ${ t } (narrator) 64kbps 10:00:00 ` ] , [ ` ${ a } - series 01 - ${ t } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ a } ') returns non-empty result (with 2 fuzzy searches) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , a ) ) . to . deep . equal ( r )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 3 )
} )
2024-09-11 21:40:31 +03:00
} )
; [ [ ` ${ t } - ${ a } ` ] , [ ` ${ t } junk ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ a } ') returns an empty result ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , a ) ) . to . deep . equal ( [ ] )
2023-11-22 19:00:11 +02:00
} )
} )
describe ( 'maxFuzzySearches = 0' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ ` ${ t } - ${ a } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ a } ') returns an empty result (with no fuzzy searches) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , a , null , null , { maxFuzzySearches : 0 } ) ) . to . deep . equal ( [ ] )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 1 )
} )
} )
} )
describe ( 'maxFuzzySearches = 1' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ ` s-01 - ${ t } (narrator) 64kbps 10:00:00 ` ] , [ ` ${ a } - series 01 - ${ t } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ a } ') returns an empty result (1 fuzzy search) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , a , null , null , { maxFuzzySearches : 1 } ) ) . to . deep . equal ( [ ] )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 2 )
} )
} )
} )
} )
describe ( 'search title contains recognized title and search author is empty' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ ` ${ t } - ${ a } ` ] , [ ` ${ a } - ${ t } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', '') returns a non-empty result (1 fuzzy search) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , '' ) ) . to . deep . equal ( r )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 2 )
} )
2024-09-11 21:40:31 +03:00
} )
; [ [ ` ${ t } ` ] , [ ` ${ t } - ${ u } ` ] , [ ` ${ u } - ${ t } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', '') returns an empty result ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , '' ) ) . to . deep . equal ( [ ] )
2023-11-22 19:00:11 +02:00
} )
} )
} )
describe ( 'search title contains recognized title and search author is an unrecognized author' , ( ) => {
2024-09-11 21:40:31 +03:00
; [ [ ` ${ t } - ${ u } ` ] , [ ` ${ u } - ${ t } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ u } ') returns a non-empty result (1 fuzzy search) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , u ) ) . to . deep . equal ( r )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 2 )
} )
2024-09-11 21:40:31 +03:00
} )
; [ [ ` ${ t } ` ] ] . forEach ( ( [ searchTitle ] ) => {
2023-11-22 19:00:11 +02:00
it ( ` search(' ${ searchTitle } ', ' ${ u } ') returns a non-empty result (no fuzzy search) ` , async ( ) => {
2023-12-09 00:33:06 +02:00
expect ( await bookFinder . search ( null , '' , searchTitle , u ) ) . to . deep . equal ( r )
2023-11-22 19:00:11 +02:00
sinon . assert . callCount ( bookFinder . runSearch , 1 )
} )
} )
} )
2023-12-09 00:33:06 +02:00
describe ( 'search provider results have duration' , ( ) => {
2024-09-11 21:40:31 +03:00
const libraryItem = { media : { duration : 60 * 1000 } }
2023-12-09 00:33:06 +02:00
const provider = 'audible'
const unsorted = [ { duration : 3000 } , { duration : 2000 } , { duration : 1000 } , { duration : 500 } ]
const sorted = [ { duration : 1000 } , { duration : 500 } , { duration : 2000 } , { duration : 3000 } ]
2024-09-11 21:40:31 +03:00
beforeEach ( ( ) => {
2025-05-21 10:25:42 +03:00
runSearchStub . withArgs ( t , a , provider ) . resolves ( structuredClone ( unsorted ) )
} )
afterEach ( ( ) => {
sinon . restore ( )
2024-09-11 21:40:31 +03:00
} )
2023-12-09 00:33:06 +02:00
it ( 'returns results sorted by library item duration diff' , async ( ) => {
2025-05-21 10:25:42 +03:00
const result = ( await bookFinder . search ( libraryItem , provider , t , a ) ) . map ( ( r ) => ( r . duration ? { duration : r . duration } : { } ) )
expect ( result ) . to . deep . equal ( sorted )
2023-12-09 00:33:06 +02:00
} )
2024-09-11 21:40:31 +03:00
2023-12-09 00:33:06 +02:00
it ( 'returns unsorted results if library item is null' , async ( ) => {
2025-05-21 10:25:42 +03:00
const result = ( await bookFinder . search ( null , provider , t , a ) ) . map ( ( r ) => ( r . duration ? { duration : r . duration } : { } ) )
expect ( result ) . to . deep . equal ( unsorted )
2023-12-09 00:33:06 +02:00
} )
it ( 'returns unsorted results if library item duration is undefined' , async ( ) => {
2025-05-21 10:25:42 +03:00
const result = ( await bookFinder . search ( { media : { } } , provider , t , a ) ) . map ( ( r ) => ( r . duration ? { duration : r . duration } : { } ) )
expect ( result ) . to . deep . equal ( unsorted )
2023-12-09 00:33:06 +02:00
} )
it ( 'returns unsorted results if library item media is undefined' , async ( ) => {
2025-05-21 10:25:42 +03:00
const result = ( await bookFinder . search ( { } , provider , t , a ) ) . map ( ( r ) => ( r . duration ? { duration : r . duration } : { } ) )
expect ( result ) . to . deep . equal ( unsorted )
2023-12-09 00:33:06 +02:00
} )
2024-09-11 21:40:31 +03:00
it ( 'should return a result last if it has no duration' , async ( ) => {
2023-12-09 00:33:06 +02:00
const unsorted = [ { } , { duration : 3000 } , { duration : 2000 } , { duration : 1000 } , { duration : 500 } ]
const sorted = [ { duration : 1000 } , { duration : 500 } , { duration : 2000 } , { duration : 3000 } , { } ]
2025-05-21 10:25:42 +03:00
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 )
} )
} )
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 )
} )
2023-12-09 00:33:06 +02:00
2025-05-21 10:25:42 +03:00
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' )
} )
2023-12-09 00:33:06 +02:00
} )
} )
2023-11-22 19:00:11 +02:00
} )