Improve book matching

This commit is contained in:
DariusIII
2026-04-14 16:35:33 +02:00
parent d0b86542f5
commit 99fb50adde
4 changed files with 127 additions and 14 deletions
+23 -6
View File
@@ -486,16 +486,20 @@ class BookService
return false;
}
if (! empty($releasename) && ! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename)) {
return $parsed->searchQuery();
$wordCount = count(preg_split('/\s+/', trim($releasename)) ?: []);
if ($wordCount >= 2) {
return $parsed->searchQuery();
}
}
return false;
}
if ($releasetype === 'audiobook') {
if (! empty($releasename) && ! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename)) {
// We can skip category checks for audiobooks since the category is known.
// As long as the release name is valid, it should still be post-processed.
return $parsed->searchQuery();
$wordCount = count(preg_split('/\s+/', trim($releasename)) ?: []);
if ($wordCount >= 2) {
return $parsed->searchQuery();
}
}
return false;
@@ -514,6 +518,7 @@ class BookService
$normalized = trim((string) preg_replace('/\s\s+/i', ' ', (string) $d));
$normalized = (string) preg_replace('/[._]+/', ' ', $normalized);
$normalized = (string) preg_replace('/\b97[89](?:[-\s]?\d){10}\b|\b\d(?:[-\s]?\d){8}[-\s]?[\dXx]\b/', '', $normalized);
$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;
@@ -552,6 +557,18 @@ class BookService
if ($releaseType === 'ebook' && preg_match('/\b(WEB[\.\-_ ]?FLAC|MP3|320kbps|FALCON|discography)\b/i', $releaseName) === 1) {
$isJunk = true;
}
if (preg_match('/\b(S\d{1,3}[\.\-_ ]?E\d{1,3}|Season[\.\-_ ]?\d+|Temporada[\.\-_ ]?\d+|Saison[\.\-_ ]?\d+|Staffel[\.\-_ ]?\d+|Episode[\.\-_ ]?\d+|[12]\d{3}[\.\-_ ]?S\d{2}|HDTV|WEB[\.\-_ ]?DL|WEBRip|BluRay|BDRip|BRRip|DVDRip|XviD|x26[45]|H[\.\-_ ]?26[45]|HEVC|10bit|AAC5[\.\-]1|DDP?5[\.\-]1|ATMOS|NTb|FLUX|PSA|RARBG|YTS|YIFY|AMZN|DSNP|HMAX|NF[\.\-_ ]|ATVP)\b/i', $releaseName) === 1) {
$isJunk = true;
}
if (preg_match('/\b(PAR2|PAR[\.\-_ ]?Files?|vol\d+\+\d+|\.nzb|\.part\d+\.rar|\.r\d{2,}|sample|subs?pack|subtitle|nfo[\.\-_ ]?only)\b/i', $releaseName) === 1) {
$isJunk = true;
}
if (preg_match('/\b(1080[pi]|720p|2160p|4K[\.\-_ ]?UHD|UHD|HDR|SDR|REMUX|mHD|mSD)\b/i', $releaseName) === 1) {
$isJunk = true;
}
if ($releaseType === 'ebook' && preg_match('/\b(keygen|activat(or|ion)|licen[sc]e[\.\-_ ]?key|serial[\.\-_ ]?number|nulled|warez|regged|incl[\.\-_ ]?crack)\b/i', $releaseName) === 1) {
$isJunk = true;
}
$isMagazine = preg_match('/^([a-z0-9ü!]+ ){1,2}(N|Vol)?\d{1,4}([abc])?$|^([a-z0-9]+ ){1,2}(Jan( |unar|$)|Feb( |ruary|$)|Mar( |ch|$)|Apr( |il|$)|May(?![a-z0-9])|Jun([ e$])|Jul([ y$])|Aug( |ust|$)|Sep( |tember|$)|O([ck])t( |ober|$)|Nov( |ember|$)|De([cz])( |ember|$))/ui', $normalized) === 1
&& preg_match('/Part \d+/i', $normalized) !== 1;
@@ -793,7 +810,7 @@ class BookService
}
}
return $bestScore >= 0.4 ? $best : null;
return $bestScore >= 0.55 ? $best : null;
}
/**
@@ -816,7 +833,7 @@ class BookService
}
}
if ($best === null || $bestScore < 0.4) {
if ($best === null || $bestScore < 0.55) {
return null;
}
+46 -8
View File
@@ -20,19 +20,26 @@ class BookMatchScorer
return 1.0;
}
$titleScore = $this->similarity($parsed->title, (string) ($candidate['title'] ?? ''));
$titleScore = $this->titleSimilarity($parsed->title, (string) ($candidate['title'] ?? ''));
$authorScore = $parsed->hasAuthor()
? $this->similarity((string) $parsed->author, (string) ($candidate['author'] ?? ''))
: 0.5;
: 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;
return (0.4 * $titleScore)
+ (0.3 * $authorScore)
+ (0.1 * $yearScore)
+ (0.1 * $publisherScore)
+ (0.1 * $coverScore);
if ($parsed->hasAuthor()) {
return (0.45 * $titleScore)
+ (0.30 * $authorScore)
+ (0.10 * $yearScore)
+ (0.08 * $publisherScore)
+ (0.07 * $coverScore);
}
return (0.65 * $titleScore)
+ (0.15 * $yearScore)
+ (0.10 * $publisherScore)
+ (0.10 * $coverScore);
}
public function scoreBookInfo(BookInfo $book, BookParseResult $parsed): float
@@ -47,6 +54,37 @@ class BookMatchScorer
], $parsed);
}
private function titleSimilarity(string $left, string $right): float
{
$left = $this->normalizeText($left);
$right = $this->normalizeText($right);
if ($left === '' || $right === '') {
return 0.0;
}
if ($left === $right) {
return 1.0;
}
$leftWords = array_filter(explode(' ', $left), fn (string $w): bool => mb_strlen($w) > 1);
$rightWords = array_filter(explode(' ', $right), fn (string $w): bool => mb_strlen($w) > 1);
if ($leftWords === [] || $rightWords === []) {
return 0.0;
}
$shared = array_intersect($leftWords, $rightWords);
$total = max(count($leftWords), count($rightWords));
$jaccardScore = count($shared) / $total;
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);
}
private function similarity(string $left, string $right): float
{
$left = $this->normalizeText($left);
@@ -84,7 +122,7 @@ class BookMatchScorer
private function normalizeText(string $value): string
{
$value = strtolower($value);
$value = mb_strtolower($value);
$value = (string) preg_replace('/[._-]+/', ' ', $value);
$value = (string) preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value);
$value = (string) preg_replace('/\s+/', ' ', $value);
+40
View File
@@ -30,4 +30,44 @@ class BookServiceTest extends TestCase
$this->assertTrue($parsed->isJunk);
}
public function test_parse_release_name_flags_tv_season_as_junk(): void
{
/** @var BookService $service */
$service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor();
$tvReleases = [
'El verano en que me enamore Temporada 01 PAR2',
'Breaking.Bad.S05E16.720p.BluRay.x264',
'The.Last.of.Us.Season.1.WEBRip.x265',
'La.Casa.de.Papel.Saison.03.HDTV',
'Dark.Staffel.02.German.WEB-DL',
'Game.of.Thrones.S08E06.1080p.AMZN.WEB-DL',
];
foreach ($tvReleases as $release) {
$parsed = $service->parseReleaseName($release, 'ebook');
$this->assertTrue($parsed->isJunk, "Expected '{$release}' to be flagged as junk");
}
}
public function test_parse_release_name_flags_par2_repair_files_as_junk(): void
{
/** @var BookService $service */
$service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor();
$parsed = $service->parseReleaseName('Some.Release.Name.PAR2', 'ebook');
$this->assertTrue($parsed->isJunk);
}
public function test_parse_release_name_strips_video_terms_from_title(): void
{
/** @var BookService $service */
$service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor();
$parsed = $service->parseReleaseName('El verano en que me enamore Temporada 01 PAR2', 'ebook');
$this->assertStringNotContainsString('Temporada', $parsed->title);
$this->assertStringNotContainsString('PAR2', $parsed->title);
}
}
@@ -60,4 +60,22 @@ class BookMatchScorerTest extends TestCase
$this->assertGreaterThan($bad, $good);
}
public function test_unrelated_title_without_author_scores_below_threshold(): void
{
$parsed = new BookParseResult(
rawName: 'El verano en que me enamore',
title: 'El verano en que me enamore',
);
$score = (new BookMatchScorer)->score([
'title' => 'Aquellos veranos de pileta',
'author' => 'Ignacio Pomi & Lucas Enrique Sastre',
'publishdate' => '',
'publisher' => '',
'cover' => 1,
], $parsed);
$this->assertLessThan(0.55, $score, 'Unrelated book with partial word overlap should not reach match threshold');
}
}