mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 02:01:33 +00:00
Improve book matching
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user