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);