Improve categorization

This commit is contained in:
DariusIII
2026-04-22 21:26:09 +02:00
parent bbc54feaf2
commit 44e9f63b68
16 changed files with 746 additions and 78 deletions
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Facades\Search;
use App\Models\Release;
use App\Services\Categorization\CategorizationService;
use App\Services\NameFixing\NzbSplitUnwrapper;
use Illuminate\Console\Command;
class FixNzbSplitNames extends Command
{
protected $signature = 'releases:fix-nzbsplit
{--dry-run : Preview changes without updating the database}';
protected $description = 'Fix NZBSPLIT-wrapped release names and recategorize the affected releases.';
public function handle(CategorizationService $categorizationService, NzbSplitUnwrapper $nzbSplitUnwrapper): int
{
$query = Release::query()
->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;
}
}
@@ -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<array<string, mixed>> $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);
}
}
@@ -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'];
@@ -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<AbstractCategorizationPipe> $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(
@@ -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;
}
}
@@ -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');
@@ -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.
*/
@@ -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);
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services\NameFixing;
final class NzbSplitUnwrapper
{
private const string WRAPPER_PATTERN = '/__NZBSPLIT__([a-f0-9]{8,})__NZBSPLIT__(.+)$/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}$/',
];
/**
* @var list<string>
*/
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;
}
}
+18 -1
View File
@@ -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);
Generated
+38 -38
View File
@@ -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"
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Release;
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
use App\Services\AdditionalProcessing\ReleaseFileManager;
use App\Services\NameFixing\FileNameCleaner;
use App\Services\NameFixing\NameFixingService;
use App\Services\NameFixing\ReleaseUpdateService;
use App\Services\NfoService;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use PHPUnit\Framework\TestCase;
class AdditionalProcessingNzbSplitRenameTest extends TestCase
{
public function test_additional_postprocessing_renames_nzb_split_release_from_nzb_titles(): void
{
$wrapped = 'WinterOlympics2026__NZBSPLIT__0456f274737cea074abd86a89144cc7b__NZBSPLIT__Winter_Olympic_Games_Milano_Cortina_2026_Closing_Ceremony_1080p25_WEB-DL_(MultiAudio).7z.065" yEnc(1';
$expected = 'Winter.Olympic.Games.Milano.Cortina.2026.Closing.Ceremony.1080p25.WEB-DL.(MultiAudio)';
$release = new Release([
'id' => 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;
}
}
@@ -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) {
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Models\Category;
use App\Services\Categorization\Categorizers\TvCategorizer;
use App\Services\Categorization\ReleaseContext;
use App\Services\NameFixing\NzbSplitUnwrapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class CategorizeSportTest extends TestCase
{
/**
* @return array<string, array{0: string, 1: string}>
*/
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);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\NameFixing\Extractors\FileNameExtractor;
use PHPUnit\Framework\TestCase;
class FileNameExtractorTest extends TestCase
{
public function test_extracts_release_name_from_nzb_split_wrapper(): void
{
$extractor = new FileNameExtractor;
$result = $extractor->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);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\NameFixing\NzbSplitUnwrapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class NzbSplitUnwrapperTest extends TestCase
{
/**
* @return array<string, array{0: string, 1: string}>
*/
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<string, array{0: string}>
*/
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));
}
}