diff --git a/AGENTS.md b/AGENTS.md index 5bb3f7417..3d5002630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ NNTP → NNTPService → BinariesRunner → ReleaseCreationService → ReleasePr | Pattern | Location | Example | |---------|----------|---------| | **Service Layer** | `app/Services/` | 50+ services with facades (`Search::`, `Categorization::`, `TvProcessing::`, `Yenc::`, `Elasticsearch::`) | -| **Pipeline** | `*/Pipes/` | `TvProcessingPipeline` (TMDB→TVDB→TVMaze→Trakt), `CategorizationPipeline` (TV→Movie→PC→Console→Music→Book→XXX→Misc) | +| **Pipeline** | `*/Pipes/` | `TvProcessingPipeline` (TMDB→TVDB→TVMaze→Trakt), `CategorizationPipeline` (priority-driven; `Music` runs before `Book` for audiobook detection) | | **Driver** | `Search/Drivers/` | Manticore/Elasticsearch via `SEARCH_DRIVER` env var | | **Runners** | `Runners/` | `BinariesRunner`, `ReleasesRunner`, `BackfillRunner`, `PostProcessRunner` | | **DTO** | `*/DTO/`, `app/Support/DTOs/` | `NameFixResult`, `ReleaseProcessingContext`, `ReleaseCreationResult` | diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index 51fd850fa..c5cf42621 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -517,6 +517,10 @@ if (! function_exists('getReleaseCover')) { return null; }; + $isPositiveId = static function (mixed $value): bool { + return is_numeric($value) && (int) $value > 0; + }; + // Determine cover type and ID based on category $imdbid = $getValue($release, 'imdbid'); $videos_id = $getValue($release, 'videos_id'); @@ -529,22 +533,22 @@ if (! function_exists('getReleaseCover')) { if (! empty($imdbid) && imdb_id_is_valid($imdbid)) { $coverType = 'movies'; $coverId = (string) $imdbid; - } elseif (! empty($videos_id) && $videos_id > 0) { + } elseif ($isPositiveId($videos_id)) { $coverType = 'tvshows'; $coverId = $videos_id; - } elseif (! empty($musicinfo_id)) { + } elseif ($isPositiveId($musicinfo_id)) { $coverType = 'music'; $coverId = $musicinfo_id; - } elseif (! empty($consoleinfo_id)) { + } elseif ($isPositiveId($consoleinfo_id)) { $coverType = 'console'; $coverId = $consoleinfo_id; - } elseif (! empty($bookinfo_id)) { + } elseif ($isPositiveId($bookinfo_id)) { $coverType = 'book'; $coverId = $bookinfo_id; - } elseif (! empty($gamesinfo_id)) { + } elseif ($isPositiveId($gamesinfo_id)) { $coverType = 'games'; $coverId = $gamesinfo_id; - } elseif (! empty($anidbid) && $anidbid > 0) { + } elseif ($isPositiveId($anidbid)) { $coverType = 'anime'; $coverId = $anidbid; } diff --git a/app/Http/Controllers/CoverController.php b/app/Http/Controllers/CoverController.php index 78cdc1cbb..c329729f7 100644 --- a/app/Http/Controllers/CoverController.php +++ b/app/Http/Controllers/CoverController.php @@ -10,6 +10,11 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; class CoverController extends Controller { + /** + * @var array + */ + private const array NUMERIC_ID_TYPES = ['anime', 'book', 'console', 'games', 'music', 'tvshows']; + /** * Serve cover images from storage * @@ -26,6 +31,10 @@ class CoverController extends Controller abort(404); } + if (in_array($type, self::NUMERIC_ID_TYPES, true) && $this->isInvalidNumericCoverFilename($filename)) { + return $this->respondWithPlaceholder(); + } + // Build the file path // For preview and sample images, try with _thumb suffix first if (in_array($type, ['preview', 'sample'], true)) { @@ -40,20 +49,6 @@ class CoverController extends Controller $filePath = storage_path("covers/{$type}/{$filename}"); } } elseif ($type === 'anime') { - // For anime, check if the ID is valid (must be > 0) - // Reject covers for anidbid <= 0 (failed processing, no match, etc.) - if (preg_match('/^(-?\d+)(?:-cover)?\.jpg$/', $filename, $matches)) { - $anidbid = (int) $matches[1]; - if ($anidbid <= 0) { - // Return placeholder for invalid IDs - $placeholderPath = public_path('assets/images/no-cover.png'); - if (file_exists($placeholderPath)) { - return response()->file($placeholderPath); - } - abort(404); - } - } - // For anime, try the requested filename first, then fall back to old format (without -cover) $filePath = storage_path("covers/{$type}/{$filename}"); @@ -94,4 +89,23 @@ class CoverController extends Controller 'Cache-Control' => 'public, max-age=31536000', // Cache for 1 year ]); } + + private function isInvalidNumericCoverFilename(string $filename): bool + { + if (preg_match('/^(-?\d+)(?:-cover)?\.jpg$/', $filename, $matches) !== 1) { + return false; + } + + return (int) $matches[1] <= 0; + } + + private function respondWithPlaceholder(): Response|BinaryFileResponse + { + $placeholderPath = public_path('assets/images/no-cover.png'); + if (file_exists($placeholderPath)) { + return response()->file($placeholderPath); + } + + abort(404); + } } diff --git a/app/Http/Controllers/DetailsController.php b/app/Http/Controllers/DetailsController.php index 23fd05b58..1d827040c 100644 --- a/app/Http/Controllers/DetailsController.php +++ b/app/Http/Controllers/DetailsController.php @@ -108,22 +108,22 @@ class DetailsController extends BasePageController } $game = ''; - if (! empty($data['gamesinfo_id'])) { + if ((int) $data['gamesinfo_id'] > 0) { $game = (new GamesService)->getGamesInfoById($data['gamesinfo_id']); } $mus = ''; - if (! empty($data['musicinfo_id'])) { + if ((int) $data['musicinfo_id'] > 0) { $mus = (new MusicService)->getMusicInfo($data['musicinfo_id']); } $book = ''; - if (! empty($data['bookinfo_id'])) { + if ((int) $data['bookinfo_id'] > 0) { $book = (new BookService)->getBookInfo($data['bookinfo_id']); } $con = ''; - if (! empty($data['consoleinfo_id'])) { + if ((int) $data['consoleinfo_id'] > 0) { $con = (new ConsoleService)->getConsoleInfo($data['consoleinfo_id']); } diff --git a/app/Http/Resources/BookResource.php b/app/Http/Resources/BookResource.php index be1e717fe..ac490f45e 100644 --- a/app/Http/Resources/BookResource.php +++ b/app/Http/Resources/BookResource.php @@ -48,7 +48,7 @@ class BookResource extends JsonResource */ protected function getCoverUrl(): ?string { - if (! $this->cover) { + if ((int) $this->id <= 0 || ! $this->cover) { return null; } diff --git a/app/Models/BookInfo.php b/app/Models/BookInfo.php index 2c83f22f4..306270281 100644 --- a/app/Models/BookInfo.php +++ b/app/Models/BookInfo.php @@ -80,6 +80,10 @@ class BookInfo extends Model */ public function getCoverPath(): string { + if ((int) $this->id <= 0) { + return ''; + } + return storage_path('covers/book/'.$this->id.'.jpg'); } @@ -88,7 +92,9 @@ class BookInfo extends Model */ public function hasCoverImage(): bool { - return file_exists($this->getCoverPath()); + $coverPath = $this->getCoverPath(); + + return $coverPath !== '' && file_exists($coverPath); } /** @@ -96,7 +102,7 @@ class BookInfo extends Model */ public function getCoverUrl(): ?string { - if (! $this->cover || ! $this->hasCoverImage()) { + if ((int) $this->id <= 0 || ! $this->cover || ! $this->hasCoverImage()) { return null; } diff --git a/app/Services/BookService.php b/app/Services/BookService.php index 8dba76c6c..d6fa621b6 100644 --- a/app/Services/BookService.php +++ b/app/Services/BookService.php @@ -10,6 +10,7 @@ use App\Models\BookInfo; use App\Models\Category; use App\Models\Release; use App\Models\Settings; +use App\Services\NameFixing\Extractors\ObfuscatedSubjectExtractor; use App\Services\Releases\ReleaseBrowseService; use App\Support\BookMatchScorer; use App\Support\DTOs\BookParseResult; @@ -17,6 +18,7 @@ use App\Support\MetadataSearchLookup; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Service class for book data fetching and processing. @@ -37,6 +39,8 @@ class BookService public ?BookParseResult $parsedBookResult; + private ObfuscatedSubjectExtractor $obfuscatedSubjectExtractor; + /** * @throws \Exception */ @@ -52,6 +56,7 @@ class BookService $this->parsedIsbn = null; $this->parsedBookResult = null; + $this->obfuscatedSubjectExtractor = new ObfuscatedSubjectExtractor; } /** @@ -363,7 +368,10 @@ class BookService { $query = Release::query() ->whereNull('bookinfo_id') - ->whereBetween('categories_id', [Category::BOOKS_ROOT, Category::BOOKS_UNKNOWN]) + ->where(static function ($builder): void { + $builder->whereBetween('categories_id', [Category::BOOKS_ROOT, Category::BOOKS_UNKNOWN]) + ->orWhere('categories_id', Category::MUSIC_AUDIOBOOK); + }) ->orderByDesc('postdate') ->limit($this->bookqty); @@ -377,7 +385,12 @@ class BookService $this->processBookReleasesHelper( $query->get(['searchname', 'id', 'categories_id']), - sprintf('%d-%d', Category::BOOKS_ROOT, Category::BOOKS_UNKNOWN) + sprintf( + '%d-%d,%d', + Category::BOOKS_ROOT, + Category::BOOKS_UNKNOWN, + Category::MUSIC_AUDIOBOOK + ) ); } @@ -459,7 +472,8 @@ class BookService */ public function parseTitle(mixed $release_name, mixed $releaseID, mixed $releasetype) { - $parsed = $this->parseReleaseName((string) $release_name, (string) $releasetype); + $normalizedReleaseName = $this->obfuscatedSubjectExtractor->extract((string) $release_name) ?? (string) $release_name; + $parsed = $this->parseReleaseName($normalizedReleaseName, (string) $releasetype); $this->parsedBookResult = $parsed; $this->parsedIsbn = $parsed->isbn; $releasename = $parsed->title; @@ -514,6 +528,10 @@ class BookService public function parseReleaseName(string $releaseName, string $releaseType = 'ebook'): BookParseResult { + $releaseName = $this->obfuscatedSubjectExtractor->extract($releaseName) ?? $releaseName; + if (preg_match('/"([^"]{3,240})"/', $releaseName, $quotedMatch) === 1) { + $releaseName = $quotedMatch[1]; + } $isbn = $this->extractIsbn($releaseName); $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); @@ -538,8 +556,12 @@ class BookService $author = null; $title = $normalized; if (preg_match('/^(?[A-Za-z0-9&\'\.\-\s]{3,80})\s-\s(?.+)$/', $normalized, $matches) === 1) { - $author = trim($matches['author']); - $title = trim($matches['title']); + $candidateAuthor = trim($matches['author']); + $candidateTitle = trim($matches['title']); + if (preg_match('/^(Issue\s+\d+|\d+\w*\s+Edition\b)/i', $candidateTitle) !== 1) { + $author = $candidateAuthor; + $title = $candidateTitle; + } } elseif (preg_match('/^(?<author>[A-Za-z0-9&\'\.\-\s]{3,80})\s+by\s+(?<title>.+)$/i', $normalized, $matches) === 1) { $author = trim($matches['author']); $title = trim($matches['title']); @@ -549,6 +571,12 @@ class BookService } $title = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace('/\((Book|Series)\s*\d+\)/i', '', $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'; @@ -574,8 +602,11 @@ class BookService $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; + $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('/\bIssue[\._\- ]?\d{1,4}\b.*\b(19|20)\d{2}\b/i', $normalized) === 1 + || preg_match('/\bIssue[\._\- ]?\d{1,4}\b/i', $normalized) === 1 + ) && preg_match('/Part \d+/i', $normalized) !== 1; return new BookParseResult( rawName: $releaseName, @@ -627,19 +658,40 @@ class BookService $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'; + } } } @@ -654,11 +706,17 @@ class BookService $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 === []) { @@ -667,15 +725,26 @@ class BookService $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)) { diff --git a/app/Services/Categorization/CategorizationPipeline.php b/app/Services/Categorization/CategorizationPipeline.php index 758271539..40aa133e3 100644 --- a/app/Services/Categorization/CategorizationPipeline.php +++ b/app/Services/Categorization/CategorizationPipeline.php @@ -8,6 +8,7 @@ use App\Models\Settings; use App\Models\UsenetGroup; use App\Services\Categorization\Pipes\AbstractCategorizationPipe; use App\Services\Categorization\Pipes\CategorizationPassable; +use App\Services\NameFixing\Extractors\ObfuscatedSubjectExtractor; use App\Services\NameFixing\NzbSplitUnwrapper; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; @@ -34,11 +35,16 @@ class CategorizationPipeline protected NzbSplitUnwrapper $nzbSplitUnwrapper; + protected ObfuscatedSubjectExtractor $obfuscatedSubjectExtractor; + /** * @param iterable<AbstractCategorizationPipe> $pipes */ - public function __construct(iterable $pipes = [], ?NzbSplitUnwrapper $nzbSplitUnwrapper = null) - { + public function __construct( + iterable $pipes = [], + ?NzbSplitUnwrapper $nzbSplitUnwrapper = null, + ?ObfuscatedSubjectExtractor $obfuscatedSubjectExtractor = null + ) { /** @phpstan-ignore argument.templateType */ $this->pipes = collect($pipes) ->sortBy(fn (AbstractCategorizationPipe $p) => $p->getPriority()); @@ -46,6 +52,7 @@ class CategorizationPipeline $this->categorizeForeign = (bool) Settings::settingValue('categorizeforeign'); $this->catWebDL = (bool) Settings::settingValue('catwebdl'); $this->nzbSplitUnwrapper = $nzbSplitUnwrapper ?? new NzbSplitUnwrapper; + $this->obfuscatedSubjectExtractor = $obfuscatedSubjectExtractor ?? new ObfuscatedSubjectExtractor; } /** @@ -75,6 +82,7 @@ class CategorizationPipeline bool $debug = false ): array { $releaseName = $this->nzbSplitUnwrapper->unwrap($releaseName) ?? $releaseName; + $releaseName = $this->obfuscatedSubjectExtractor->extract($releaseName) ?? $releaseName; $groupName = UsenetGroup::whereId($groupId)->value('name') ?? ''; diff --git a/app/Services/Categorization/Categorizers/BookCategorizer.php b/app/Services/Categorization/Categorizers/BookCategorizer.php index 35b2fd051..e5389905a 100644 --- a/app/Services/Categorization/Categorizers/BookCategorizer.php +++ b/app/Services/Categorization/Categorizers/BookCategorizer.php @@ -136,12 +136,16 @@ class BookCategorizer extends AbstractCategorizer { $magazines = 'Forbes|Fortune|GQ|National[._ -]Geographic|Newsweek|Vogue|Wired|The[._ -]?Economist|New[._ -]?Yorker|Scientific[._ -]?American|Popular[._ -]?Mechanics|Cosmopolitan|Elle|Esquire|Vanity[._ -]?Fair|Rolling[._ -]?Stone|Entertainment[._ -]?Weekly|People|Playboy'; $hasTitle = preg_match('/\b('.$magazines.')\b/i', $name) === 1; - $hasIssueNumber = preg_match('/(?:^|[._ -])Issue[._ -]?\d{1,4}(?:$|[._ -])/i', $name) === 1; + $hasIssueNumber = preg_match('/(?:^|[._ -])Issue[._ -]?\d{1,4}(?:$|[._ -,])/i', $name) === 1; $hasDateSignal = preg_match('/\b(?:19|20)\d{2}\b|(?:^|[._ -])(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*[._ -]?(?:19|20)?\d{2}\b/i', $name) === 1; $hasFrequency = preg_match('/[._ -](Monthly|Weekly|Quarterly|Annual)[._ -]/i', $name) === 1; + $hasIssueStyleTitle = preg_match('/\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)*)\s*-\s*Issue\s+\d+/i', $name) === 1; + $hasMcnMagazineSignal = preg_match('/\bMCN[._ -](?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|January|February|March|April|June|July|August|September|October|November|December)[._ -]\d{1,2}[._ -](?:19|20)\d{2}\b/i', $name) === 1 + && preg_match('/\bMAGAZINE\b/i', $name) === 1; if (($hasFrequency && ($hasIssueNumber || $hasDateSignal || $hasTitle)) || - ($hasIssueNumber && ($hasDateSignal || $hasTitle))) { + ($hasIssueNumber && ($hasDateSignal || $hasTitle || $hasIssueStyleTitle)) || + $hasMcnMagazineSignal) { return $this->matched(Category::BOOKS_MAGAZINES, 0.9, 'magazine_frequency'); } if ($hasTitle) { @@ -173,6 +177,9 @@ class BookCategorizer extends AbstractCategorizer if (preg_match('/\b(E-?book|Kindle|Kobo|Nook)\b/i', $name)) { return $this->matched(Category::BOOKS_EBOOK, 0.8, 'ebook_platform'); } + if ($this->hasBookCorroborator($name) && preg_match('/\b(19|20)\d{2}\b/', $name) === 1) { + return $this->matched(Category::BOOKS_EBOOK, 0.6, 'ebook_book_signals'); + } return null; } diff --git a/app/Services/NameFixing/Extractors/ObfuscatedSubjectExtractor.php b/app/Services/NameFixing/Extractors/ObfuscatedSubjectExtractor.php new file mode 100644 index 000000000..f727464e0 --- /dev/null +++ b/app/Services/NameFixing/Extractors/ObfuscatedSubjectExtractor.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace App\Services\NameFixing\Extractors; + +final class ObfuscatedSubjectExtractor +{ + /** + * @var list<string> + */ + private const array PREFIX_PATTERNS = [ + '/^\s*N:\/NZB\s*\[\d+\/\d+\]\s*-\s*/i', + '/^\s*\[[^\]]{2,80}\]\s*/', + '/^\s*(?:re\s*)?posted\s+by\s+[^-]{2,120}\s*-\s*/i', + ]; + + /** + * @var list<string> + */ + private const array TRAILING_ARCHIVE_PATTERNS = [ + '/\.part\d+\.rar$/i', + '/\.r\d{2,4}$/i', + '/\.7z\.\d{2,4}$/i', + '/\.7z$/i', + '/\.rar$/i', + '/\.par2?$/i', + '/\.vol\d+[+\-]\d+\.par2?$/i', + '/\.\d{3}$/', + ]; + + public function extract(string $value): ?string + { + $original = trim($value); + if ($original === '') { + return null; + } + + $normalized = $original; + $looksObfuscated = false; + foreach (self::PREFIX_PATTERNS as $pattern) { + $updated = preg_replace($pattern, '', $normalized) ?? $normalized; + if ($updated !== $normalized) { + $looksObfuscated = true; + $normalized = $updated; + } + } + + if (preg_match('/"([^"]{3,240})"/', $normalized, $quoted) === 1) { + $normalized = $quoted[1]; + $looksObfuscated = true; + } elseif (preg_match("/'([^']{3,240})'/", $normalized, $quoted) === 1) { + $normalized = $quoted[1]; + $looksObfuscated = true; + } + + if ($looksObfuscated) { + foreach (self::TRAILING_ARCHIVE_PATTERNS as $pattern) { + $normalized = preg_replace($pattern, '', $normalized) ?? $normalized; + } + } + + $normalized = str_replace(['_', '+'], [' ', ' '], $normalized); + $normalized = preg_replace('/\.(?=[A-Za-z0-9])/', ' ', $normalized) ?? $normalized; + $normalized = preg_replace('/\s{2,}/', ' ', $normalized) ?? $normalized; + $normalized = trim($normalized, " \t\n\r\0\x0B\"'`-_."); + + if ($normalized === '' || $normalized === $original) { + return null; + } + + return $normalized; + } +} diff --git a/app/Services/NameFixing/FileNameCleaner.php b/app/Services/NameFixing/FileNameCleaner.php index 12e385023..2f2cf432a 100644 --- a/app/Services/NameFixing/FileNameCleaner.php +++ b/app/Services/NameFixing/FileNameCleaner.php @@ -167,8 +167,12 @@ class FileNameCleaner // Remove common video file extensions $t = preg_replace('/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx)$/i', '', $t) ?? $t; - // Remove archive and metadata file extensions - $t = preg_replace('/\.(par2?|nfo|sfv|nzb|rar|zip|7z|gz|tar|bz2|xz|r\d{2,3}|\d{3}|pkg|exe|msi|jpe?g|png|gif|bmp)$/i', '', $t) ?? $t; + // Remove archive, metadata, and common document/file extensions + $t = preg_replace( + '/\.(par2?|nfo|sfv|nzb|rar|zip|7z|gz|tar|bz2|xz|r\d{2,3}|\d{3}|pkg|exe|msi|jpe?g|png|gif|bmp|pdf|epub|mobi|azw3?|djvu|cbr|cbz|fb2|lit|prc|opf|txt|log)$/i', + '', + $t + ) ?? $t; // Remove common trailing segment markers $t = preg_replace('/[.\-_ ](?:part|vol|r)\d+(?:\+\d+)?$/i', '', $t) ?? $t; diff --git a/app/Services/PostProcessService.php b/app/Services/PostProcessService.php index 18fdc9c0e..ad09fe660 100644 --- a/app/Services/PostProcessService.php +++ b/app/Services/PostProcessService.php @@ -123,7 +123,7 @@ final class PostProcessService } /** - * Process book releases using Amazon. + * Process book releases using ISBNdb, Google Books, iTunes, and Open Library. * * @param string $groupID Optional group ID filter * @param string $guidChar Optional GUID character filter diff --git a/tests/Unit/CategorizeBookTest.php b/tests/Unit/CategorizeBookTest.php index 8f0178820..62b30be5b 100644 --- a/tests/Unit/CategorizeBookTest.php +++ b/tests/Unit/CategorizeBookTest.php @@ -35,7 +35,10 @@ class CategorizeBookTest extends TestCase 'comic cbz' => ['Batman.Vol.3.Issue.145.2024.DC.Comics.CBZ', Category::BOOKS_COMICS], 'technical publisher ebook' => ['The.Pragmatic.Programmer.20th.Anniversary.Edition.OReilly.2019.EPUB', Category::BOOKS_TECHNICAL], 'magazine issue date' => ['Vegan_Food_and_Living_Monthly_May_2026_Issue_352', Category::BOOKS_MAGAZINES], + 'magazine issue with year' => ['History of War - Issue 158, 2026', Category::BOOKS_MAGAZINES], + 'mcn magazine pattern' => ['MCN.April.22.2026.HYBRID.MAGAZINE.eBook-21A1', Category::BOOKS_MAGAZINES], 'ebook pdf with book context' => ['George.Orwell.1984.Novel.PDF', Category::BOOKS_EBOOK], + 'ebook part rar style after normalize' => ['Harry Styles Songbook - 1st Edition 2026', Category::BOOKS_EBOOK], ]; } @@ -122,7 +125,7 @@ class CategorizeBookTest extends TestCase $this->assertSame(Category::BOOKS_EBOOK, $result->categoryId); $this->assertSame(0.5, $result->confidence); - $this->assertSame('group_book', $result->matchedBy); + $this->assertSame('group_name_book', $result->matchedBy); } /** diff --git a/tests/Unit/Extensions/HelperCoverUrlTest.php b/tests/Unit/Extensions/HelperCoverUrlTest.php new file mode 100644 index 000000000..20c7b7583 --- /dev/null +++ b/tests/Unit/Extensions/HelperCoverUrlTest.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Unit\Extensions; + +use Tests\TestCase; + +class HelperCoverUrlTest extends TestCase +{ + public function test_get_release_cover_returns_placeholder_for_negative_bookinfo_id(): void + { + $url = getReleaseCover((object) ['bookinfo_id' => -2]); + + $this->assertStringContainsString('assets/images/no-cover.png', $url); + } + + public function test_get_release_cover_returns_placeholder_for_negative_music_id(): void + { + $url = getReleaseCover((object) ['musicinfo_id' => -2]); + + $this->assertStringContainsString('assets/images/no-cover.png', $url); + } +} diff --git a/tests/Unit/Services/BookServiceTest.php b/tests/Unit/Services/BookServiceTest.php index 9ebe46b31..46f25b206 100644 --- a/tests/Unit/Services/BookServiceTest.php +++ b/tests/Unit/Services/BookServiceTest.php @@ -5,14 +5,25 @@ declare(strict_types=1); namespace Tests\Unit\Services; use App\Services\BookService; +use App\Services\NameFixing\Extractors\ObfuscatedSubjectExtractor; use Tests\TestCase; class BookServiceTest extends TestCase { - public function test_parse_release_name_extracts_author_title_and_isbn(): void + private function makeService(): BookService { /** @var BookService $service */ $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $property = new \ReflectionProperty(BookService::class, 'obfuscatedSubjectExtractor'); + $property->setAccessible(true); + $property->setValue($service, new ObfuscatedSubjectExtractor); + + return $service; + } + + public function test_parse_release_name_extracts_author_title_and_isbn(): void + { + $service = $this->makeService(); $parsed = $service->parseReleaseName('Eric.Evans - Domain.Driven.Design 978-0321125217 RETAIL EPUB'); @@ -23,8 +34,7 @@ class BookServiceTest extends TestCase public function test_parse_release_name_flags_software_as_junk(): void { - /** @var BookService $service */ - $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $service = $this->makeService(); $parsed = $service->parseReleaseName('Foxit PDF Editor Pro 14.0.4.33508 Multilingual x64', 'ebook'); @@ -33,8 +43,7 @@ class BookServiceTest extends TestCase public function test_parse_release_name_flags_tv_season_as_junk(): void { - /** @var BookService $service */ - $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $service = $this->makeService(); $tvReleases = [ 'El verano en que me enamore Temporada 01 PAR2', @@ -53,8 +62,7 @@ class BookServiceTest extends TestCase public function test_parse_release_name_flags_par2_repair_files_as_junk(): void { - /** @var BookService $service */ - $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $service = $this->makeService(); $parsed = $service->parseReleaseName('Some.Release.Name.PAR2', 'ebook'); $this->assertTrue($parsed->isJunk); @@ -62,8 +70,7 @@ class BookServiceTest extends TestCase public function test_parse_release_name_strips_video_terms_from_title(): void { - /** @var BookService $service */ - $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $service = $this->makeService(); $parsed = $service->parseReleaseName('El verano en que me enamore Temporada 01 PAR2', 'ebook'); @@ -73,8 +80,7 @@ class BookServiceTest extends TestCase public function test_tv_and_video_releases_flagged_as_junk_for_both_types(): void { - /** @var BookService $service */ - $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $service = $this->makeService(); $releases = [ 'El verano en que me enamore Temporada 01 PAR2', @@ -94,11 +100,30 @@ class BookServiceTest extends TestCase public function test_legitimate_book_not_flagged_as_junk(): void { - /** @var BookService $service */ - $service = (new \ReflectionClass(BookService::class))->newInstanceWithoutConstructor(); + $service = $this->makeService(); $parsed = $service->parseReleaseName('Eric.Evans - Domain.Driven.Design 978-0321125217 RETAIL EPUB', 'ebook'); $this->assertFalse($parsed->isJunk); $this->assertFalse($parsed->isMagazine); } + + public function test_parse_release_name_extracts_obfuscated_quoted_book_title(): void + { + $service = $this->makeService(); + + $parsed = $service->parseReleaseName('N:/NZB [2/8] - "Harry Styles Songbook - 1st Edition 2026.part1.rar"', 'ebook'); + + $this->assertSame('Harry Styles Songbook - 1st Edition', $parsed->title); + $this->assertFalse($parsed->isJunk); + } + + public function test_parse_release_name_marks_issue_with_year_as_magazine(): void + { + $service = $this->makeService(); + + $parsed = $service->parseReleaseName('N:/NZB [2/5] - "History of War - Issue 158, 2026.rar"', 'ebook'); + + $this->assertSame('History of War - Issue 158,', $parsed->title); + $this->assertTrue($parsed->isMagazine); + } } diff --git a/tests/Unit/Services/NameFixing/FileNameCleanerTest.php b/tests/Unit/Services/NameFixing/FileNameCleanerTest.php index 0dbd70ba8..d5d92c801 100644 --- a/tests/Unit/Services/NameFixing/FileNameCleanerTest.php +++ b/tests/Unit/Services/NameFixing/FileNameCleanerTest.php @@ -31,4 +31,11 @@ class FileNameCleanerTest extends TestCase $this->assertSame('cover the fisher king', $cleaner->cleanForMatching('Film ;-)/cover the fisher king.jpg')); } + + public function test_normalize_candidate_title_removes_pdf_extension(): void + { + $cleaner = new FileNameCleaner; + + $this->assertSame('WOB Klassik 4.25', $cleaner->normalizeCandidateTitle('WOB Klassik 4.25.Pdf')); + } } diff --git a/tests/Unit/Services/NameFixing/ObfuscatedSubjectExtractorTest.php b/tests/Unit/Services/NameFixing/ObfuscatedSubjectExtractorTest.php new file mode 100644 index 000000000..e3827bd97 --- /dev/null +++ b/tests/Unit/Services/NameFixing/ObfuscatedSubjectExtractorTest.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Unit\Services\NameFixing; + +use App\Services\NameFixing\Extractors\ObfuscatedSubjectExtractor; +use PHPUnit\Framework\Attributes\Test; +use Tests\TestCase; + +class ObfuscatedSubjectExtractorTest extends TestCase +{ + #[Test] + public function it_extracts_quoted_title_from_nzb_prefix(): void + { + $extractor = new ObfuscatedSubjectExtractor; + + $result = $extractor->extract('N:/NZB [2/5] - "History of War - Issue 158, 2026.rar"'); + + $this->assertSame('History of War - Issue 158, 2026', $result); + } + + #[Test] + public function it_extracts_title_and_removes_par2_archive_suffix(): void + { + $extractor = new ObfuscatedSubjectExtractor; + + $result = $extractor->extract('N:/NZB [1/6] - "Woman\'s Day New Zealand - Issue 45 April 27, 2026.par2"'); + + $this->assertSame('Woman\'s Day New Zealand - Issue 45 April 27, 2026', $result); + } + + #[Test] + public function it_extracts_title_and_removes_part_rar_suffix(): void + { + $extractor = new ObfuscatedSubjectExtractor; + + $result = $extractor->extract('N:/NZB [2/8] - "Harry Styles Songbook - 1st Edition 2026.part1.rar"'); + + $this->assertSame('Harry Styles Songbook - 1st Edition 2026', $result); + } + + #[Test] + public function it_returns_null_for_already_clean_title(): void + { + $extractor = new ObfuscatedSubjectExtractor; + + $result = $extractor->extract('History of War - Issue 158, 2026'); + + $this->assertNull($result); + } +}