diff --git a/app/Exceptions/BookProviderException.php b/app/Exceptions/BookProviderException.php new file mode 100644 index 000000000..8084462b6 --- /dev/null +++ b/app/Exceptions/BookProviderException.php @@ -0,0 +1,21 @@ +echooutput = config('nntmux.echocli'); $this->bookqty = Settings::settingValue('maxbooksprocessed') !== '' ? (int) Settings::settingValue('maxbooksprocessed') : 300; @@ -58,6 +80,12 @@ class BookService $this->parsedIsbn = null; $this->parsedBookResult = null; $this->obfuscatedSubjectExtractor = new ObfuscatedSubjectExtractor; + $this->isbnDb = $isbnDb ?? new IsbnDbService; + $this->googleBooks = $googleBooks ?? new GoogleBooksService; + $this->openLibrary = $openLibrary ?? new OpenLibraryService; + $this->itunes = $itunes ?? new ItunesService; + $this->scorer = $scorer ?? new BookMatchScorer; + $this->releaseImageService = $releaseImageService ?? new ReleaseImageService; } /** @@ -118,6 +146,21 @@ class BookService return $this->pickBestExistingBook($results->all(), $parsed); } + public function getBookInfoByIsbn(?string $isbn): ?BookInfo + { + $identifiers = BookIsbn::equivalents($isbn); + if ($identifiers === []) { + return null; + } + + return BookInfo::query() + ->where(static function ($query) use ($identifiers): void { + $query->whereIn('isbn', $identifiers) + ->orWhereIn('ean', $identifiers); + }) + ->first(); + } + /** * Get book range with pagination. * @@ -386,6 +429,10 @@ class BookService $query->where('groups_id', $groupID); } + if ($this->renamed !== '') { + $query->where('isrenamed', 1); + } + $this->processBookReleasesHelper( $query->get(['searchname', 'id', 'categories_id']), sprintf( @@ -406,8 +453,10 @@ class BookService ->orWhere('categories_id', Category::MUSIC_AUDIOBOOK); }) ->where(function ($builder): void { - $builder->where('isrenamed', 0) - ->orWhere('searchname', 'like', 'N:/NZB%') + if ($this->renamed === '') { + $builder->where('isrenamed', 0); + } + $builder->orWhere('searchname', 'like', 'N:/NZB%') ->orWhere('searchname', 'like', 'N_NZB_%') ->orWhere('name', 'like', 'N:/NZB%') ->orWhere('name', 'like', 'N_NZB_%'); @@ -453,8 +502,8 @@ class BookService cli()->header('Processing '.$res->count().' book release(s) for categories id '.$categoryID); } - $bookId = -2; foreach ($res as $arr) { + $bookId = -2; $startTime = now()->timestamp; $usedExternalApi = false; // audiobooks are also books and should be handled in an identical manor, even though it falls under a music category @@ -471,9 +520,10 @@ class BookService cli()->info('Looking up: '.$bookInfo); } - // Do a local lookup first - $bookCheck = $this->getBookInfoByName($bookInfo, $this->parsedBookResult); - $failCacheKey = 'book_lookup_fail_'.md5(strtolower($bookInfo)); + // Prefer an exact local identifier before any fuzzy title match. + $bookCheck = $this->getBookInfoByIsbn($this->parsedIsbn) + ?? $this->getBookInfoByName($bookInfo, $this->parsedBookResult); + $failCacheKey = $this->failureCacheKey($this->parsedBookResult, $bookInfo); if ($bookCheck === null && Cache::has($failCacheKey)) { // Lookup recently failed, no point trying again @@ -485,8 +535,8 @@ class BookService $bookId = $this->updateBookInfo($bookInfo, $this->parsedIsbn); $usedExternalApi = true; if ($bookId === -2) { - Cache::put($failCacheKey, true, now()->addDays(7)); - } else { + Cache::put($failCacheKey, true, now()->addDay()); + } elseif ($bookId !== null) { Cache::forget($failCacheKey); } } else { @@ -554,9 +604,12 @@ class BookService return false; } - if (! empty($releasename) && ! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename)) { + if (! empty($releasename) + && (! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename) + || $parsed->hasAuthor() + || $parsed->isbn !== null)) { $wordCount = count(preg_split('/\s+/', trim($releasename)) ?: []); - if ($wordCount >= 2) { + if ($wordCount >= 2 || $parsed->hasAuthor() || $parsed->isbn !== null) { return $parsed->searchQuery(); } } @@ -568,9 +621,12 @@ class BookService return false; } - if (! empty($releasename) && ! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename)) { + if (! empty($releasename) + && (! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename) + || $parsed->hasAuthor() + || $parsed->isbn !== null)) { $wordCount = count(preg_split('/\s+/', trim($releasename)) ?: []); - if ($wordCount >= 2) { + if ($wordCount >= 2 || $parsed->hasAuthor() || $parsed->isbn !== null) { return $parsed->searchQuery(); } } @@ -589,6 +645,10 @@ class BookService $releaseName = $quotedMatch[1]; } $isbn = $this->extractIsbn($releaseName); + $year = null; + if (preg_match('/\b(19|20)\d{2}\b/', $releaseName, $yearMatch) === 1) { + $year = (int) $yearMatch[0]; + } $a = preg_replace('/\d{1,2} \d{1,2} \d{2,4}|(19|20)\d\d|anybody got .+?[a-z]\? |[ ._-](Novel|TIA)([ ._-]|$)|([ \.])HQ([-\. ])|[\(\)\.\-_ ](AVI|AZW3?|DOC|EPUB|LIT|MOBI|NFO|RETAIL|(si)?PDF|RTF|TXT)[\)\]\.\-_ ](?![a-z0-9])|compleet|DAGSTiDNiNGEN|DiRFiX|\+ extra|r?e ?Books?([\.\-_ ]English|ers)?|azw3?|ePu([bp])s?|html|mobi|^NEW[\.\-_ ]|PDF([\.\-_ ]English)?|Please post more|Post description|Proper|Repack(fix)?|[\.\-_ ](Chinese|English|French|German|Italian|Retail|Scan|Swedish|Multilingual)|^R4 |Repost|Skytwohigh|TIA!+|TruePDF|V413HAV|(would someone )?please (re)?post.+? "|with the authors name right/i', '', $releaseName); $b = preg_replace('/^(As Req |conversion |eq |Das neue Abenteuer \d+|Fixed version( ignore previous post)?|Full |Per Req As Found|(\s+)?R4 |REQ |revised |version |\d+(\s+)?$)|(COMPLETE|INTERNAL|RELOADED| (AZW3|eB|docx|ENG?|exe|FR|Fix|gnv64|MU|NIV|R\d\s+\d{1,2} \d{1,2}|R\d|Req|TTL|UC|v(\s+)?\d))(\s+)?$/i', '', (string) $a); $c = preg_replace('/ - \[.+\]|\[.+\]/', '', (string) $b); @@ -599,7 +659,6 @@ class BookService $normalized = (string) preg_replace('/\b(S\d{1,3}E\d{1,3}|Season\s*\d+|Temporada\s*\d+|Saison\s*\d+|Staffel\s*\d+|Episode\s*\d+|HDTV|WEB[\s-]?DL|WEBRip|BluRay|BDRip|DVDRip|BRRip|XviD|[xh][\s.]?26[45]|HEVC|10bit|1080[pi]|720p|2160p|4K|UHD|HDR|REMUX|AAC5?\s*\d|DDP?5\s*\d|ATMOS|PAR2?|vol\d+\+\d+|NTb|FLUX|PSA|RARBG|YTS|YIFY|AMZN|DSNP|NF|ATVP|HMAX)\b/i', '', $normalized); $normalized = trim((string) preg_replace('/\s+/', ' ', $normalized)); - $year = null; $specialMagazineTitle = null; if (preg_match('/^MCN[._ -](?[A-Za-z]+)[._ -](?\d{1,2})[._ -](?(?:19|20)\d{2})/i', $rawReleaseName, $mcnMatch) === 1) { $month = ucfirst(strtolower($mcnMatch['month'])); @@ -627,22 +686,15 @@ class BookService $author = $candidateAuthor; $title = $candidateTitle; } - } elseif (preg_match('/^(?[A-Za-z0-9&\'\.\-\s]{3,80})\s+by\s+(?.+)$/i', $normalized, $matches) === 1) { - $author = trim($matches['author']); - $title = trim($matches['title']); } elseif (preg_match('/^(?<title>.+?)\s+by\s+(?<author>[A-Za-z0-9&\'\.\-\s]{3,80})$/i', $normalized, $matches) === 1) { $author = trim($matches['author']); $title = trim($matches['title']); } - $title = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace('/\((Book|Series)\s*\d+\)/i', '', $title))); + $title = trim((string) preg_replace('/\s+/', ' ', $title)); if ($author === null && preg_match('/^(?<title>.+?)[\s._-]+(?<year>(19|20)\d{2})[\s._-]+(?<format>EPUB|MOBI|AZW3?|PDF|FB2|DJVU|LIT)$/i', $normalized, $matches) === 1) { $title = trim(str_replace(['.', '_'], ' ', $matches['title'])); } - if ($author === null && preg_match('/^(?<title>.+?)\s+Vol\.?\s*\d{1,3}$/i', $title, $matches) === 1) { - $title = trim($matches['title']); - } - $isJunk = false; $junkPattern = '/^([a-z0-9] )+$|ArtofUsenet|ekiosk|(ebook|mobi).+collection|erotica|Full Video|ImwithJamie|linkoff org|Mega.+pack|^[a-z0-9]+ (?!((January|February|March|April|May|June|July|August|September|O([ck])tober|November|De([cz])ember)))[a-z]+( (ebooks?|The))?$|NY Times|(Book|Massive) Dump|Sexual/i'; if (preg_match($junkPattern, $normalized) === 1) { @@ -694,16 +746,15 @@ class BookService { if (preg_match('/\b(97[89](?:[-\s]?\d){10})\b/', $releaseName, $matches) === 1) { $isbn = preg_replace('/[^0-9]/', '', $matches[1]); - if (is_string($isbn) && strlen($isbn) === 13) { - return $isbn; + if (is_string($isbn)) { + return BookIsbn::normalize($isbn); } } if (preg_match('/\b(\d(?:[-\s]?\d){8}[-\s]?[\dXx])\b/', $releaseName, $matches) === 1) { $isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', $matches[1])); - if (strlen($isbn) === 10) { - return $isbn; - } + + return BookIsbn::normalize($isbn); } return null; @@ -745,185 +796,160 @@ class BookService /** * Update book info from external sources. * - * @return false|int|string - * * @throws \Exception */ - public function updateBookInfo(string $bookInfo = '', ?string $isbn = null) + public function updateBookInfo(string $bookInfo = '', ?string $isbn = null): ?int { - $ri = new ReleaseImageService; - $isbnDb = new IsbnDbService; - $googleBooks = new GoogleBooksService; - $openLibrary = new OpenLibraryService; - $itunes = new ItunesService; - $scorer = new BookMatchScorer; - - $bookId = -2; - $book = null; - if ($bookInfo !== '') { - $wordCount = count(array_filter(preg_split('/\s+/', trim($bookInfo)) ?: [])); - if ($wordCount < 3) { - return -2; - } - - $parsed = $this->parsedBookResult ?? $this->parseReleaseName($bookInfo); - $candidates = []; - $resolvedSource = null; - if ($isbnDb->isConfigured()) { - if ($isbn !== null && $isbn !== '') { - cli()->info('Fetching data from ISBNdb by ISBN '.$isbn); - $candidate = $isbnDb->findByIsbn($isbn); - if (is_array($candidate)) { - $candidates[] = $candidate; - $resolvedSource = 'isbndb_isbn'; - } - } - if ($candidates === []) { - cli()->info('Fetching data from ISBNdb for '.$bookInfo); - $candidates = array_merge($candidates, $isbnDb->searchBooks($bookInfo)); - if ($candidates !== []) { - $resolvedSource = 'isbndb_search'; - } - } - } - - if ($candidates === []) { - if ($isbn !== null && $isbn !== '') { - cli()->info('Fetching data from Google Books by ISBN '.$isbn); - $candidate = $googleBooks->findByIsbn($isbn); - if (is_array($candidate)) { - $candidates[] = $candidate; - $resolvedSource = 'google_books_isbn'; - } - } - } - - if ($candidates === []) { - cli()->info('Fetching data from Google Books for '.$bookInfo); - $candidates = array_merge( - $candidates, - $googleBooks->searchBooks( - $bookInfo, - $parsed->title, - $parsed->author, - $isbn - ) - ); - if ($candidates !== []) { - $resolvedSource = 'google_books_search'; - } - } - - if ($candidates === []) { - cli()->info('Fetching data from iTunes for '.$bookInfo); - $candidates = array_merge($candidates, $this->fetchItunesBookProperties($bookInfo, $itunes)); - if ($candidates !== []) { - $resolvedSource = 'itunes_search'; - } - } - - if ($candidates === []) { - cli()->info('Fetching data from Open Library for '.$bookInfo); - if ($isbn !== null && $isbn !== '') { - $candidate = $openLibrary->findByIsbn($isbn); - if (is_array($candidate)) { - $candidates[] = $candidate; - $resolvedSource = 'open_library_isbn'; - } - } - - if ($candidates === []) { - $candidates = array_merge($candidates, $openLibrary->searchBooks($bookInfo)); - if ($candidates !== []) { - $resolvedSource = 'open_library_search'; - } - } - } - - $book = $this->pickBestCandidate($candidates, $parsed, $scorer); - if (is_array($book)) { - Log::debug('Book metadata source resolved', [ - 'book_info' => $bookInfo, - 'source' => $resolvedSource, - 'isbn' => $isbn, - ]); - } - } - - if (empty($book)) { + $bookInfo = trim($bookInfo); + if ($bookInfo === '') { return -2; } - $check = BookInfo::query()->where('asin', $book['asin'])->first(); - if ($check === null && ! empty($book['isbn'])) { - $check = BookInfo::query()->where('isbn', $book['isbn'])->first(); - } - if ($check === null) { - $bookId = BookInfo::query()->insertGetId( - [ - 'title' => $book['title'], - 'author' => $book['author'], - 'asin' => $book['asin'], - 'isbn' => $book['isbn'], - 'ean' => $book['ean'], - 'url' => $book['url'], - 'salesrank' => $book['salesrank'], - 'publisher' => $book['publisher'], - 'publishdate' => $book['publishdate'], - 'pages' => $book['pages'], - 'overview' => $book['overview'], - 'genre' => $book['genre'], - 'cover' => $book['cover'], - 'created_at' => now(), - 'updated_at' => now(), - ] - ); - } else { - $bookId = $check['id']; - BookInfo::query()->where('id', $bookId)->update( - [ - 'title' => $book['title'], - 'author' => $book['author'], - 'asin' => $book['asin'], - 'isbn' => $book['isbn'], - 'ean' => $book['ean'], - 'url' => $book['url'], - 'salesrank' => $book['salesrank'], - 'publisher' => $book['publisher'], - 'publishdate' => $book['publishdate'], - 'pages' => $book['pages'], - 'overview' => $book['overview'], - 'genre' => $book['genre'], - 'cover' => $book['cover'], - ] - ); + $parsed = $this->parsedBookResult ?? $this->parseReleaseName($bookInfo); + $isbn = BookIsbn::normalize($isbn ?? $parsed->isbn); + if ($isbn === null && ! $parsed->hasAuthor() && $this->scorer->meaningfulTitleTokenCount($parsed->title) < 2) { + return -2; } - if ($bookId && $bookId !== -2) { - if ($this->echooutput) { - cli()->header('Added/updated book: '); - if ($book['author'] !== '') { - cli()->alternateOver(' Author: ').cli()->primary($book['author']); + $candidates = []; + $book = null; + $hadProviderFailure = false; + + if ($this->isbnDb->isConfigured()) { + try { + if ($isbn !== null) { + cli()->info('Fetching data from ISBNdb by ISBN '.$isbn); + $candidate = $this->isbnDb->findByIsbn($isbn); + if (is_array($candidate)) { + $this->appendCandidates($candidates, [$candidate], 'isbndb_isbn'); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } } - cli()->alternateOver(' Title: ').cli()->primary(' '.$book['title']); - if ($book['genre'] !== '') { - cli()->alternateOver(' Genre: ').cli()->primary(' '.$book['genre']); + + if ($book === null) { + cli()->info('Fetching data from ISBNdb for '.$parsed->title); + $this->appendCandidates( + $candidates, + $this->isbnDb->searchBooks($parsed->title, $parsed->author, true), + 'isbndb_search', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); } + + $titleOnlyHypothesis = $this->titleOnlyHypothesis($parsed); + if ($book === null && $titleOnlyHypothesis !== null) { + cli()->info('Retrying ISBNdb with title-only hypothesis '.$titleOnlyHypothesis->title); + $this->appendCandidates( + $candidates, + $this->isbnDb->searchBooks($titleOnlyHypothesis->title, null, true), + 'isbndb_title_fallback', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + } catch (BookProviderException $exception) { + $hadProviderFailure = true; + $this->logProviderFailure($exception, $bookInfo); } - - $book['cover'] = (int) $ri->saveRemoteImage( - (string) $bookId, - $book['coverurl'], - $this->imgSavePath, - ImageAssetProfile::MetadataCover, - )->success; - } elseif ($this->echooutput) { - cli()->header('Nothing to update: '). - cli()->header($book['author']. - ' - '. - $book['title']); } + if ($book === null) { + try { + if ($isbn !== null) { + cli()->info('Fetching data from Google Books by ISBN '.$isbn); + $candidate = $this->googleBooks->findByIsbn($isbn); + if (is_array($candidate)) { + $this->appendCandidates($candidates, [$candidate], 'google_books_isbn'); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + } + if ($book === null) { + cli()->info('Fetching data from Google Books for '.$parsed->title); + $this->appendCandidates( + $candidates, + $this->googleBooks->searchBooks($parsed->searchQuery(), $parsed->title, $parsed->author), + 'google_books_search', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + $titleOnlyHypothesis = $this->titleOnlyHypothesis($parsed); + if ($book === null && $titleOnlyHypothesis !== null) { + $this->appendCandidates( + $candidates, + $this->googleBooks->searchBooks( + $titleOnlyHypothesis->title, + $titleOnlyHypothesis->title, + null, + ), + 'google_books_title_fallback', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + } catch (BookProviderException $exception) { + $hadProviderFailure = true; + $this->logProviderFailure($exception, $bookInfo); + } + } + + if ($book === null) { + try { + cli()->info('Fetching data from Open Library for '.$parsed->title); + if ($isbn !== null) { + $candidate = $this->openLibrary->findByIsbn($isbn); + if (is_array($candidate)) { + $this->appendCandidates($candidates, [$candidate], 'open_library_isbn'); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + } + if ($book === null) { + $this->appendCandidates( + $candidates, + $this->openLibrary->searchBooks($parsed->searchQuery()), + 'open_library_search', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + $titleOnlyHypothesis = $this->titleOnlyHypothesis($parsed); + if ($book === null && $titleOnlyHypothesis !== null) { + $this->appendCandidates( + $candidates, + $this->openLibrary->searchBooks($titleOnlyHypothesis->title), + 'open_library_title_fallback', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + } + } catch (BookProviderException $exception) { + $hadProviderFailure = true; + $this->logProviderFailure($exception, $bookInfo); + } + } + + if ($book === null) { + cli()->info('Fetching data from iTunes for '.$parsed->title); + $itunesCandidates = $this->fetchItunesBookProperties($parsed->searchQuery(), $this->itunes); + $this->appendCandidates( + $candidates, + $itunesCandidates, + 'itunes_search', + ); + $book = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + if ($itunesCandidates === [] && $this->itunes->lastRequestFailed()) { + $hadProviderFailure = true; + } + } + + if ($book === null) { + return $hadProviderFailure ? null : -2; + } + + Log::debug('Book metadata source resolved', [ + 'book_info' => $bookInfo, + 'source' => $book['_source'] ?? null, + 'isbn' => $isbn, + ]); + + $bookId = $this->persistBookCandidate($book); + $this->outputBookMatch($book); + return $bookId; } @@ -980,18 +1006,28 @@ class BookService return $books[0]; } - $scorer = new BookMatchScorer; - $best = null; - $bestScore = 0.0; + $candidates = []; foreach ($books as $book) { - $score = $scorer->scoreBookInfo($book, $parsed); - if ($score > $bestScore) { - $best = $book; - $bestScore = $score; - } + $candidates[] = [ + '_book_id' => $book->id, + 'title' => $book->title, + 'author' => $book->author, + 'isbn' => $book->isbn, + 'ean' => $book->ean, + 'publishdate' => $book->publishdate, + 'publisher' => $book->publisher, + 'cover' => $book->cover ? 1 : 0, + ]; } - return $bestScore >= $this->minimumMatchScore($parsed) ? $best : null; + $best = $this->pickBestCandidate($candidates, $parsed, $this->scorer); + if ($best === null) { + return null; + } + + $bookId = (int) ($best['_book_id'] ?? 0); + + return $bookId > 0 ? BookInfo::query()->find($bookId) : null; } /** @@ -1004,30 +1040,302 @@ class BookService return null; } - $best = null; - $bestScore = 0.0; - foreach ($candidates as $candidate) { - $score = $scorer->score($candidate, $parsed); - if ($score > $bestScore) { - $bestScore = $score; - $best = $candidate; + foreach ($this->matchingHypotheses($parsed) as $hypothesis) { + $best = $this->pickBestCandidateForParse($candidates, $hypothesis, $scorer); + if ($best !== null) { + return $best; } } - if ($best === null || $bestScore < $this->minimumMatchScore($parsed)) { + return null; + } + + /** + * @param array<int, array<string, mixed>> $candidates + * @return array<string, mixed>|null + */ + private function pickBestCandidateForParse( + array $candidates, + BookParseResult $parsed, + BookMatchScorer $scorer, + ): ?array { + + /** @var array<string, array{candidate: array<string, mixed>, score: float, year_tie: int, completeness: int}> $byWork */ + $byWork = []; + foreach ($candidates as $candidate) { + $score = $scorer->score($candidate, $parsed); + $key = $scorer->workKey($candidate); + if ($key === '|') { + $key = (string) ($candidate['asin'] ?? md5((string) json_encode($candidate))); + } + $completeness = $this->candidateCompleteness($candidate); + $yearTie = $this->candidateYearTie($candidate, $parsed); + $existing = $byWork[$key] ?? null; + if ($existing === null + || $score > $existing['score'] + || ($score === $existing['score'] && $yearTie > $existing['year_tie']) + || ($score === $existing['score'] + && $yearTie === $existing['year_tie'] + && $completeness > $existing['completeness'])) { + $byWork[$key] = [ + 'candidate' => $candidate, + 'score' => $score, + 'year_tie' => $yearTie, + 'completeness' => $completeness, + ]; + } + } + + $ranked = array_values($byWork); + usort($ranked, static function (array $left, array $right): int { + $scoreComparison = $right['score'] <=> $left['score']; + + return $scoreComparison !== 0 + ? $scoreComparison + : (($right['year_tie'] <=> $left['year_tie']) + ?: ($right['completeness'] <=> $left['completeness'])); + }); + + $best = $ranked[0] ?? null; + if ($best === null || ! $this->candidatePassesIdentityRules( + $best['candidate'], + $best['score'], + $ranked[1]['score'] ?? null, + $parsed, + $scorer, + )) { return null; } - return $best; + return $best['candidate']; } - private function minimumMatchScore(?BookParseResult $parsed): float + /** + * @return list<BookParseResult> + */ + private function matchingHypotheses(BookParseResult $parsed): array { - if ($parsed === null) { - return 0.55; + $hypotheses = [$parsed]; + $titleOnly = $this->titleOnlyHypothesis($parsed); + if ($titleOnly !== null) { + $hypotheses[] = $titleOnly; } - // No-author parses are more ambiguous and need a stricter cutoff. - return $parsed->hasAuthor() ? 0.55 : 0.68; + return $hypotheses; + } + + private function titleOnlyHypothesis(BookParseResult $parsed): ?BookParseResult + { + if (! $parsed->hasAuthor() || ! str_contains($parsed->rawName, ' - ')) { + return null; + } + + return new BookParseResult( + rawName: $parsed->rawName, + title: trim($parsed->author.' - '.$parsed->title), + author: null, + isbn: $parsed->isbn, + year: $parsed->year, + format: $parsed->format, + isJunk: $parsed->isJunk, + isMagazine: $parsed->isMagazine, + ); + } + + /** + * @param array<string, mixed> $candidate + */ + private function candidatePassesIdentityRules( + array $candidate, + float $score, + ?float $runnerUpScore, + BookParseResult $parsed, + BookMatchScorer $scorer, + ): bool { + if ($score === 1.0 && $parsed->isbn !== null) { + return true; + } + + $titleScore = $scorer->titleScore($parsed->title, (string) ($candidate['title'] ?? '')); + if ($parsed->hasAuthor()) { + $authorScore = $scorer->authorScore((string) $parsed->author, (string) ($candidate['author'] ?? '')); + if ($titleScore < 0.78 || $authorScore < 0.75 || $score < 0.82) { + return false; + } + + return $runnerUpScore === null || ($score - $runnerUpScore) >= 0.08; + } + + if ($scorer->meaningfulTitleTokenCount($parsed->title) < 3 || $titleScore < 0.92) { + return false; + } + + return $runnerUpScore === null || ($score - $runnerUpScore) >= 0.12; + } + + /** + * @param array<int, array<string, mixed>> $target + * @param array<int, array<string, mixed>> $incoming + */ + private function appendCandidates(array &$target, array $incoming, string $source): void + { + foreach ($incoming as $candidate) { + $candidate['_source'] = $source; + $target[] = $candidate; + } + } + + /** + * @param array<string, mixed> $candidate + */ + private function candidateCompleteness(array $candidate): int + { + $score = 0; + foreach (['isbn', 'ean', 'publisher', 'publishdate', 'pages', 'overview', 'genre', 'coverurl'] as $field) { + if (isset($candidate[$field]) && $candidate[$field] !== '') { + $score++; + } + } + + return $score; + } + + /** + * @param array<string, mixed> $candidate + */ + private function candidateYearTie(array $candidate, BookParseResult $parsed): int + { + if ($parsed->year === null || empty($candidate['publishdate'])) { + return 0; + } + + if (preg_match('/\b(19|20)\d{2}\b/', (string) $candidate['publishdate'], $match) !== 1) { + return 0; + } + + return max(0, 100 - abs($parsed->year - (int) $match[0])); + } + + /** + * @param array<string, mixed> $book + */ + private function persistBookCandidate(array $book): int + { + $check = null; + $asin = trim((string) ($book['asin'] ?? '')); + if ($asin !== '') { + $check = BookInfo::query()->where('asin', $asin)->first(); + } + + foreach (['isbn', 'ean'] as $identifierField) { + if ($check !== null) { + break; + } + $identifiers = BookIsbn::equivalents(isset($book[$identifierField]) ? (string) $book[$identifierField] : null); + if ($identifiers !== []) { + $check = BookInfo::query() + ->where(static function ($query) use ($identifiers): void { + $query->whereIn('isbn', $identifiers)->orWhereIn('ean', $identifiers); + }) + ->first(); + } + } + + $attributes = [ + 'title' => (string) ($book['title'] ?? ''), + 'author' => (string) ($book['author'] ?? ''), + 'asin' => $asin !== '' ? $asin : null, + 'isbn' => BookIsbn::normalize(isset($book['isbn']) ? (string) $book['isbn'] : null), + 'ean' => BookIsbn::normalize(isset($book['ean']) ? (string) $book['ean'] : null), + 'url' => $book['url'] ?? null, + 'salesrank' => $book['salesrank'] ?? null, + 'publisher' => $book['publisher'] ?? null, + 'publishdate' => $book['publishdate'] ?? null, + 'pages' => $book['pages'] ?? null, + 'overview' => $book['overview'] ?? null, + 'genre' => (string) ($book['genre'] ?? ''), + ]; + + if ($check === null) { + $bookId = BookInfo::query()->insertGetId(array_merge($attributes, [ + 'cover' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ])); + } else { + $bookId = (int) $check->id; + $updates = []; + foreach ($attributes as $field => $value) { + if ($value === null || $value === '') { + continue; + } + if ($field === 'asin' && ! empty($check->asin)) { + continue; + } + $updates[$field] = $value; + } + if ($updates !== []) { + $updates['updated_at'] = now(); + BookInfo::query()->where('id', $bookId)->update($updates); + } + } + + $coverUrl = trim((string) ($book['coverurl'] ?? '')); + if ($coverUrl !== '') { + $coverSaved = $this->releaseImageService->saveRemoteImage( + (string) $bookId, + $coverUrl, + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; + if ($coverSaved) { + BookInfo::query()->where('id', $bookId)->update(['cover' => 1]); + } + } + + return $bookId; + } + + /** + * @param array<string, mixed> $book + */ + private function outputBookMatch(array $book): void + { + if (! $this->echooutput) { + return; + } + + cli()->header('Added/updated book: '); + if (! empty($book['author'])) { + cli()->alternateOver(' Author: ').cli()->primary((string) $book['author']); + } + cli()->alternateOver(' Title: ').cli()->primary(' '.(string) $book['title']); + if (! empty($book['genre'])) { + cli()->alternateOver(' Genre: ').cli()->primary(' '.(string) $book['genre']); + } + } + + private function failureCacheKey(?BookParseResult $parsed, string $bookInfo): string + { + $identity = [ + (string) self::FAILURE_CACHE_VERSION, + mb_strtolower(trim($parsed === null ? $bookInfo : $parsed->title)), + mb_strtolower(trim((string) ($parsed === null ? '' : $parsed->author))), + (string) ($parsed === null ? '' : $parsed->year), + (string) ($parsed === null ? '' : $parsed->isbn), + ]; + + return 'book_lookup_fail_v'.self::FAILURE_CACHE_VERSION.'_'.md5(implode('|', $identity)); + } + + private function logProviderFailure(BookProviderException $exception, string $bookInfo): void + { + Log::warning('Book metadata provider unavailable', [ + 'provider' => $exception->provider, + 'status' => $exception->statusCode, + 'retry_after_seconds' => $exception->retryAfterSeconds, + 'book_info' => $bookInfo, + 'message' => $exception->getMessage(), + ]); } } diff --git a/app/Services/GoogleBooksService.php b/app/Services/GoogleBooksService.php index e24a5e208..747bf441b 100644 --- a/app/Services/GoogleBooksService.php +++ b/app/Services/GoogleBooksService.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Services; +use App\Exceptions\BookProviderException; +use App\Support\BookIsbn; use Carbon\Carbon; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; +use JsonException; class GoogleBooksService { @@ -73,7 +75,7 @@ class GoogleBooksService $query = trim($query); $title = $title !== null ? trim($title) : null; $author = $author !== null ? trim($author) : null; - $isbn = $isbn !== null ? strtoupper((string) preg_replace('/[^0-9X]/i', '', $isbn)) : null; + $isbn = BookIsbn::normalize($isbn); $searchQuery = $this->buildSearchQuery($query, $title, $author, $isbn); if ($searchQuery === '') { @@ -118,20 +120,42 @@ class GoogleBooksService $query['key'] = $this->apiKey; } - $response = $this->client->get(self::API_URL, ['query' => $query]); + $response = $this->client->get(self::API_URL, [ + 'http_errors' => false, + 'query' => $query, + ]); if ($response->getStatusCode() !== 200) { - Log::warning('Google Books API request failed', ['status' => $response->getStatusCode()]); - - return null; + throw new BookProviderException( + 'google_books', + 'Google Books API request failed.', + $response->getStatusCode(), + ); } - $decoded = json_decode($response->getBody()->getContents(), true); + $decoded = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); + if (! is_array($decoded)) { + throw new BookProviderException('google_books', 'Google Books returned an invalid response payload.', 200); + } - return is_array($decoded) ? $decoded : null; + return $decoded; + } catch (BookProviderException $exception) { + throw $exception; + } catch (JsonException $exception) { + throw new BookProviderException( + 'google_books', + 'Google Books returned malformed JSON.', + 200, + null, + $exception, + ); } catch (GuzzleException $e) { - Log::error('Google Books API request error: '.$e->getMessage()); - - return null; + throw new BookProviderException( + 'google_books', + 'Google Books API request error: '.$e->getMessage(), + null, + null, + $e, + ); } } @@ -172,11 +196,11 @@ class GoogleBooksService continue; } $type = (string) ($identifier['type'] ?? ''); - $value = strtoupper((string) preg_replace('/[^0-9X]/i', '', (string) ($identifier['identifier'] ?? ''))); - if ($type === 'ISBN_13' && $value !== '') { + $value = BookIsbn::normalize((string) ($identifier['identifier'] ?? '')); + if ($type === 'ISBN_13' && $value !== null && strlen($value) === 13) { $isbn13 = $value; } - if ($type === 'ISBN_10' && $value !== '') { + if ($type === 'ISBN_10' && $value !== null && strlen($value) === 10) { $isbn10 = $value; } } diff --git a/app/Services/IsbnDbService.php b/app/Services/IsbnDbService.php index 51210076b..5a352b80a 100644 --- a/app/Services/IsbnDbService.php +++ b/app/Services/IsbnDbService.php @@ -4,22 +4,27 @@ declare(strict_types=1); namespace App\Services; +use App\Exceptions\BookProviderException; +use App\Support\BookIsbn; use Carbon\Carbon; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use JsonException; use Psr\Http\Message\ResponseInterface; class IsbnDbService { + public const COOLDOWN_CACHE_KEY = 'isbndb_provider_cooldown'; + protected const API_URL = 'https://api2.isbndb.com'; protected Client $client; protected string $apiKey; - protected int $pageSize = 5; + protected int $pageSize = 10; public function __construct(?Client $client = null, ?string $apiKey = null) { @@ -45,9 +50,9 @@ class IsbnDbService /** * @return array<string, mixed>|null */ - public function searchBook(string $query): ?array + public function searchBook(string $query, ?string $author = null, bool $shouldMatchAll = true): ?array { - $books = $this->searchBooks($query); + $books = $this->searchBooks($query, $author, $shouldMatchAll); return $books[0] ?? null; } @@ -55,26 +60,46 @@ class IsbnDbService /** * @return array<int, array<string, mixed>> */ - public function searchBooks(string $query): array + public function searchBooks(string $query, ?string $author = null, bool $shouldMatchAll = true): array { $query = trim($query); + $author = $author !== null ? trim($author) : null; if ($query === '' || ! $this->isConfigured()) { return []; } - $cacheKey = 'isbndb_search_'.md5($query.'_'.$this->pageSize); + if (Cache::has(self::COOLDOWN_CACHE_KEY)) { + throw new BookProviderException('isbndb', 'ISBNdb is temporarily cooling down after a failed request.'); + } + + $cacheKey = 'isbndb_search_'.md5(implode('|', [ + mb_strtolower((string) preg_replace('/\s+/', ' ', $query)), + mb_strtolower((string) preg_replace('/\s+/', ' ', (string) $author)), + $shouldMatchAll ? 'all' : 'any', + (string) $this->pageSize, + ])); $cached = Cache::get($cacheKey); if (is_array($cached)) { return $cached; } - $response = $this->request('/books/'.rawurlencode($query), [ - 'shouldMatchAll' => true, + $requestQuery = [ + 'shouldMatchAll' => $shouldMatchAll ? 'true' : 'false', 'pageSize' => $this->pageSize, - ]); + 'column' => 'title', + ]; + + $response = $this->request('/books/'.rawurlencode($query), $requestQuery); $books = $response['data'] ?? null; - if (! is_array($books) || $books === []) { + if (! is_array($books)) { + Cache::put(self::COOLDOWN_CACHE_KEY, true, now()->addMinutes(5)); + + throw new BookProviderException('isbndb', 'ISBNdb search response did not contain a data array.', 200, 300); + } + if ($books === []) { + Cache::put($cacheKey, [], now()->addHours(6)); + return []; } @@ -95,11 +120,15 @@ class IsbnDbService */ public function findByIsbn(string $isbn): ?array { - $isbn = $this->normalizeIsbn($isbn); - if ($isbn === '' || ! $this->isConfigured()) { + $isbn = BookIsbn::normalize($isbn); + if ($isbn === null || ! $this->isConfigured()) { return null; } + if (Cache::has(self::COOLDOWN_CACHE_KEY)) { + throw new BookProviderException('isbndb', 'ISBNdb is temporarily cooling down after a failed request.'); + } + $cacheKey = 'isbndb_isbn_'.$isbn; $cached = Cache::get($cacheKey); if (is_array($cached)) { @@ -109,7 +138,9 @@ class IsbnDbService $response = $this->request('/book/'.rawurlencode($isbn)); $bookRaw = $response['book'] ?? null; if (! is_array($bookRaw) || $bookRaw === []) { - return null; + Cache::put(self::COOLDOWN_CACHE_KEY, true, now()->addMinutes(5)); + + throw new BookProviderException('isbndb', 'ISBNdb ISBN response did not contain a book object.', 200, 300); } $book = $this->normalizeBookResult($bookRaw); @@ -130,6 +161,7 @@ class IsbnDbService try { $response = $this->client->get($path, [ + 'http_errors' => false, 'headers' => [ 'Authorization' => $this->apiKey, ], @@ -143,25 +175,50 @@ class IsbnDbService return null; } - if ($statusCode === 401 || $statusCode === 429) { - Log::warning("ISBNdb request failed with status {$statusCode}"); - - return null; - } - if ($statusCode !== 200) { - Log::warning("ISBNdb request returned status {$statusCode}"); + $retryAfter = $this->retryAfterSeconds($response); + if ($statusCode === 429 || $statusCode >= 500) { + $retryAfter ??= 300; + Cache::put(self::COOLDOWN_CACHE_KEY, true, now()->addSeconds($retryAfter)); + } - return null; + throw new BookProviderException( + 'isbndb', + "ISBNdb request returned status {$statusCode}.", + $statusCode, + $retryAfter, + ); } - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); - return is_array($data) ? $data : null; + if (! is_array($data)) { + throw new BookProviderException('isbndb', 'ISBNdb returned an invalid response payload.', $statusCode); + } + + return $data; + } catch (BookProviderException $exception) { + throw $exception; + } catch (JsonException $exception) { + Cache::put(self::COOLDOWN_CACHE_KEY, true, now()->addMinutes(5)); + + throw new BookProviderException( + 'isbndb', + 'ISBNdb returned malformed JSON.', + 200, + 300, + $exception, + ); } catch (GuzzleException $e) { - Log::error('ISBNdb API request error: '.$e->getMessage()); + Cache::put(self::COOLDOWN_CACHE_KEY, true, now()->addMinutes(5)); - return null; + throw new BookProviderException( + 'isbndb', + 'ISBNdb API request error: '.$e->getMessage(), + null, + 300, + $e, + ); } } @@ -171,12 +228,18 @@ class IsbnDbService */ private function normalizeBookResult(array $book): array { - $isbn13 = isset($book['isbn13']) ? $this->normalizeIsbn((string) $book['isbn13']) : ''; - if ($isbn13 === '' && isset($book['isbn'])) { - $isbn13 = $this->normalizeIsbn((string) $book['isbn']); + $isbn13 = isset($book['isbn13']) ? BookIsbn::normalize((string) $book['isbn13']) : null; + if ($isbn13 === null && isset($book['isbn'])) { + $isbn13 = BookIsbn::normalize((string) $book['isbn']); + } + if ($isbn13 !== null && strlen($isbn13) === 10) { + $isbn13 = BookIsbn::toIsbn13($isbn13); } - $isbn10 = isset($book['isbn10']) ? $this->normalizeIsbn((string) $book['isbn10']) : ''; + $isbn10 = isset($book['isbn10']) ? BookIsbn::normalize((string) $book['isbn10']) : null; + if ($isbn10 !== null && strlen($isbn10) === 13) { + $isbn10 = BookIsbn::toIsbn10($isbn10); + } $authors = is_array($book['authors'] ?? null) ? array_values(array_filter(array_map('strval', $book['authors']))) @@ -185,7 +248,7 @@ class IsbnDbService ? array_values(array_filter(array_map('strval', $book['subjects']))) : []; - $identifier = $isbn13 !== '' ? $isbn13 : ($isbn10 !== '' ? $isbn10 : md5((string) json_encode($book))); + $identifier = $isbn13 ?? $isbn10 ?? md5((string) json_encode($book)); $coverUrl = (string) ($book['image'] ?? $book['image_original'] ?? ''); $overview = trim((string) ($book['synopsis'] ?? $book['overview'] ?? $book['excerpt'] ?? '')); @@ -193,8 +256,8 @@ class IsbnDbService 'title' => (string) ($book['title'] ?? ''), 'author' => implode(', ', $authors), 'asin' => 'isbndb:'.$identifier, - 'isbn' => $isbn13 !== '' ? $isbn13 : null, - 'ean' => $isbn10 !== '' ? $isbn10 : null, + 'isbn' => $isbn13, + 'ean' => $isbn10, 'url' => '', 'salesrank' => '', 'publisher' => (string) ($book['publisher'] ?? ''), @@ -220,11 +283,6 @@ class IsbnDbService } } - private function normalizeIsbn(string $isbn): string - { - return strtoupper((string) preg_replace('/[^0-9X]/i', '', $isbn)); - } - private function logRateLimitHeaders(ResponseInterface $response): void { $rateLimit = $response->getHeaderLine('ratelimit'); @@ -251,4 +309,19 @@ class IsbnDbService 'ratelimit_policy' => $rateLimitPolicy, ]); } + + private function retryAfterSeconds(ResponseInterface $response): ?int + { + $retryAfter = trim($response->getHeaderLine('Retry-After')); + if ($retryAfter === '') { + return null; + } + if (ctype_digit($retryAfter)) { + return max(1, (int) $retryAfter); + } + + $retryAt = strtotime($retryAfter); + + return $retryAt === false ? null : max(1, $retryAt - time()); + } } diff --git a/app/Services/ItunesService.php b/app/Services/ItunesService.php index 926735b25..f73ce2581 100644 --- a/app/Services/ItunesService.php +++ b/app/Services/ItunesService.php @@ -29,6 +29,8 @@ class ItunesService protected string $country = 'US'; + private bool $lastRequestFailed = false; + public function __construct() { $this->client = new Client([ @@ -62,6 +64,11 @@ class ItunesService return $this; } + public function lastRequestFailed(): bool + { + return $this->lastRequestFailed; + } + /** * Search for music albums. * @@ -240,6 +247,7 @@ class ItunesService */ protected function search(string $term, string $entity, string $media): ?array { + $this->lastRequestFailed = false; $term = trim($term); if (empty($term)) { return null; @@ -265,6 +273,7 @@ class ItunesService $statusCode = $response->getStatusCode(); if ($statusCode !== 200) { + $this->lastRequestFailed = true; Log::warning("iTunes API search returned status {$statusCode}"); return null; @@ -273,6 +282,8 @@ class ItunesService $data = json_decode($response->getBody()->getContents(), true); if (! isset($data['results'])) { + $this->lastRequestFailed = true; + return null; } @@ -281,6 +292,7 @@ class ItunesService return $results; } catch (GuzzleException $e) { + $this->lastRequestFailed = true; Log::error('iTunes API search error: '.$e->getMessage()); return null; diff --git a/app/Services/OpenLibraryService.php b/app/Services/OpenLibraryService.php index aac38e4d0..a2a28c326 100644 --- a/app/Services/OpenLibraryService.php +++ b/app/Services/OpenLibraryService.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Services; +use App\Exceptions\BookProviderException; +use App\Support\BookIsbn; use Carbon\Carbon; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Log; +use JsonException; class OpenLibraryService { @@ -38,8 +40,8 @@ class OpenLibraryService */ public function findByIsbn(string $isbn): ?array { - $isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', $isbn)); - if ($isbn === '') { + $isbn = BookIsbn::normalize($isbn); + if ($isbn === null) { return null; } @@ -50,24 +52,33 @@ class OpenLibraryService } try { - $response = $this->client->get(self::ISBN_URL.rawurlencode($isbn).'.json'); - if ($response->getStatusCode() !== 200) { + $response = $this->client->get(self::ISBN_URL.rawurlencode($isbn).'.json', ['http_errors' => false]); + if ($response->getStatusCode() === 404) { return null; } + if ($response->getStatusCode() !== 200) { + throw new BookProviderException( + 'open_library', + 'Open Library ISBN request failed.', + $response->getStatusCode(), + ); + } - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); if (! is_array($data)) { - return null; + throw new BookProviderException('open_library', 'Open Library returned an invalid ISBN payload.', 200); } $book = $this->normalizeIsbnResult($data, $isbn); Cache::put($cacheKey, $book, now()->addHours(24)); return $book; + } catch (BookProviderException $exception) { + throw $exception; + } catch (JsonException $exception) { + throw new BookProviderException('open_library', 'Open Library returned malformed JSON.', 200, null, $exception); } catch (GuzzleException $e) { - Log::error('OpenLibrary ISBN lookup error: '.$e->getMessage()); - - return null; + throw new BookProviderException('open_library', 'Open Library ISBN lookup error: '.$e->getMessage(), null, null, $e); } } @@ -99,18 +110,26 @@ class OpenLibraryService try { $response = $this->client->get(self::SEARCH_URL, [ + 'http_errors' => false, 'query' => [ 'q' => $query, 'limit' => $this->limit, ], ]); - if ($response->getStatusCode() !== 200) { + if ($response->getStatusCode() === 404) { return []; } + if ($response->getStatusCode() !== 200) { + throw new BookProviderException( + 'open_library', + 'Open Library search request failed.', + $response->getStatusCode(), + ); + } - $data = json_decode($response->getBody()->getContents(), true); + $data = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); if (! is_array($data['docs'] ?? null)) { - return []; + throw new BookProviderException('open_library', 'Open Library returned an invalid search payload.', 200); } $results = []; @@ -123,10 +142,12 @@ class OpenLibraryService Cache::put($cacheKey, $results, now()->addHours(12)); return $results; + } catch (BookProviderException $exception) { + throw $exception; + } catch (JsonException $exception) { + throw new BookProviderException('open_library', 'Open Library returned malformed JSON.', 200, null, $exception); } catch (GuzzleException $e) { - Log::error('OpenLibrary search error: '.$e->getMessage()); - - return []; + throw new BookProviderException('open_library', 'Open Library search error: '.$e->getMessage(), null, null, $e); } } @@ -180,8 +201,8 @@ class OpenLibraryService $isbn10 = null; if (is_array($doc['isbn'] ?? null)) { foreach ($doc['isbn'] as $isbn) { - $normalized = strtoupper((string) preg_replace('/[^0-9X]/i', '', (string) $isbn)); - if ($normalized === '') { + $normalized = BookIsbn::normalize((string) $isbn); + if ($normalized === null) { continue; } if (strlen($normalized) === 13 && $isbn13 === null) { diff --git a/app/Services/Runners/PostProcessRunner.php b/app/Services/Runners/PostProcessRunner.php index 5a1c02831..d1e64600b 100644 --- a/app/Services/Runners/PostProcessRunner.php +++ b/app/Services/Runners/PostProcessRunner.php @@ -155,6 +155,7 @@ class PostProcessRunner extends BaseRunner private function getBooksBuckets(): array { $bucketExpr = $this->guidBucketExpression(); + $pendingCondition = $this->bookPendingCondition(); return DB::select(' SELECT DISTINCT '.$bucketExpr.' AS id @@ -164,7 +165,7 @@ class PostProcessRunner extends BaseRunner OR categories_id = '.Category::MUSIC_AUDIOBOOK.' ) AND ( - bookinfo_id IS NULL + '.$pendingCondition.' OR searchname LIKE "N:/NZB%" OR searchname LIKE "N_NZB_%" OR name LIKE "N:/NZB%" @@ -600,6 +601,8 @@ class PostProcessRunner extends BaseRunner return; } + $pendingCondition = $this->bookPendingCondition(); + $checkSql = ' SELECT id FROM releases @@ -608,7 +611,7 @@ class PostProcessRunner extends BaseRunner OR categories_id = 3030 ) AND ( - bookinfo_id IS NULL + '.$pendingCondition.' OR searchname LIKE "N:/NZB%" OR searchname LIKE "N_NZB_%" OR name LIKE "N:/NZB%" @@ -630,7 +633,7 @@ class PostProcessRunner extends BaseRunner OR categories_id = 3030 ) AND ( - bookinfo_id IS NULL + '.$pendingCondition.' OR searchname LIKE "N:/NZB%" OR searchname LIKE "N_NZB_%" OR name LIKE "N:/NZB%" @@ -643,6 +646,13 @@ class PostProcessRunner extends BaseRunner $this->runPostProcess($queue, $maxProcesses, 'books', 'books postprocessing'); } + private function bookPendingCondition(): string + { + return (int) Settings::settingValue('lookupbooks') === 2 + ? '(bookinfo_id IS NULL AND isrenamed = 1)' + : 'bookinfo_id IS NULL'; + } + public function processMusic(): void { if ((int) Settings::settingValue('lookupmusic') <= 0) { diff --git a/app/Support/BookIsbn.php b/app/Support/BookIsbn.php new file mode 100644 index 000000000..7187dc21a --- /dev/null +++ b/app/Support/BookIsbn.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +namespace App\Support; + +final class BookIsbn +{ + public static function normalize(?string $value): ?string + { + if ($value === null) { + return null; + } + + $isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', $value)); + + return match (strlen($isbn)) { + 10 => self::isValidIsbn10($isbn) ? $isbn : null, + 13 => self::isValidIsbn13($isbn) ? $isbn : null, + default => null, + }; + } + + public static function equivalent(?string $left, ?string $right): bool + { + $leftIdentifiers = self::equivalents($left); + $rightIdentifiers = self::equivalents($right); + + return $leftIdentifiers !== [] && array_intersect($leftIdentifiers, $rightIdentifiers) !== []; + } + + /** + * @return list<string> + */ + public static function equivalents(?string $value): array + { + $isbn = self::normalize($value); + if ($isbn === null) { + return []; + } + + $identifiers = [$isbn]; + $converted = strlen($isbn) === 10 ? self::toIsbn13($isbn) : self::toIsbn10($isbn); + if ($converted !== null) { + $identifiers[] = $converted; + } + + sort($identifiers); + + return array_values(array_unique($identifiers)); + } + + public static function toIsbn13(?string $value): ?string + { + $isbn10 = self::normalize($value); + if ($isbn10 === null || strlen($isbn10) !== 10) { + return null; + } + + $body = '978'.substr($isbn10, 0, 9); + $sum = 0; + for ($index = 0; $index < 12; $index++) { + $sum += ((int) $body[$index]) * ($index % 2 === 0 ? 1 : 3); + } + + return $body.(string) ((10 - ($sum % 10)) % 10); + } + + public static function toIsbn10(?string $value): ?string + { + $isbn13 = self::normalize($value); + if ($isbn13 === null || strlen($isbn13) !== 13 || ! str_starts_with($isbn13, '978')) { + return null; + } + + $body = substr($isbn13, 3, 9); + $sum = 0; + for ($index = 0; $index < 9; $index++) { + $sum += ((int) $body[$index]) * (10 - $index); + } + $check = (11 - ($sum % 11)) % 11; + + return $body.($check === 10 ? 'X' : (string) $check); + } + + private static function isValidIsbn10(string $isbn): bool + { + if (preg_match('/^\d{9}[\dX]$/', $isbn) !== 1) { + return false; + } + + $sum = 0; + for ($index = 0; $index < 10; $index++) { + $digit = $isbn[$index] === 'X' ? 10 : (int) $isbn[$index]; + $sum += $digit * (10 - $index); + } + + return $sum % 11 === 0; + } + + private static function isValidIsbn13(string $isbn): bool + { + if (preg_match('/^97[89]\d{10}$/', $isbn) !== 1) { + return false; + } + + $sum = 0; + for ($index = 0; $index < 13; $index++) { + $sum += ((int) $isbn[$index]) * ($index % 2 === 0 ? 1 : 3); + } + + return $sum % 10 === 0; + } +} diff --git a/app/Support/BookMatchScorer.php b/app/Support/BookMatchScorer.php index f57f45d34..9e24a878a 100644 --- a/app/Support/BookMatchScorer.php +++ b/app/Support/BookMatchScorer.php @@ -14,34 +14,32 @@ class BookMatchScorer */ public function score(array $candidate, BookParseResult $parsed): float { - $candidateIsbn = $this->normalizeIsbn((string) ($candidate['isbn'] ?? '')); - $parsedIsbn = $this->normalizeIsbn((string) ($parsed->isbn ?? '')); - if ($parsedIsbn !== '' && $candidateIsbn !== '' && $parsedIsbn === $candidateIsbn) { + $parsedIsbn = BookIsbn::normalize($parsed->isbn); + $candidateIdentifiers = $this->candidateIdentifiers($candidate); + if ($parsedIsbn !== null && $candidateIdentifiers !== []) { + foreach ($candidateIdentifiers as $candidateIdentifier) { + if (! BookIsbn::equivalent($parsedIsbn, $candidateIdentifier)) { + return 0.0; + } + } + return 1.0; } - $titleScore = $this->titleSimilarity($parsed->title, (string) ($candidate['title'] ?? '')); - $authorScore = $parsed->hasAuthor() - ? $this->similarity((string) $parsed->author, (string) ($candidate['author'] ?? '')) - : 0.0; - $yearScore = $this->yearScore($parsed->year, (string) ($candidate['publishdate'] ?? '')); - $publisherScore = ! empty($candidate['publisher']) ? 1.0 : 0.0; - $coverScore = (int) ($candidate['cover'] ?? 0) === 1 ? 1.0 : 0.0; - - if ($parsed->hasAuthor()) { - return (0.45 * $titleScore) - + (0.30 * $authorScore) - + (0.10 * $yearScore) - + (0.08 * $publisherScore) - + (0.07 * $coverScore); + if ($this->hasStructuralConflict($parsed, (string) ($candidate['title'] ?? ''))) { + return 0.0; } - // When the release has no reliable author, avoid over-trusting - // "has cover/publisher" signals for weak title matches. - return (0.80 * $titleScore) - + (0.10 * $yearScore) - + (0.05 * $publisherScore) - + (0.05 * $coverScore); + $titleScore = $this->titleScore($parsed->title, (string) ($candidate['title'] ?? '')); + $authorScore = $parsed->hasAuthor() + ? $this->authorScore((string) $parsed->author, (string) ($candidate['author'] ?? '')) + : 0.0; + + if ($parsed->hasAuthor()) { + return (0.65 * $titleScore) + (0.35 * $authorScore); + } + + return $titleScore; } public function scoreBookInfo(BookInfo $book, BookParseResult $parsed): float @@ -50,14 +48,28 @@ class BookMatchScorer 'title' => $book->title, 'author' => $book->author, 'isbn' => $book->isbn, + 'ean' => $book->ean, 'publishdate' => $book->publishdate, 'publisher' => $book->publisher, 'cover' => $book->cover ? 1 : 0, ], $parsed); } - private function titleSimilarity(string $left, string $right): float + public function titleScore(string $left, string $right): float { + $leftVariants = $this->titleVariants($left); + $rightVariants = $this->titleVariants($right); + foreach ($leftVariants as $leftVariant) { + foreach ($rightVariants as $rightVariant) { + if ($leftVariant !== '' && $leftVariant === $rightVariant) { + return $leftVariant === $this->normalizeText($left) + && $rightVariant === $this->normalizeText($right) + ? 1.0 + : 0.97; + } + } + } + $left = $this->normalizeText($left); $right = $this->normalizeText($right); if ($left === '' || $right === '') { @@ -74,52 +86,62 @@ class BookMatchScorer return 0.0; } - $shared = array_intersect($leftWords, $rightWords); - $total = max(count($leftWords), count($rightWords)); - $jaccardScore = count($shared) / $total; + $shared = array_intersect(array_unique($leftWords), array_unique($rightWords)); + $tokenScore = (2 * count($shared)) / (count(array_unique($leftWords)) + count(array_unique($rightWords))); similar_text($left, $right, $percent); $simTextScore = max(0.0, min(1.0, $percent / 100)); - $lengthRatio = min(mb_strlen($left), mb_strlen($right)) / max(mb_strlen($left), mb_strlen($right)); - $lengthPenalty = $lengthRatio < 0.5 ? 0.7 : 1.0; - - return min(1.0, ((0.5 * $jaccardScore) + (0.5 * $simTextScore)) * $lengthPenalty); + return min(1.0, max($tokenScore, $simTextScore)); } - private function similarity(string $left, string $right): float + public function authorScore(string $left, string $right): float { - $left = $this->normalizeText($left); - $right = $this->normalizeText($right); - if ($left === '' || $right === '') { + $leftTokens = $this->tokens($left); + $rightTokens = $this->tokens($right); + if ($leftTokens === [] || $rightTokens === []) { return 0.0; } - similar_text($left, $right, $percent); + $leftCoverage = $this->authorTokenCoverage($leftTokens, $rightTokens); + $rightCoverage = $this->authorTokenCoverage($rightTokens, $leftTokens); - return max(0.0, min(1.0, $percent / 100)); + return ($leftCoverage + $rightCoverage) / 2; } - private function yearScore(?int $parsedYear, string $candidatePublishDate): float + public function hasStructuralConflict(BookParseResult $parsed, string $candidateTitle): bool { - if ($parsedYear === null) { - return 0.5; + $parsedMarkers = $this->structuralMarkers($parsed->rawName.' '.$parsed->title); + $candidateMarkers = $this->structuralMarkers($candidateTitle); + foreach ($parsedMarkers as $type => $number) { + if (isset($candidateMarkers[$type]) && $candidateMarkers[$type] !== $number) { + return true; + } } - if (preg_match('/\b(19|20)\d{2}\b/', $candidatePublishDate, $matches) !== 1) { - return 0.0; - } + return false; + } - $candidateYear = (int) $matches[0]; - if ($parsedYear === $candidateYear) { - return 1.0; - } + public function meaningfulTitleTokenCount(string $title): int + { + $stopWords = ['a', 'an', 'and', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'with']; - if (abs($parsedYear - $candidateYear) <= 1) { - return 0.6; - } + return count(array_filter( + $this->tokens($title), + static fn (string $token): bool => mb_strlen($token) > 1 && ! in_array($token, $stopWords, true) + )); + } - return 0.0; + /** + * @param array<string, mixed> $candidate + */ + public function workKey(array $candidate): string + { + $authorTokens = $this->tokens((string) ($candidate['author'] ?? '')); + sort($authorTokens); + + return $this->normalizeText((string) ($candidate['title'] ?? '')).'|' + .implode(' ', $authorTokens); } private function normalizeText(string $value): string @@ -132,8 +154,82 @@ class BookMatchScorer return trim($value); } - private function normalizeIsbn(string $value): string + /** + * @return list<string> + */ + private function titleVariants(string $value): array { - return strtoupper((string) preg_replace('/[^0-9X]/i', '', $value)); + $variants = [$this->normalizeText($value)]; + $parts = preg_split('/\s+(?::|-)\s+|:\s*/u', $value, 2); + if (is_array($parts) && count($parts) === 2) { + $variants[] = $this->normalizeText($parts[0]); + } + + return array_values(array_unique(array_filter($variants))); + } + + /** + * @param array<string, mixed> $candidate + * @return list<string> + */ + private function candidateIdentifiers(array $candidate): array + { + $identifiers = []; + foreach (['isbn', 'ean'] as $field) { + $isbn = BookIsbn::normalize(isset($candidate[$field]) ? (string) $candidate[$field] : null); + if ($isbn !== null) { + $identifiers[] = $isbn; + } + } + + return array_values(array_unique($identifiers)); + } + + /** + * @return list<string> + */ + private function tokens(string $value): array + { + $normalized = $this->normalizeText($value); + + return $normalized === '' ? [] : array_values(array_unique(explode(' ', $normalized))); + } + + /** + * @param list<string> $needles + * @param list<string> $haystack + */ + private function authorTokenCoverage(array $needles, array $haystack): float + { + $matched = 0; + foreach ($needles as $needle) { + foreach ($haystack as $candidate) { + if ($needle === $candidate + || (mb_strlen($needle) === 1 && str_starts_with($candidate, $needle)) + || (mb_strlen($candidate) === 1 && str_starts_with($needle, $candidate))) { + $matched++; + + break; + } + } + } + + return $matched / count($needles); + } + + /** + * @return array<string, int> + */ + private function structuralMarkers(string $value): array + { + $markers = []; + if (preg_match('/\b(?:book|series|vol(?:ume)?)\.?\s*#?\s*(\d{1,3})\b/i', $value, $match) === 1) { + $markers['volume'] = (int) $match[1]; + } + if (preg_match('/\b(\d{1,3})(?:st|nd|rd|th)?\s+(?:edition|ed\.?)\b/i', $value, $match) === 1) { + $markers['edition'] = (int) $match[1]; + } + + return $markers; } } diff --git a/database/migrations/2026_08_13_133021_add_isbn_indexes_to_bookinfo_table.php b/database/migrations/2026_08_13_133021_add_isbn_indexes_to_bookinfo_table.php new file mode 100644 index 000000000..5aa42c73f --- /dev/null +++ b/database/migrations/2026_08_13_133021_add_isbn_indexes_to_bookinfo_table.php @@ -0,0 +1,50 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + private const ISBN_INDEX = 'ix_bookinfo_isbn'; + + private const EAN_INDEX = 'ix_bookinfo_ean'; + + /** + * Run the migrations. + */ + public function up(): void + { + if (! Schema::hasTable('bookinfo')) { + return; + } + + Schema::table('bookinfo', static function (Blueprint $table): void { + if (! Schema::hasIndex('bookinfo', self::ISBN_INDEX)) { + $table->index('isbn', self::ISBN_INDEX); + } + if (! Schema::hasIndex('bookinfo', self::EAN_INDEX)) { + $table->index('ean', self::EAN_INDEX); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('bookinfo')) { + return; + } + + Schema::table('bookinfo', static function (Blueprint $table): void { + if (Schema::hasIndex('bookinfo', self::ISBN_INDEX)) { + $table->dropIndex(self::ISBN_INDEX); + } + if (Schema::hasIndex('bookinfo', self::EAN_INDEX)) { + $table->dropIndex(self::EAN_INDEX); + } + }); + } +}; diff --git a/database/schema/mariadb-schema.sql b/database/schema/mariadb-schema.sql index c32dcd4dc..a8142d1eb 100644 --- a/database/schema/mariadb-schema.sql +++ b/database/schema/mariadb-schema.sql @@ -119,6 +119,8 @@ CREATE TABLE `bookinfo` ( `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ix_bookinfo_asin` (`asin`), + KEY `ix_bookinfo_isbn` (`isbn`), + KEY `ix_bookinfo_ean` (`ean`), FULLTEXT KEY `ix_bookinfo_author_title_ft` (`author`,`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci; diff --git a/database/schema/mysql-schema.sql b/database/schema/mysql-schema.sql index c32dcd4dc..a8142d1eb 100644 --- a/database/schema/mysql-schema.sql +++ b/database/schema/mysql-schema.sql @@ -119,6 +119,8 @@ CREATE TABLE `bookinfo` ( `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ix_bookinfo_asin` (`asin`), + KEY `ix_bookinfo_isbn` (`isbn`), + KEY `ix_bookinfo_ean` (`ean`), FULLTEXT KEY `ix_bookinfo_author_title_ft` (`author`,`title`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci; diff --git a/tests/Feature/BookInfoIsbnIndexesMigrationTest.php b/tests/Feature/BookInfoIsbnIndexesMigrationTest.php new file mode 100644 index 000000000..9ff389d7c --- /dev/null +++ b/tests/Feature/BookInfoIsbnIndexesMigrationTest.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Feature; + +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use PDO; +use Tests\TestCase; + +class BookInfoIsbnIndexesMigrationTest extends TestCase +{ + private string $databasePath; + + /** + * @var array<string, string|false> + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-bookinfo-isbn-indexes.sqlite'; + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES ('categorizeforeign', '0'), ('catwebdl', '1')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $this->databasePath, + ]); + + Schema::create('bookinfo', function (Blueprint $table): void { + $table->increments('id'); + $table->string('isbn')->nullable(); + $table->string('ean')->nullable(); + }); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_it_adds_and_removes_isbn_indexes_idempotently(): void + { + $migration = require database_path('migrations/2026_08_13_133021_add_isbn_indexes_to_bookinfo_table.php'); + + $migration->up(); + $migration->up(); + + $this->assertTrue(Schema::hasIndex('bookinfo', 'ix_bookinfo_isbn')); + $this->assertTrue(Schema::hasIndex('bookinfo', 'ix_bookinfo_ean')); + + $migration->down(); + + $this->assertFalse(Schema::hasIndex('bookinfo', 'ix_bookinfo_isbn')); + $this->assertFalse(Schema::hasIndex('bookinfo', 'ix_bookinfo_ean')); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +} diff --git a/tests/Feature/BookServiceMatchingTest.php b/tests/Feature/BookServiceMatchingTest.php new file mode 100644 index 000000000..9878b2b20 --- /dev/null +++ b/tests/Feature/BookServiceMatchingTest.php @@ -0,0 +1,433 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Feature; + +use App\Exceptions\BookProviderException; +use App\Models\BookInfo; +use App\Services\BookService; +use App\Services\GoogleBooksService; +use App\Services\IsbnDbService; +use App\Services\ItunesService; +use App\Services\OpenLibraryService; +use App\Services\ReleaseImageService; +use App\Support\BookMatchScorer; +use App\Support\Data\BookParseResult; +use App\Support\Data\ImageProcessingResult; +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Mockery; +use PDO; +use Tests\TestCase; + +class BookServiceMatchingTest extends TestCase +{ + private string $databasePath; + + /** + * @var array<string, string|false> + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-book-service-matching.sqlite'; + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES ('maxbooksprocessed', '50'), ('amazonsleep', '0'), ('lookupbooks', '1')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + config([ + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $this->databasePath, + ]); + DB::purge(); + DB::reconnect(); + + Schema::create('bookinfo', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title'); + $table->string('author')->default(''); + $table->string('asin')->nullable()->unique(); + $table->string('isbn')->nullable()->index(); + $table->string('ean')->nullable()->index(); + $table->string('url')->nullable(); + $table->unsignedInteger('salesrank')->nullable(); + $table->string('publisher')->nullable(); + $table->dateTime('publishdate')->nullable(); + $table->string('pages')->nullable(); + $table->text('overview')->nullable(); + $table->string('genre')->default(''); + $table->boolean('cover')->default(false); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_rejected_isbndb_candidates_fall_through_to_google_books(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('searchBooks')->once()->andReturn([ + $this->candidate('Unrelated Cookbook', 'Different Author', 'isbndb:wrong'), + ]); + + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldReceive('searchBooks')->once()->andReturn([ + $this->candidate('Clean Code', 'Robert C. Martin', 'googlebooks:clean-code'), + ]); + + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldNotReceive('searchBooks'); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldNotReceive('findEbooks'); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = $this->cleanCodeParseResult(); + + $bookId = $service->updateBookInfo('Robert C Martin Clean Code'); + + $this->assertIsInt($bookId); + $this->assertGreaterThan(0, $bookId); + $this->assertSame('Clean Code', BookInfo::query()->findOrFail($bookId)->title); + } + + public function test_accepted_isbndb_candidate_stops_the_provider_cascade(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('searchBooks')->once()->andReturn([ + $this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:clean-code'), + ]); + + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldNotReceive('searchBooks'); + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldNotReceive('searchBooks'); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldNotReceive('findEbooks'); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = $this->cleanCodeParseResult(); + + $this->assertGreaterThan(0, $service->updateBookInfo('Robert C Martin Clean Code')); + } + + public function test_duplicate_editions_of_the_same_work_do_not_create_false_ambiguity(): void + { + $first = $this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:edition-one'); + $second = $this->candidate('Clean Code', 'Martin, Robert C.', 'isbndb:edition-two'); + $second['publisher'] = 'Prentice Hall'; + + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('searchBooks')->once()->andReturn([$first, $second]); + + $service = $this->makeService( + $isbnDb, + Mockery::mock(GoogleBooksService::class), + Mockery::mock(OpenLibraryService::class), + Mockery::mock(ItunesService::class), + ); + $service->parsedBookResult = $this->cleanCodeParseResult(); + + $this->assertGreaterThan(0, $service->updateBookInfo('Robert C Martin Clean Code')); + } + + public function test_close_distinct_runner_up_prevents_a_premature_match(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('searchBooks')->once()->andReturn([ + $this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:clean-code'), + $this->candidate('Clean Coder', 'Robert C. Martin', 'isbndb:clean-coder'), + ]); + + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldReceive('searchBooks')->once()->andReturn([]); + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldReceive('searchBooks')->once()->andReturn([]); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldReceive('findEbooks')->once()->andReturn([]); + $itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse(); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = $this->cleanCodeParseResult(); + + $this->assertSame(-2, $service->updateBookInfo('Robert C Martin Clean Code')); + } + + public function test_ambiguous_author_dash_title_parse_retries_as_a_title_only_search(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('searchBooks') + ->once() + ->with('From Journeyman to Master', 'The Pragmatic Programmer', true) + ->andReturn([]); + $isbnDb->shouldReceive('searchBooks') + ->once() + ->with('The Pragmatic Programmer - From Journeyman to Master', null, true) + ->andReturn([ + $this->candidate( + 'The Pragmatic Programmer: From Journeyman to Master', + 'Andrew Hunt, David Thomas', + 'isbndb:pragmatic-programmer', + ), + ]); + + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldNotReceive('searchBooks'); + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldNotReceive('searchBooks'); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldNotReceive('findEbooks'); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = $service->parseReleaseName( + 'The Pragmatic Programmer - From Journeyman to Master EPUB' + ); + + $this->assertGreaterThan(0, $service->updateBookInfo($service->parsedBookResult->searchQuery())); + } + + public function test_transient_provider_failure_leaves_the_match_pending(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('searchBooks')->once()->andThrow( + new BookProviderException('isbndb', 'Unavailable', 429, 300) + ); + + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldReceive('searchBooks')->once()->andReturn([]); + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldReceive('searchBooks')->once()->andReturn([]); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldReceive('findEbooks')->once()->andReturn([]); + $itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse(); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = $this->cleanCodeParseResult(); + + $this->assertNull($service->updateBookInfo('Robert C Martin Clean Code')); + } + + public function test_deterministic_exhaustion_returns_failed_sentinel(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnFalse(); + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldReceive('searchBooks')->once()->andReturn([]); + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldReceive('searchBooks')->once()->andReturn([]); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldReceive('findEbooks')->once()->andReturn([]); + $itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse(); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = $this->cleanCodeParseResult(); + + $this->assertSame(-2, $service->updateBookInfo('Robert C Martin Clean Code')); + } + + public function test_exact_local_isbn_lookup_checks_both_identifier_versions(): void + { + $bookId = DB::table('bookinfo')->insertGetId([ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'asin' => 'existing:clean-code', + 'isbn' => '9780132350884', + 'ean' => '0132350882', + 'genre' => '', + 'cover' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = $this->makeService( + Mockery::mock(IsbnDbService::class), + Mockery::mock(GoogleBooksService::class), + Mockery::mock(OpenLibraryService::class), + Mockery::mock(ItunesService::class), + ); + + $this->assertSame($bookId, $service->getBookInfoByIsbn('0-13-235088-2')?->id); + } + + public function test_equivalent_isbn_deduplication_preserves_metadata_and_failed_cover_state(): void + { + $bookId = DB::table('bookinfo')->insertGetId([ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'asin' => 'legacy:clean-code', + 'isbn' => null, + 'ean' => '0132350882', + 'publisher' => 'Prentice Hall', + 'genre' => 'Programming', + 'cover' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $candidate = $this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:clean-code'); + $candidate['isbn'] = '9780132350884'; + $candidate['publisher'] = ''; + $candidate['genre'] = ''; + $candidate['coverurl'] = 'https://example.com/cover.jpg'; + + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnTrue(); + $isbnDb->shouldReceive('findByIsbn')->once()->with('9780132350884')->andReturn($candidate); + $isbnDb->shouldNotReceive('searchBooks'); + + $service = $this->makeService( + $isbnDb, + Mockery::mock(GoogleBooksService::class), + Mockery::mock(OpenLibraryService::class), + Mockery::mock(ItunesService::class), + ); + $service->parsedBookResult = new BookParseResult( + rawName: 'Clean Code 9780132350884', + title: 'Clean Code', + author: 'Robert C Martin', + isbn: '9780132350884', + ); + + $this->assertSame($bookId, $service->updateBookInfo('Clean Code', '9780132350884')); + + $book = BookInfo::query()->findOrFail($bookId); + $this->assertSame('legacy:clean-code', $book->asin); + $this->assertSame('Prentice Hall', $book->publisher); + $this->assertSame('Programming', $book->genre); + $this->assertFalse((bool) $book->cover); + $this->assertSame(1, BookInfo::query()->count()); + } + + public function test_two_word_unauthored_title_reaches_external_providers_but_is_not_loosely_accepted(): void + { + $isbnDb = Mockery::mock(IsbnDbService::class); + $isbnDb->shouldReceive('isConfigured')->andReturnFalse(); + $google = Mockery::mock(GoogleBooksService::class); + $google->shouldReceive('searchBooks')->once()->andReturn([]); + $openLibrary = Mockery::mock(OpenLibraryService::class); + $openLibrary->shouldReceive('searchBooks')->once()->andReturn([]); + $itunes = Mockery::mock(ItunesService::class); + $itunes->shouldReceive('findEbooks')->once()->andReturn([]); + $itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse(); + + $service = $this->makeService($isbnDb, $google, $openLibrary, $itunes); + $service->parsedBookResult = new BookParseResult( + rawName: 'Atomic Habits EPUB', + title: 'Atomic Habits', + ); + + $this->assertSame(-2, $service->updateBookInfo('Atomic Habits')); + } + + private function makeService( + IsbnDbService $isbnDb, + GoogleBooksService $googleBooks, + OpenLibraryService $openLibrary, + ItunesService $itunes, + ): BookService { + $images = Mockery::mock(ReleaseImageService::class); + $images->shouldReceive('saveRemoteImage') + ->zeroOrMoreTimes() + ->andReturn(ImageProcessingResult::failure('No cover URL.')); + + return new BookService( + isbnDb: $isbnDb, + googleBooks: $googleBooks, + openLibrary: $openLibrary, + itunes: $itunes, + scorer: new BookMatchScorer, + releaseImageService: $images, + ); + } + + private function cleanCodeParseResult(): BookParseResult + { + return new BookParseResult( + rawName: 'Clean Code by Robert C Martin', + title: 'Clean Code', + author: 'Robert C Martin', + year: 2008, + ); + } + + /** + * @return array<string, mixed> + */ + private function candidate(string $title, string $author, string $asin): array + { + return [ + 'title' => $title, + 'author' => $author, + 'asin' => $asin, + 'isbn' => null, + 'ean' => null, + 'url' => '', + 'salesrank' => '', + 'publisher' => '', + 'publishdate' => null, + 'pages' => '', + 'overview' => '', + 'genre' => '', + 'coverurl' => '', + 'cover' => 0, + ]; + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +} diff --git a/tests/Feature/PostProcessRunnerBooksGateTest.php b/tests/Feature/PostProcessRunnerBooksGateTest.php index c75687c98..c7ab2409b 100644 --- a/tests/Feature/PostProcessRunnerBooksGateTest.php +++ b/tests/Feature/PostProcessRunnerBooksGateTest.php @@ -122,6 +122,46 @@ class PostProcessRunnerBooksGateTest extends TestCase $this->assertStringContainsString('artisan postprocess:guid books a', $runner->captured[0]); } + public function test_renamed_only_mode_skips_unrenamed_pending_books(): void + { + DB::table('settings')->where('name', 'lookupbooks')->update(['value' => '2']); + DB::table('releases')->insert([ + 'id' => 2, + 'name' => 'A Plain Unrenamed Book', + 'searchname' => 'A Plain Unrenamed Book', + 'groups_id' => 1, + 'size' => 1, + 'postdate' => now(), + 'adddate' => now(), + 'guid' => str_repeat('b', 40), + 'leftguid' => 'b', + 'fromname' => 'poster@example.com', + 'categories_id' => Category::BOOKS_EBOOK, + 'bookinfo_id' => null, + 'isrenamed' => 0, + ]); + + $runner = new class extends PostProcessRunner + { + public array $captured = []; + + public function headerNone(): void {} + + protected function headerStart(string $workType, int $count, int $maxProcesses): void {} + + protected function executeCommand(string $command): string + { + $this->captured[] = $command; + + return ''; + } + }; + + $runner->processBooks(); + + $this->assertSame([], $runner->captured); + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { @@ -159,6 +199,7 @@ class PostProcessRunnerBooksGateTest extends TestCase $table->string('fromname')->nullable(); $table->integer('categories_id')->default(Category::OTHER_MISC); $table->integer('bookinfo_id')->nullable(); + $table->tinyInteger('isrenamed')->default(0); }); } } diff --git a/tests/Unit/Services/BookServiceTest.php b/tests/Unit/Services/BookServiceTest.php index d83829771..6b4cd4931 100644 --- a/tests/Unit/Services/BookServiceTest.php +++ b/tests/Unit/Services/BookServiceTest.php @@ -32,6 +32,39 @@ class BookServiceTest extends TestCase $this->assertSame('9780321125217', $parsed->isbn); } + public function test_parse_release_name_parses_title_by_author_in_conventional_order(): void + { + $parsed = $this->makeService()->parseReleaseName('Clean Code by Robert C. Martin 2008 EPUB'); + + $this->assertSame('Clean Code', $parsed->title); + $this->assertSame('Robert C Martin', $parsed->author); + $this->assertSame(2008, $parsed->year); + } + + public function test_parse_release_name_captures_year_before_removing_release_noise(): void + { + $parsed = $this->makeService()->parseReleaseName('The Pragmatic Programmer 1999 EPUB'); + + $this->assertSame('The Pragmatic Programmer', $parsed->title); + $this->assertSame(1999, $parsed->year); + } + + public function test_parse_release_name_preserves_series_and_volume_evidence(): void + { + $parsed = $this->makeService()->parseReleaseName('Frank Herbert - Dune (Book 2) EPUB'); + + $this->assertSame('Dune (Book 2)', $parsed->title); + $this->assertSame('Frank Herbert', $parsed->author); + } + + public function test_extract_isbn_ignores_invalid_check_digits(): void + { + $service = $this->makeService(); + + $this->assertNull($service->extractIsbn('Fake Book 978-0-13-235088-5 EPUB')); + $this->assertNull($service->extractIsbn('Fake Book 0132350883 EPUB')); + } + public function test_parse_release_name_flags_software_as_junk(): void { $service = $this->makeService(); diff --git a/tests/Unit/Services/GoogleBooksServiceTest.php b/tests/Unit/Services/GoogleBooksServiceTest.php index f07d551b5..faea57eb2 100644 --- a/tests/Unit/Services/GoogleBooksServiceTest.php +++ b/tests/Unit/Services/GoogleBooksServiceTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Services; +use App\Exceptions\BookProviderException; use App\Services\GoogleBooksService; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; @@ -61,4 +62,19 @@ class GoogleBooksServiceTest extends TestCase $this->assertTrue($service->isConfigured()); $this->assertFalse($service->hasApiKey()); } + + public function test_server_error_is_reported_as_provider_unavailability(): void + { + $service = new GoogleBooksService( + new Client([ + 'handler' => HandlerStack::create(new MockHandler([new Response(503)])), + 'http_errors' => false, + ]), + null + ); + + $this->expectException(BookProviderException::class); + + $service->searchBooks('unavailable query'); + } } diff --git a/tests/Unit/Services/IsbnDbServiceTest.php b/tests/Unit/Services/IsbnDbServiceTest.php index ca22208a7..2b301e39a 100644 --- a/tests/Unit/Services/IsbnDbServiceTest.php +++ b/tests/Unit/Services/IsbnDbServiceTest.php @@ -4,16 +4,28 @@ declare(strict_types=1); namespace Tests\Unit\Services; +use App\Exceptions\BookProviderException; use App\Services\BookService; use App\Services\IsbnDbService; use GuzzleHttp\Client; +use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use Illuminate\Support\Facades\Cache; use Tests\TestCase; class IsbnDbServiceTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + Cache::flush(); + } + public function test_is_configured_returns_false_when_key_is_missing(): void { $service = new IsbnDbService(null, ''); @@ -67,6 +79,163 @@ class IsbnDbServiceTest extends TestCase $this->assertSame('isbndb:9780321125217', $book['asin']); } + public function test_search_books_uses_the_documented_title_and_match_parameters(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['data' => []])), + ]); + $history = []; + $handler = HandlerStack::create($mock); + $handler->push(Middleware::history($history)); + $service = new IsbnDbService( + new Client(['handler' => $handler, 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + $this->assertSame([], $service->searchBooks('Clean Code', 'Robert C Martin', true)); + $this->assertCount(1, $history); + + parse_str($history[0]['request']->getUri()->getQuery(), $query); + $this->assertArrayNotHasKey('author', $query); + $this->assertSame('title', $query['column']); + $this->assertSame('true', $query['shouldMatchAll']); + $this->assertSame('10', $query['pageSize']); + } + + public function test_successful_empty_search_is_cached(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['data' => []])), + ]); + $history = []; + $handler = HandlerStack::create($mock); + $handler->push(Middleware::history($history)); + $service = new IsbnDbService( + new Client(['handler' => $handler, 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + $this->assertSame([], $service->searchBooks('Unique Empty Book Query')); + $this->assertSame([], $service->searchBooks('Unique Empty Book Query')); + $this->assertCount(1, $history); + } + + public function test_rate_limit_response_throws_provider_exception_and_sets_cooldown(): void + { + $mock = new MockHandler([ + new Response(429, ['Retry-After' => '120']), + ]); + $service = new IsbnDbService( + new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + try { + $service->searchBooks('Rate Limited Query'); + $this->fail('Expected the ISBNdb request to report provider unavailability.'); + } catch (BookProviderException $exception) { + $this->assertSame('isbndb', $exception->provider); + $this->assertSame(429, $exception->statusCode); + $this->assertSame(120, $exception->retryAfterSeconds); + } + + $this->assertTrue(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY)); + } + + public function test_malformed_success_response_throws_provider_exception(): void + { + $mock = new MockHandler([ + new Response(200, [], '{not-json'), + ]); + $service = new IsbnDbService( + new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + $this->expectException(BookProviderException::class); + + $service->searchBooks('Malformed Response Query'); + } + + public function test_success_response_without_documented_data_envelope_is_retryable(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['books' => []])), + ]); + $service = new IsbnDbService( + new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + $this->expectException(BookProviderException::class); + + $service->searchBooks('Missing Data Envelope Query'); + } + + public function test_authentication_failure_is_distinguished_without_transient_cooldown(): void + { + $mock = new MockHandler([ + new Response(401), + ]); + $service = new IsbnDbService( + new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + try { + $service->searchBooks('Authentication Failure Query'); + $this->fail('Expected an authentication provider exception.'); + } catch (BookProviderException $exception) { + $this->assertSame(401, $exception->statusCode); + $this->assertNull($exception->retryAfterSeconds); + } + + $this->assertFalse(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY)); + } + + public function test_server_failure_applies_default_cooldown(): void + { + $mock = new MockHandler([ + new Response(503), + ]); + $service = new IsbnDbService( + new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + try { + $service->searchBooks('Server Failure Query'); + $this->fail('Expected a transient provider exception.'); + } catch (BookProviderException $exception) { + $this->assertSame(503, $exception->statusCode); + $this->assertSame(300, $exception->retryAfterSeconds); + } + + $this->assertTrue(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY)); + } + + public function test_network_failure_applies_default_cooldown(): void + { + $request = new Request('GET', 'https://api2.isbndb.com/books/Network'); + $mock = new MockHandler([ + new ConnectException('Connection failed', $request), + ]); + $service = new IsbnDbService( + new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']), + 'test-key' + ); + + try { + $service->searchBooks('Network Failure Query'); + $this->fail('Expected a network provider exception.'); + } catch (BookProviderException $exception) { + $this->assertNull($exception->statusCode); + $this->assertSame(300, $exception->retryAfterSeconds); + } + + $this->assertTrue(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY)); + } + public function test_find_by_isbn_reads_book_payload(): void { $mock = new MockHandler([ diff --git a/tests/Unit/Services/OpenLibraryServiceTest.php b/tests/Unit/Services/OpenLibraryServiceTest.php index 829d2e19a..300dda7aa 100644 --- a/tests/Unit/Services/OpenLibraryServiceTest.php +++ b/tests/Unit/Services/OpenLibraryServiceTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Services; +use App\Exceptions\BookProviderException; use App\Services\OpenLibraryService; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; @@ -66,4 +67,17 @@ class OpenLibraryServiceTest extends TestCase $this->assertSame('Robert C. Martin', $book['author']); $this->assertSame('9780134494166', $book['isbn']); } + + public function test_malformed_search_response_is_reported_as_provider_unavailability(): void + { + $service = new OpenLibraryService( + new Client(['handler' => HandlerStack::create(new MockHandler([ + new Response(200, [], '{invalid-json'), + ]))]) + ); + + $this->expectException(BookProviderException::class); + + $service->searchBooks('malformed query'); + } } diff --git a/tests/Unit/Support/BookIsbnTest.php b/tests/Unit/Support/BookIsbnTest.php new file mode 100644 index 000000000..f03ff80fe --- /dev/null +++ b/tests/Unit/Support/BookIsbnTest.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Unit\Support; + +use App\Support\BookIsbn; +use PHPUnit\Framework\TestCase; + +class BookIsbnTest extends TestCase +{ + public function test_normalizes_and_validates_isbn_10_and_isbn_13(): void + { + $this->assertSame('0132350882', BookIsbn::normalize('0-13-235088-2')); + $this->assertSame('9780132350884', BookIsbn::normalize('978-0-13-235088-4')); + $this->assertNull(BookIsbn::normalize('0132350883')); + $this->assertNull(BookIsbn::normalize('9780132350885')); + } + + public function test_converts_and_compares_equivalent_isbn_versions(): void + { + $this->assertSame('9780132350884', BookIsbn::toIsbn13('0132350882')); + $this->assertSame('0132350882', BookIsbn::toIsbn10('9780132350884')); + $this->assertTrue(BookIsbn::equivalent('0132350882', '9780132350884')); + $this->assertFalse(BookIsbn::equivalent('0132350882', '9780321125217')); + } + + public function test_returns_all_equivalent_identifiers_without_duplicates(): void + { + $this->assertSame( + ['0132350882', '9780132350884'], + BookIsbn::equivalents('9780132350884') + ); + } +} diff --git a/tests/Unit/Support/BookMatchScorerTest.php b/tests/Unit/Support/BookMatchScorerTest.php index b8e3f5f48..95cbf865f 100644 --- a/tests/Unit/Support/BookMatchScorerTest.php +++ b/tests/Unit/Support/BookMatchScorerTest.php @@ -31,6 +31,154 @@ class BookMatchScorerTest extends TestCase $this->assertSame(1.0, $score); } + public function test_returns_perfect_score_for_equivalent_isbn_10_candidate(): void + { + $parsed = new BookParseResult( + rawName: 'Clean Code 9780132350884', + title: 'Clean Code', + isbn: '9780132350884' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'isbn' => null, + 'ean' => '0132350882', + ], $parsed); + + $this->assertSame(1.0, $score); + } + + public function test_rejects_candidate_with_conflicting_explicit_isbn(): void + { + $parsed = new BookParseResult( + rawName: 'Clean Code 9780132350884', + title: 'Clean Code', + author: 'Robert C. Martin', + isbn: '9780132350884' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'isbn' => '9780321125217', + 'ean' => '0321125215', + 'publisher' => 'Excellent Publisher', + 'cover' => 1, + ], $parsed); + + $this->assertSame(0.0, $score); + } + + public function test_rejects_candidate_when_only_one_of_two_explicit_identifiers_matches(): void + { + $parsed = new BookParseResult( + rawName: 'Clean Code 9780132350884', + title: 'Clean Code', + isbn: '9780132350884' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Clean Code', + 'author' => 'Robert C. Martin', + 'isbn' => '9780132350884', + 'ean' => '0321125215', + ], $parsed); + + $this->assertSame(0.0, $score); + } + + public function test_author_matching_is_order_insensitive_and_supports_initials(): void + { + $parsed = new BookParseResult( + rawName: 'Clean Code by Robert C Martin', + title: 'Clean Code', + author: 'Robert C Martin' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Clean Code', + 'author' => 'Martin, Robert C.', + ], $parsed); + + $this->assertGreaterThanOrEqual(0.82, $score); + } + + public function test_cover_and_publisher_do_not_increase_identity_score(): void + { + $parsed = new BookParseResult( + rawName: 'Completely Different Book', + title: 'Completely Different Book' + ); + $scorer = new BookMatchScorer; + + $withoutMetadata = $scorer->score([ + 'title' => 'Another Unrelated Work', + 'publisher' => '', + 'cover' => 0, + ], $parsed); + $withMetadata = $scorer->score([ + 'title' => 'Another Unrelated Work', + 'publisher' => 'Publisher', + 'cover' => 1, + ], $parsed); + + $this->assertSame($withoutMetadata, $withMetadata); + } + + public function test_rejects_conflicting_volume_numbers(): void + { + $parsed = new BookParseResult( + rawName: 'Dune Book 2', + title: 'Dune Book 2', + author: 'Frank Herbert' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Dune Book 3', + 'author' => 'Frank Herbert', + ], $parsed); + + $this->assertSame(0.0, $score); + } + + public function test_rejects_conflicting_edition_numbers(): void + { + $parsed = new BookParseResult( + rawName: 'Effective Java 3rd Edition', + title: 'Effective Java 3rd Edition', + author: 'Joshua Bloch' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Effective Java 2nd Edition', + 'author' => 'Joshua Bloch', + ], $parsed); + + $this->assertSame(0.0, $score); + } + + public function test_multilingual_punctuation_normalizes_without_metadata_boosts(): void + { + $parsed = new BookParseResult( + rawName: 'Cien años de soledad by Gabriel García Márquez', + title: 'Cien años de soledad', + author: 'Gabriel García Márquez' + ); + + $score = (new BookMatchScorer)->score([ + 'title' => 'Cien años de soledad!', + 'author' => 'Márquez, Gabriel García', + ], $parsed); + + $this->assertSame(1.0, $score); + } + + public function test_generic_stop_words_do_not_count_as_significant_title_evidence(): void + { + $this->assertSame(2, (new BookMatchScorer)->meaningfulTitleTokenCount('The Art of War')); + } + public function test_higher_score_for_better_title_author_match(): void { $parsed = new BookParseResult(