From 44e9f63b6878d95e52f68bdcbb360586b3d287aa Mon Sep 17 00:00:00 2001 From: DariusIII Date: Wed, 22 Apr 2026 21:26:09 +0200 Subject: [PATCH] Improve categorization --- app/Console/Commands/FixNzbSplitNames.php | 113 ++++++++++++++++ .../ReleaseFileManager.php | 102 +++++++++----- .../AdditionalProcessing/ReleaseProcessor.php | 1 + .../Categorization/CategorizationPipeline.php | 8 +- .../Categorizers/TvCategorizer.php | 17 ++- .../Extractors/FileNameExtractor.php | 10 ++ app/Services/NameFixing/FileNameCleaner.php | 20 +++ app/Services/NameFixing/NameFixingService.php | 7 + app/Services/NameFixing/NzbSplitUnwrapper.php | 75 +++++++++++ app/Services/ReleaseCleaningService.php | 19 ++- composer.lock | 76 +++++------ ...AdditionalProcessingNzbSplitRenameTest.php | 125 ++++++++++++++++++ .../ReleaseNameFixedRecategorizationTest.php | 48 +++++++ tests/Unit/CategorizeSportTest.php | 82 ++++++++++++ tests/Unit/FileNameExtractorTest.php | 36 +++++ tests/Unit/NzbSplitUnwrapperTest.php | 85 ++++++++++++ 16 files changed, 746 insertions(+), 78 deletions(-) create mode 100644 app/Console/Commands/FixNzbSplitNames.php create mode 100644 app/Services/NameFixing/NzbSplitUnwrapper.php create mode 100644 tests/Feature/AdditionalProcessingNzbSplitRenameTest.php create mode 100644 tests/Unit/CategorizeSportTest.php create mode 100644 tests/Unit/FileNameExtractorTest.php create mode 100644 tests/Unit/NzbSplitUnwrapperTest.php diff --git a/app/Console/Commands/FixNzbSplitNames.php b/app/Console/Commands/FixNzbSplitNames.php new file mode 100644 index 000000000..8e6a613a7 --- /dev/null +++ b/app/Console/Commands/FixNzbSplitNames.php @@ -0,0 +1,113 @@ +select(['id', 'name', 'searchname', 'fromname', 'groups_id', 'categories_id']) + ->where(function ($builder): void { + $builder + ->where('name', 'like', '%__NZBSPLIT__%') + ->orWhere('searchname', 'like', '%__NZBSPLIT__%'); + }) + ->orderBy('id'); + + $count = (clone $query)->count(); + + if ($count === 0) { + $this->info('No NZBSPLIT-wrapped releases found.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $updated = 0; + $bar = $this->output->createProgressBar($count); + $bar->start(); + + $query->chunkById(250, function ($releases) use ($categorizationService, $nzbSplitUnwrapper, $dryRun, &$updated, $bar): void { + foreach ($releases as $release) { + $bar->advance(); + + $newTitle = $nzbSplitUnwrapper->unwrap((string) $release->searchname) + ?? $nzbSplitUnwrapper->unwrap((string) $release->name); + + if ($newTitle === null) { + continue; + } + + $category = $categorizationService->determineCategory( + $release->groups_id, + $newTitle, + (string) ($release->fromname ?? '') + ); + + $newCategoryId = (int) ($category['categories_id'] ?? $release->categories_id); + + if ($dryRun) { + $this->newLine(); + $this->line(sprintf( + '[dry-run] #%d %s -> %s (%d -> %d)', + $release->id, + (string) $release->searchname, + $newTitle, + (int) $release->categories_id, + $newCategoryId + )); + + continue; + } + + Release::query() + ->where('id', $release->id) + ->update([ + 'name' => $newTitle, + 'searchname' => $newTitle, + 'categories_id' => $newCategoryId, + 'isrenamed' => 1, + 'iscategorized' => 1, + 'videos_id' => 0, + 'tv_episodes_id' => 0, + 'imdbid' => null, + 'musicinfo_id' => null, + 'consoleinfo_id' => null, + 'gamesinfo_id' => 0, + 'bookinfo_id' => 0, + 'anidbid' => null, + ]); + + Search::updateRelease((int) $release->id); + $updated++; + } + }); + + $bar->finish(); + $this->newLine(2); + + if ($dryRun) { + $this->info(sprintf('Dry run complete. %d NZBSPLIT-wrapped releases inspected.', $count)); + + return self::SUCCESS; + } + + $this->info(sprintf('Updated %d NZBSPLIT-wrapped releases.', $updated)); + + return self::SUCCESS; + } +} diff --git a/app/Services/AdditionalProcessing/ReleaseFileManager.php b/app/Services/AdditionalProcessing/ReleaseFileManager.php index bfea1d56b..d08e56e18 100644 --- a/app/Services/AdditionalProcessing/ReleaseFileManager.php +++ b/app/Services/AdditionalProcessing/ReleaseFileManager.php @@ -12,6 +12,7 @@ use App\Models\Release; use App\Models\ReleaseFile; use App\Services\AdditionalProcessing\Config\ProcessingConfiguration; use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext; +use App\Services\NameFixing\FileNameCleaner; use App\Services\NameFixing\NameFixingService; use App\Services\NameFixing\ReleaseUpdateService; use App\Services\NfoService; @@ -31,13 +32,22 @@ use Illuminate\Support\Facades\Log; */ class ReleaseFileManager { + private readonly ReleaseUpdateService $releaseUpdateService; + + private readonly FileNameCleaner $fileNameCleaner; + public function __construct( private readonly ProcessingConfiguration $config, private readonly ReleaseImageService $releaseImage, private readonly NfoService $nfo, private readonly NzbService $nzb, - private readonly NameFixingService $nameFixingService - ) {} + private readonly NameFixingService $nameFixingService, + ?ReleaseUpdateService $releaseUpdateService = null, + ?FileNameCleaner $fileNameCleaner = null + ) { + $this->releaseUpdateService = $releaseUpdateService ?? new ReleaseUpdateService; + $this->fileNameCleaner = $fileNameCleaner ?? new FileNameCleaner; + } /** * Add file information to the database. @@ -152,6 +162,50 @@ class ReleaseFileManager Search::updateRelease($releaseId); } + /** + * Rename NZBSPLIT-wrapped releases directly from parsed NZB titles. + * + * @param list> $nzbContents + */ + public function processReleaseNameFromNzbContents(array $nzbContents, ReleaseProcessingContext $context): bool + { + foreach ($nzbContents as $nzbFile) { + $title = (string) ($nzbFile['title'] ?? ''); + + if ($title === '' || ! str_contains($title, '__NZBSPLIT__')) { + continue; + } + + $candidate = $this->fileNameCleaner->extractNzbSplitName($title); + + if ($candidate === null) { + continue; + } + + $candidate = $this->fileNameCleaner->normalizeCandidateTitle($candidate); + + if (! $this->fileNameCleaner->isPlausibleReleaseTitle($candidate)) { + continue; + } + + $this->releaseUpdateService->updateRelease( + $context->release, + $candidate, + 'NZBSPLIT wrapper', + true, + 'Filenames, ', + true, + true + ); + + $context->release->searchname = $candidate; + + return true; + } + + return false; + } + /** * Finalize release processing with status updates. */ @@ -519,7 +573,7 @@ class ReleaseFileManager $candidate = $this->normalizeCandidateTitle($candidate); if ($this->isPlausibleReleaseTitle($candidate)) { - (new ReleaseUpdateService)->updateRelease( + $this->releaseUpdateService->updateRelease( $context->release, $candidate, 'RarInfo FileName Match', @@ -545,7 +599,7 @@ class ReleaseFileManager $candidate = $this->normalizeCandidateTitle($candidate); if ($this->isPlausibleReleaseTitle($candidate)) { - (new ReleaseUpdateService)->updateRelease( + $this->releaseUpdateService->updateRelease( $context->release, $candidate, 'RarInfo FileName Match', @@ -566,6 +620,12 @@ class ReleaseFileManager private function extractReleaseNameFromFile(string $filename): ?string { $basename = basename($filename); + + $unwrapped = $this->fileNameCleaner->extractNzbSplitName($basename); + if ($unwrapped !== null) { + return $unwrapped; + } + $cleaned = preg_replace( '/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx|par2?|nfo|sfv|nzb|rar|zip|r\d{2,3}|pkg|exe|msi)$/i', '', @@ -588,13 +648,7 @@ class ReleaseFileManager */ private function normalizeCandidateTitle(string $title): string { - $t = trim($title); - $t = preg_replace('/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx)$/i', '', $t) ?? $t; - $t = preg_replace('/\.(par2?|nfo|sfv|nzb|rar|zip|r\d{2,3}|pkg|exe|msi|jpe?g|png|gif|bmp)$/i', '', $t) ?? $t; - $t = preg_replace('/[.\-_ ](?:part|vol|r)\d+(?:\+\d+)?$/i', '', $t) ?? $t; - $t = preg_replace('/[\s_]+/', ' ', $t) ?? $t; - - return trim($t, " .-_\t\r\n"); + return $this->fileNameCleaner->normalizeCandidateTitle($title); } /** @@ -602,30 +656,6 @@ class ReleaseFileManager */ private function isPlausibleReleaseTitle(string $title): bool { - $t = trim($title); - if ($t === '' || strlen($t) < 12) { - return false; - } - - $wordCount = preg_match_all('/[A-Za-z0-9]{3,}/', $t); - if ($wordCount < 2) { - return false; - } - - if (preg_match('/(?:^|[.\-_ ])(?:part|vol|r)\d+(?:\+\d+)?$/i', $t)) { - return false; - } - - if (preg_match('/^(setup|install|installer|patch|update|crack|keygen)\d*[\s._-]/i', $t)) { - return false; - } - - $hasGroupSuffix = (bool) preg_match('/[-.][A-Za-z0-9]{2,}$/', $t); - $hasYear = (bool) preg_match('/\b(19|20)\d{2}\b/', $t); - $hasQuality = (bool) preg_match('/\b(480p|720p|1080p|2160p|4k|webrip|web[ .-]?dl|bluray|bdrip|dvdrip|hdtv|hdrip|xvid|x264|x265|hevc|h\.?264|ts|cam|r5|proper|repack)\b/i', $t); - $hasTV = (bool) preg_match('/\bS\d{1,2}[Eex]\d{1,3}\b/i', $t); - $hasXXX = (bool) preg_match('/\bXXX\b/i', $t); - - return $hasGroupSuffix || ($hasTV && $hasQuality) || ($hasYear && ($hasQuality || $hasTV)) || $hasXXX; + return $this->fileNameCleaner->isPlausibleReleaseTitle($title); } } diff --git a/app/Services/AdditionalProcessing/ReleaseProcessor.php b/app/Services/AdditionalProcessing/ReleaseProcessor.php index c1579b285..30ae5e5b4 100644 --- a/app/Services/AdditionalProcessing/ReleaseProcessor.php +++ b/app/Services/AdditionalProcessing/ReleaseProcessor.php @@ -59,6 +59,7 @@ class ReleaseProcessor } $this->initializeContext($context); + $this->releaseManager->processReleaseNameFromNzbContents($context->nzbContents, $context); $messageIds = $this->nzbParser->extractMessageIDs($context->nzbContents, $context->releaseGroupName, $this->config); $context->nzbHasCompressedFile = $messageIds['hasCompressedFile']; diff --git a/app/Services/Categorization/CategorizationPipeline.php b/app/Services/Categorization/CategorizationPipeline.php index 01ccb7281..758271539 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\NzbSplitUnwrapper; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -31,10 +32,12 @@ class CategorizationPipeline protected bool $catWebDL; + protected NzbSplitUnwrapper $nzbSplitUnwrapper; + /** * @param iterable $pipes */ - public function __construct(iterable $pipes = []) + public function __construct(iterable $pipes = [], ?NzbSplitUnwrapper $nzbSplitUnwrapper = null) { /** @phpstan-ignore argument.templateType */ $this->pipes = collect($pipes) @@ -42,6 +45,7 @@ class CategorizationPipeline $this->categorizeForeign = (bool) Settings::settingValue('categorizeforeign'); $this->catWebDL = (bool) Settings::settingValue('catwebdl'); + $this->nzbSplitUnwrapper = $nzbSplitUnwrapper ?? new NzbSplitUnwrapper; } /** @@ -70,6 +74,8 @@ class CategorizationPipeline ?string $poster = '', bool $debug = false ): array { + $releaseName = $this->nzbSplitUnwrapper->unwrap($releaseName) ?? $releaseName; + $groupName = UsenetGroup::whereId($groupId)->value('name') ?? ''; $context = new ReleaseContext( diff --git a/app/Services/Categorization/Categorizers/TvCategorizer.php b/app/Services/Categorization/Categorizers/TvCategorizer.php index e2aae5b35..d7e87d7e4 100644 --- a/app/Services/Categorization/Categorizers/TvCategorizer.php +++ b/app/Services/Categorization/Categorizers/TvCategorizer.php @@ -13,6 +13,10 @@ use App\Services\Categorization\ReleaseContext; */ class TvCategorizer extends AbstractCategorizer { + private const string SPORTS_MARKER_REGEX = '/\b(NFL|NBA|NHL|MLB|MLS|EPL|UFC|WWE|Boxing|F1|Formula[._ -]?1|NASCAR|PGA|Tennis|Golf|Soccer|Football|Cricket|Rugby|Olympics?|Olympic[._ -]?Games?|Paralympics?)\b/i'; + + private const string SPORTS_EVENT_CONTEXT_REGEX = '/\d{4}|\b(Season|Week|Round|Match|Game|vs|Playoffs?|Finals?|Qualifying|Opening|Closing|Ceremony|Championship)\b/i'; + protected int $priority = 20; public function getName(): string @@ -97,6 +101,10 @@ class TvCategorizer extends AbstractCategorizer if (preg_match('/[._ -](19|20)\d{2}[._ -]\d{2}[._ -]\d{2}[._ -]/i', $name)) { return true; } + // Sports events are TV-like even without SxxExx markers. + if ($this->isSportEvent($name)) { + return true; + } // Known anime release groups (should be treated as TV) if (preg_match('/(?:^|[.\-_ \[])(URANiME|ANiHLS|HaiKU|ANiURL|SkyAnime|Erai-raws|LostYears|Vodes|SubsPlease|Judas|Ember|EMBER|YuiSubs|ASW|Tsundere-Raws|Anime-Raws|Kekkan)(?:[.\-_ \]]|$)/i', $name)) { return true; @@ -148,8 +156,7 @@ class TvCategorizer extends AbstractCategorizer protected function checkSport(string $name): ?CategorizationResult { - if (preg_match('/\b(NFL|NBA|NHL|MLB|MLS|UFC|WWE|Boxing|F1|Formula[._ -]?1|NASCAR|PGA|Tennis|Golf|Soccer|Football|Cricket|Rugby)\b/i', $name) && - preg_match('/\d{4}|\b(Season|Week|Round|Match|Game)\b/i', $name)) { + if ($this->isSportEvent($name)) { return $this->matched(Category::TV_SPORT, 0.85, 'sport'); } @@ -250,4 +257,10 @@ class TvCategorizer extends AbstractCategorizer return null; } + + protected function isSportEvent(string $name): bool + { + return preg_match(self::SPORTS_MARKER_REGEX, $name) === 1 + && preg_match(self::SPORTS_EVENT_CONTEXT_REGEX, $name) === 1; + } } diff --git a/app/Services/NameFixing/Extractors/FileNameExtractor.php b/app/Services/NameFixing/Extractors/FileNameExtractor.php index 0336ce663..756d4c1b7 100644 --- a/app/Services/NameFixing/Extractors/FileNameExtractor.php +++ b/app/Services/NameFixing/Extractors/FileNameExtractor.php @@ -37,6 +37,16 @@ class FileNameExtractor // Try each pattern in order of specificity + if (str_contains($filename, '__NZBSPLIT__')) { + $unwrapped = $this->cleaner->extractNzbSplitName($filename); + + if ($unwrapped !== null && $this->cleaner->isPlausibleReleaseTitle($unwrapped)) { + return NameFixResult::fromMatch($unwrapped, 'NZBSPLIT wrapper', 'File'); + } + + return null; + } + // Scene TV release with group suffix if (preg_match('/^(.+?(x264|x265|HEVC|XviD|H\.?264|H\.?265)\-[A-Za-z0-9]+)\\\\/i', $filename, $result)) { return NameFixResult::fromMatch($result[1], 'Scene release with group', 'File'); diff --git a/app/Services/NameFixing/FileNameCleaner.php b/app/Services/NameFixing/FileNameCleaner.php index 913266368..12e385023 100644 --- a/app/Services/NameFixing/FileNameCleaner.php +++ b/app/Services/NameFixing/FileNameCleaner.php @@ -16,6 +16,13 @@ class FileNameCleaner { use DetectsHashedNames; + protected NzbSplitUnwrapper $nzbSplitUnwrapper; + + public function __construct(?NzbSplitUnwrapper $nzbSplitUnwrapper = null) + { + $this->nzbSplitUnwrapper = $nzbSplitUnwrapper ?? new NzbSplitUnwrapper; + } + /** * Generic DVD structure filenames and similarly low-information names. * @@ -92,6 +99,8 @@ class FileNameCleaner // Extract filename from path $fileName = $this->extractFilenameFromPath($fileName); + $fileName = $this->extractNzbSplitName($fileName) ?? $fileName; + if ($this->isIgnorableForMatching($fileName)) { return false; } @@ -153,6 +162,8 @@ class FileNameCleaner { $t = trim($title); + $t = $this->extractNzbSplitName($t) ?? $t; + // Remove common video file extensions $t = preg_replace('/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx)$/i', '', $t) ?? $t; @@ -231,6 +242,8 @@ class FileNameCleaner */ public function cleanForTitleMatch(string $filename): string { + $filename = $this->extractNzbSplitName($filename) ?? $filename; + // Remove file extension $clean = preg_replace('/\.(mkv|avi|mp4|m4v|wmv|mpg|mpeg|mov|ts|m2ts|vob|divx|flv|nfo|sfv|nzb|srr|srs|rar|r\d{2,4}|zip|7z|par2?|vol\d+[\+\-]\d+|\d{3})$/i', '', $filename); @@ -271,6 +284,8 @@ class FileNameCleaner */ public function looksLikeSceneRelease(string $filename): bool { + $filename = $this->extractNzbSplitName($filename) ?? $filename; + $baseName = preg_replace('/\.[a-z0-9]{2,4}$/i', '', $filename); // Check for group suffix @@ -313,6 +328,11 @@ class FileNameCleaner return false; } + public function extractNzbSplitName(string $value): ?string + { + return $this->nzbSplitUnwrapper->unwrap($value); + } + /** * Ignore junk inputs that should never drive PreDB matches. */ diff --git a/app/Services/NameFixing/NameFixingService.php b/app/Services/NameFixing/NameFixingService.php index 5d91b0bc4..6c9b035df 100644 --- a/app/Services/NameFixing/NameFixingService.php +++ b/app/Services/NameFixing/NameFixingService.php @@ -1200,6 +1200,13 @@ class NameFixingService break; case 'Filenames, ': + // Try direct file name extraction first (handles NZBSPLIT wrappers) + if (! $this->updateService->matched) { + $result = $this->fileExtractor->extractFromFile($release->textstring); + if ($result !== null) { + $this->updateService->updateRelease($release, $result->newName, 'fileCheck: '.$result->method, $echo, $type, $nameStatus, $show); + } + } // Try PreDB file check if (! $this->updateService->matched) { $this->preDbFileCheck($release, $echo, $type, $nameStatus, $show); diff --git a/app/Services/NameFixing/NzbSplitUnwrapper.php b/app/Services/NameFixing/NzbSplitUnwrapper.php new file mode 100644 index 000000000..17f4ddaf6 --- /dev/null +++ b/app/Services/NameFixing/NzbSplitUnwrapper.php @@ -0,0 +1,75 @@ + + */ + 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}$/', + ]; + + /** + * @var list + */ + private const array TRAILING_SUBJECT_PATTERNS = [ + '/"\s*yEnc(?:\s*\([^)]*\))?$/i', + '/\s+yEnc(?:\s*\([^)]*\))?$/i', + '/"\s*yEnc.*$/i', + '/\s+yEnc.*$/i', + ]; + + public function unwrap(string $value): ?string + { + if (! preg_match(self::WRAPPER_PATTERN, $value, $matches)) { + return null; + } + + $title = $matches[2]; + $didStrip = true; + + while ($didStrip) { + $didStrip = false; + + foreach (self::TRAILING_SUBJECT_PATTERNS as $pattern) { + $updated = preg_replace($pattern, '', $title); + + if ($updated !== null && $updated !== $title) { + $title = rtrim($updated, " \t\n\r\0\x0B\"'"); + $didStrip = true; + } + } + + foreach (self::TRAILING_ARCHIVE_PATTERNS as $pattern) { + $updated = preg_replace($pattern, '', $title); + + if ($updated !== null && $updated !== $title) { + $title = $updated; + $didStrip = true; + } + } + } + + $title = str_replace(['_', '+'], ['.', ' '], $title); + $title = preg_replace('/\.{2,}/', '.', $title) ?? $title; + $title = preg_replace('/\s{2,}/', ' ', $title) ?? $title; + $title = preg_replace('/\.\s+/', '.', $title) ?? $title; + $title = preg_replace('/\s+\./', '.', $title) ?? $title; + $title = trim($title, " \t\n\r\0\x0B.-_"); + + return $title !== '' ? $title : null; + } +} diff --git a/app/Services/ReleaseCleaningService.php b/app/Services/ReleaseCleaningService.php index 8162c10b0..c2e9d9165 100644 --- a/app/Services/ReleaseCleaningService.php +++ b/app/Services/ReleaseCleaningService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services; use App\Models\Predb; +use App\Services\NameFixing\NzbSplitUnwrapper; /** * Cleans names for releases/imports/namefixer. @@ -48,6 +49,8 @@ class ReleaseCleaningService protected RegexService $_regexes; + protected NzbSplitUnwrapper $nzbSplitUnwrapper; + /** * ReleaseCleaningService constructor. * @@ -60,6 +63,7 @@ class ReleaseCleaningService $this->e1 = CollectionsCleaningService::REGEX_FILE_EXTENSIONS.CollectionsCleaningService::REGEX_END; $this->e2 = CollectionsCleaningService::REGEX_FILE_EXTENSIONS.CollectionsCleaningService::REGEX_SUBJECT_SIZE.CollectionsCleaningService::REGEX_END; $this->_regexes = new RegexService('release_naming_regexes'); + $this->nzbSplitUnwrapper = new NzbSplitUnwrapper; } /** @@ -73,6 +77,19 @@ class ReleaseCleaningService $this->fromName = $fromName; $this->groupName = $groupName; + $unwrappedSubject = $this->nzbSplitUnwrapper->unwrap($subject); + if ($unwrappedSubject !== null) { + $this->subject = $unwrappedSubject; + + return [ + 'cleansubject' => $unwrappedSubject, + 'properlynamed' => true, + 'increment' => false, + 'predb' => 0, + 'requestid' => false, + ]; + } + $hit = $hits = []; // Get pre style name from releases.name if (preg_match_all( @@ -429,7 +446,7 @@ class ReleaseCleaningService */ public function fixerCleaner(string $name) { - $cleanerName = $name; + $cleanerName = $this->nzbSplitUnwrapper->unwrap($name) ?? $name; // Remove sample/proof/thumbs markers from the end $cleanerName = preg_replace('/[.\-_](sample|proof|thumbs?)$/i', '', $cleanerName); diff --git a/composer.lock b/composer.lock index 59172b598..df5a0c08b 100644 --- a/composer.lock +++ b/composer.lock @@ -1934,16 +1934,16 @@ }, { "name": "laravel/framework", - "version": "v13.5.0", + "version": "v13.6.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ffa1850049a691b93129808f27ecd10e65c9d1a5" + "reference": "416a93ea9c53161e0d4b8a44045f447b65a7d2f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ffa1850049a691b93129808f27ecd10e65c9d1a5", - "reference": "ffa1850049a691b93129808f27ecd10e65c9d1a5", + "url": "https://api.github.com/repos/laravel/framework/zipball/416a93ea9c53161e0d4b8a44045f447b65a7d2f1", + "reference": "416a93ea9c53161e0d4b8a44045f447b65a7d2f1", "shasum": "" }, "require": { @@ -2153,20 +2153,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-14T13:55:03+00:00" + "time": "2026-04-21T13:32:11+00:00" }, { "name": "laravel/horizon", - "version": "v5.45.6", + "version": "v5.46.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "629707ff3cce9ff72715e7815f74dda10bdcbe0a" + "reference": "bfea968e8aa674fb649d02e55ea0d38bdf5137d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/629707ff3cce9ff72715e7815f74dda10bdcbe0a", - "reference": "629707ff3cce9ff72715e7815f74dda10bdcbe0a", + "url": "https://api.github.com/repos/laravel/horizon/zipball/bfea968e8aa674fb649d02e55ea0d38bdf5137d5", + "reference": "bfea968e8aa674fb649d02e55ea0d38bdf5137d5", "shasum": "" }, "require": { @@ -2231,9 +2231,9 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.45.6" + "source": "https://github.com/laravel/horizon/tree/v5.46.0" }, - "time": "2026-04-14T13:31:53+00:00" + "time": "2026-04-20T18:08:11+00:00" }, { "name": "laravel/prompts", @@ -11848,16 +11848,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.4", + "version": "v2.4.5", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "db101b977897e00c6d2e40e9b610591cb0aa277e" + "reference": "60386c7723ff7cb388b62b6c137597244a9cf2f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/db101b977897e00c6d2e40e9b610591cb0aa277e", - "reference": "db101b977897e00c6d2e40e9b610591cb0aa277e", + "url": "https://api.github.com/repos/laravel/boost/zipball/60386c7723ff7cb388b62b6c137597244a9cf2f2", + "reference": "60386c7723ff7cb388b62b6c137597244a9cf2f2", "shasum": "" }, "require": { @@ -11866,7 +11866,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/mcp": "^0.5.1|^0.6.0|^0.7.0", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -11910,20 +11910,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-04-16T16:53:05+00:00" + "time": "2026-04-22T13:29:20+00:00" }, { "name": "laravel/mcp", - "version": "v0.6.7", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2" + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2", - "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", "shasum": "" }, "require": { @@ -11983,7 +11983,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-04-15T08:30:42+00:00" + "time": "2026-04-21T10:23:03+00:00" }, { "name": "laravel/pint", @@ -12450,23 +12450,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.3", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/b0d8ab95b29c3189aeeb902d81215231df4c1b64", - "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.8 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -12474,12 +12474,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.3", - "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", - "laravel/pint": "^1.29.0", - "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -12542,7 +12542,7 @@ "type": "patreon" } ], - "time": "2026-04-06T19:25:53+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "phar-io/manifest", @@ -12664,11 +12664,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.1.51", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", "shasum": "" }, "require": { @@ -12713,7 +12713,7 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-04-21T18:22:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -14953,5 +14953,5 @@ "ext-zlib": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/tests/Feature/AdditionalProcessingNzbSplitRenameTest.php b/tests/Feature/AdditionalProcessingNzbSplitRenameTest.php new file mode 100644 index 000000000..21f9fbab8 --- /dev/null +++ b/tests/Feature/AdditionalProcessingNzbSplitRenameTest.php @@ -0,0 +1,125 @@ + 123, + 'releases_id' => 123, + 'name' => $wrapped, + 'searchname' => $wrapped, + 'fromname' => 'poster@example.com', + 'guid' => str_repeat('a', 40), + 'groups_id' => 1, + 'categories_id' => Category::OTHER_HASHED, + ]); + + $context = new ReleaseProcessingContext($release); + $updateService = $this->createMock(ReleaseUpdateService::class); + $updateService->expects($this->once()) + ->method('updateRelease') + ->with( + $release, + $expected, + 'NZBSPLIT wrapper', + true, + 'Filenames, ', + true, + true + ); + + $manager = $this->makeReleaseFileManager($updateService); + + $renamed = $manager->processReleaseNameFromNzbContents([ + ['title' => $wrapped], + ], $context); + + $this->assertTrue($renamed); + $this->assertSame($expected, $context->release->searchname); + } + + public function test_additional_postprocessing_skips_low_information_nzb_split_payloads(): void + { + $wrapped = 'TEST__NZBSPLIT__1234567890abcdef__NZBSPLIT__setup.7z.001'; + $release = new Release([ + 'id' => 124, + 'releases_id' => 124, + 'name' => $wrapped, + 'searchname' => $wrapped, + 'groups_id' => 1, + 'categories_id' => Category::OTHER_HASHED, + ]); + + $context = new ReleaseProcessingContext($release); + $updateService = $this->createMock(ReleaseUpdateService::class); + $updateService->expects($this->never())->method('updateRelease'); + + $manager = $this->makeReleaseFileManager($updateService); + + $renamed = $manager->processReleaseNameFromNzbContents([ + ['title' => $wrapped], + ], $context); + + $this->assertFalse($renamed); + $this->assertSame($wrapped, $context->release->searchname); + } + + public function test_archive_name_extractor_unwraps_nzb_split_file_names(): void + { + $manager = $this->makeReleaseFileManager(); + $extractMethod = new \ReflectionMethod($manager, 'extractReleaseNameFromFile'); + + $result = $extractMethod->invoke( + $manager, + 'MLB__NZBSPLIT__f5054661a1d468cc6a8712de8217c1c9__NZBSPLIT__MLB_2026_Cincinnati_Reds_vs_Tampa_Bay_Rays_20_04_720pEN60fps.7z.075' + ); + + $this->assertSame('MLB.2026.Cincinnati.Reds.vs.Tampa.Bay.Rays.20.04.720pEN60fps', $result); + } + + private function makeReleaseFileManager(?ReleaseUpdateService $updateService = null): ReleaseFileManager + { + $updateService ??= $this->createMock(ReleaseUpdateService::class); + + return new ReleaseFileManager( + $this->makeConfiguration(), + $this->createMock(ReleaseImageService::class), + $this->createMock(NfoService::class), + $this->createMock(NzbService::class), + $this->createMock(NameFixingService::class), + $updateService, + new FileNameCleaner + ); + } + + private function makeConfiguration(): ProcessingConfiguration + { + $reflection = new \ReflectionClass(ProcessingConfiguration::class); + + /** @var ProcessingConfiguration $config */ + $config = $reflection->newInstanceWithoutConstructor(); + + return $config; + } +} diff --git a/tests/Feature/ReleaseNameFixedRecategorizationTest.php b/tests/Feature/ReleaseNameFixedRecategorizationTest.php index d7560ffa7..093127922 100644 --- a/tests/Feature/ReleaseNameFixedRecategorizationTest.php +++ b/tests/Feature/ReleaseNameFixedRecategorizationTest.php @@ -131,6 +131,54 @@ class ReleaseNameFixedRecategorizationTest extends TestCase $this->assertSame(1, (int) $release->isrenamed); } + public function test_renaming_olympic_webdl_release_recategorizes_it_from_movie_webdl_to_tv_sport(): void + { + Search::shouldReceive('updateRelease')->twice(); + + $group = UsenetGroup::query()->create([ + 'name' => 'alt.binaries.hdtv', + 'active' => 1, + 'backfill' => 0, + ]); + + $oldName = 'WinterOlympics2026__NZBSPLIT__0456f274737cea074abd86a89144cc7b__NZBSPLIT__Winter_Olympic_Games_Milano_Cortina_2026_Closing_Ceremony_1080p25_WEB-DL_(MultiAudio).7z.065'; + $newName = 'Winter.Olympic.Games.Milano.Cortina.2026.Closing.Ceremony.1080p25.WEB-DL.(MultiAudio)'; + + $release = Release::factory()->create([ + 'name' => $oldName, + 'searchname' => $oldName, + 'fromname' => 'poster@example.com', + 'groups_id' => $group->id, + 'categories_id' => Category::MOVIE_WEBDL, + 'iscategorized' => 1, + 'isrenamed' => 0, + 'guid' => str_repeat('b', 40), + 'leftguid' => 'b', + 'nzb_guid' => 'test-olympics', + 'size' => 1, + 'postdate' => now(), + 'adddate' => now(), + ]); + + $service = app(ReleaseUpdateService::class); + $service->updateRelease( + $release->fresh(), + $newName, + 'NZBSPLIT wrapper', + true, + 'Filenames, ', + true, + false, + ); + + $release->refresh(); + + $this->assertSame($newName, $release->searchname); + $this->assertSame(Category::TV_SPORT, $release->categories_id); + $this->assertSame(1, (int) $release->iscategorized); + $this->assertSame(1, (int) $release->isrenamed); + } + private function setEnvironmentValue(string $key, ?string $value): void { if ($value === null) { diff --git a/tests/Unit/CategorizeSportTest.php b/tests/Unit/CategorizeSportTest.php new file mode 100644 index 000000000..d5e938c8c --- /dev/null +++ b/tests/Unit/CategorizeSportTest.php @@ -0,0 +1,82 @@ + + */ + public static function nzbSplitSportProvider(): array + { + return [ + 'mlb reds rays' => [ + 'MLB__NZBSPLIT__f5054661a1d468cc6a8712de8217c1c9__NZBSPLIT__MLB_2026_Cincinnati_Reds_vs_Tampa_Bay_Rays_20_04_720pEN60fps.7z.075', + 'MLB.2026.Cincinnati.Reds.vs.Tampa.Bay.Rays.20.04.720pEN60fps', + ], + 'winter olympics closing ceremony' => [ + 'WinterOlympics2026__NZBSPLIT__0456f274737cea074abd86a89144cc7b__NZBSPLIT__Winter_Olympic_Games_Milano_Cortina_2026_Closing_Ceremony_1080p25_WEB-DL_(MultiAudio).7z.065', + 'Winter.Olympic.Games.Milano.Cortina.2026.Closing.Ceremony.1080p25.WEB-DL.(MultiAudio)', + ], + 'nba playoffs wolves nuggets' => [ + 'NBA__NZBSPLIT__9b1e889d7fb8d8a63c21217266069c71__NZBSPLIT__NBA_2026_Playoffs_Minnesota_Timberwolves_vs_Denver_Nuggets_Game_2_20_04_1080pEN60fps_NBC.7z.074', + 'NBA.2026.Playoffs.Minnesota.Timberwolves.vs.Denver.Nuggets.Game.2.20.04.1080pEN60fps.NBC', + ], + 'nba abc feed' => [ + 'NBA__NZBSPLIT__bdab31d6f79989608009e7e8eadcbe66__NZBSPLIT__NBA_20260419_PHI_BOS_1080p60_ABC.7z.073', + 'NBA.20260419.PHI.BOS.1080p60.ABC', + ], + ]; + } + + #[DataProvider('nzbSplitSportProvider')] + public function test_unwrapped_nzb_split_sports_are_classified_as_tv_sport(string $wrapped, string $expectedName): void + { + $unwrapper = new NzbSplitUnwrapper; + $categorizer = new TvCategorizer; + + $unwrapped = $unwrapper->unwrap($wrapped); + + $this->assertSame($expectedName, $unwrapped); + + $context = new ReleaseContext( + releaseName: $unwrapped ?? '', + groupId: 0, + groupName: '', + poster: '' + ); + + $result = $categorizer->categorize($context); + + $this->assertTrue($result->isSuccessful(), "Expected TV sport match for: {$wrapped}"); + $this->assertSame(Category::TV_SPORT, $result->categoryId, "Expected TV_SPORT for: {$wrapped}"); + $this->assertNotSame(Category::MOVIE_HD, $result->categoryId); + } + + public function test_plain_epl_release_is_classified_as_tv_sport(): void + { + $categorizer = new TvCategorizer; + + $context = new ReleaseContext( + releaseName: 'EPL.2026.Tottenham.vs.Brighton.18.04.720pEN60fps.Fubo', + groupId: 0, + groupName: '', + poster: '' + ); + + $result = $categorizer->categorize($context); + + $this->assertTrue($result->isSuccessful()); + $this->assertSame(Category::TV_SPORT, $result->categoryId); + $this->assertNotSame(Category::MOVIE_HD, $result->categoryId); + } +} diff --git a/tests/Unit/FileNameExtractorTest.php b/tests/Unit/FileNameExtractorTest.php new file mode 100644 index 000000000..402cfb493 --- /dev/null +++ b/tests/Unit/FileNameExtractorTest.php @@ -0,0 +1,36 @@ +extractFromFile( + 'NBA__NZBSPLIT__bdab31d6f79989608009e7e8eadcbe66__NZBSPLIT__NBA_20260419_PHI_BOS_1080p60_ABC.7z.073' + ); + + $this->assertNotNull($result); + $this->assertSame('NBA.20260419.PHI.BOS.1080p60.ABC', $result->newName); + $this->assertSame('NZBSPLIT wrapper', $result->method); + $this->assertSame('File', $result->checkerName); + } + + public function test_rejects_low_information_nzb_split_payloads(): void + { + $extractor = new FileNameExtractor; + + $result = $extractor->extractFromFile( + 'TEST__NZBSPLIT__1234567890abcdef__NZBSPLIT__setup.7z.001' + ); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/NzbSplitUnwrapperTest.php b/tests/Unit/NzbSplitUnwrapperTest.php new file mode 100644 index 000000000..8e74ae39d --- /dev/null +++ b/tests/Unit/NzbSplitUnwrapperTest.php @@ -0,0 +1,85 @@ + + */ + public static function wrappedReleaseProvider(): array + { + return [ + 'winter olympics closing ceremony' => [ + 'WinterOlympics2026__NZBSPLIT__0456f274737cea074abd86a89144cc7b__NZBSPLIT__Winter_Olympic_Games_Milano_Cortina_2026_Closing_Ceremony_1080p25_WEB-DL_(MultiAudio).7z.065', + 'Winter.Olympic.Games.Milano.Cortina.2026.Closing.Ceremony.1080p25.WEB-DL.(MultiAudio)', + ], + 'winter olympics with yenc tail' => [ + 'WinterOlympics2026__NZBSPLIT__0456f274737cea074abd86a89144cc7b__NZBSPLIT__Winter_Olympic_Games_Milano_Cortina_2026_Closing_Ceremony_1080p25_WEB-DL_(MultiAudio).7z.065" yEnc(1', + 'Winter.Olympic.Games.Milano.Cortina.2026.Closing.Ceremony.1080p25.WEB-DL.(MultiAudio)', + ], + 'mlb reds rays' => [ + 'MLB__NZBSPLIT__f5054661a1d468cc6a8712de8217c1c9__NZBSPLIT__MLB_2026_Cincinnati_Reds_vs_Tampa_Bay_Rays_20_04_720pEN60fps.7z.075', + 'MLB.2026.Cincinnati.Reds.vs.Tampa.Bay.Rays.20.04.720pEN60fps', + ], + 'nba playoffs wolves nuggets' => [ + 'NBA__NZBSPLIT__9b1e889d7fb8d8a63c21217266069c71__NZBSPLIT__NBA_2026_Playoffs_Minnesota_Timberwolves_vs_Denver_Nuggets_Game_2_20_04_1080pEN60fps_NBC.7z.074', + 'NBA.2026.Playoffs.Minnesota.Timberwolves.vs.Denver.Nuggets.Game.2.20.04.1080pEN60fps.NBC', + ], + 'nba abc feed' => [ + 'NBA__NZBSPLIT__bdab31d6f79989608009e7e8eadcbe66__NZBSPLIT__NBA_20260419_PHI_BOS_1080p60_ABC.7z.073', + 'NBA.20260419.PHI.BOS.1080p60.ABC', + ], + 'mlb blue jays diamondbacks' => [ + 'MLB__NZBSPLIT__bc07883ca7b3c29e5cca57b8ebef3ef7__NZBSPLIT__MLB_2026_Toronto_Maple_Leafs_vs_Arizona_Diamondbacks_19_04_720pEN60fps_SN1.7z.028', + 'MLB.2026.Toronto.Maple.Leafs.vs.Arizona.Diamondbacks.19.04.720pEN60fps.SN1', + ], + 'mlb padres dodgers' => [ + 'MLB__NZBSPLIT__9a0239e72a5201f889929a81eac53d9a__NZBSPLIT__MLB_2026_San_Diego_Padres_vs_Los_Angeles_Angels_19_04_720pEN60fps.7z.057', + 'MLB.2026.San.Diego.Padres.vs.Los.Angeles.Angels.19.04.720pEN60fps', + ], + 'multipart rar' => [ + 'TV__NZBSPLIT__1234567890abcdef__NZBSPLIT__Some_Show_2026_Episode_01_1080p_WEB-DL.part01.rar', + 'Some.Show.2026.Episode.01.1080p.WEB-DL', + ], + 'scene rxx archive' => [ + 'MOVIE__NZBSPLIT__1234567890abcdef__NZBSPLIT__Some_Movie_2026_1080p_WEB-DL-GROUP.r05', + 'Some.Movie.2026.1080p.WEB-DL-GROUP', + ], + ]; + } + + /** + * @return array + */ + public static function invalidWrappedReleaseProvider(): array + { + return [ + 'missing marker' => ['Regular.Release.Name.2026.1080p.WEB-DL'], + 'non hex hash' => ['NBA__NZBSPLIT__not-a-hex-hash__NZBSPLIT__NBA_2026_Finals_Game_1_1080p.7z.001'], + 'short hash' => ['NBA__NZBSPLIT__abc1234__NZBSPLIT__NBA_2026_Finals_Game_1_1080p.7z.001'], + ]; + } + + #[DataProvider('wrappedReleaseProvider')] + public function test_unwrap_returns_embedded_release_name(string $wrapped, string $expected): void + { + $unwrapper = new NzbSplitUnwrapper; + + $this->assertSame($expected, $unwrapper->unwrap($wrapped)); + } + + #[DataProvider('invalidWrappedReleaseProvider')] + public function test_unwrap_returns_null_for_invalid_or_non_wrapped_input(string $wrapped): void + { + $unwrapper = new NzbSplitUnwrapper; + + $this->assertNull($unwrapper->unwrap($wrapped)); + } +}