Files
newznab-tmux/app/Services/NameFixing/FileNameCleaner.php
T
2026-04-23 17:01:28 +02:00

410 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\NameFixing;
use App\Traits\DetectsHashedNames;
/**
* Utility class for cleaning and normalizing filenames.
*
* Extracts filename cleaning logic from NameFixer for better testability
* and reusability.
*/
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.
*
* @var list<string>
*/
private const IGNORABLE_NAME_PATTERNS = [
'/^(?:audio|video)[._-]?ts$/i',
'/^vts[._-]?\d{1,2}[._-]?\d{1,2}$/i',
'/^\d{1,3}$/',
];
/**
* Archive extension patterns to remove.
*/
private const ARCHIVE_PATTERNS = [
'/\.part\d{1,4}\.rar$/i',
'/\.r\d{2,4}$/i',
'/\.rar$/i',
'/\.z\d{2}$/i',
'/\.zip$/i',
'/\.7z\.\d{3}$/i',
'/\.7z$/i',
'/\.vol\d+[\+\-]\d+\.par2?$/i',
'/\.par2?$/i',
'/\.(tar|gz|bz2|xz|lz|lzma|cab|arj|ace|arc)$/i',
'/\.\d{3}$/i',
];
/**
* Video file extension pattern.
*/
private const VIDEO_EXTENSIONS = '/\.(mkv|avi|mp4|m4v|wmv|mpg|mpeg|mov|ts|m2ts|vob|divx|flv|webm|ogv|3gp|asf|rm|rmvb|f4v)$/i';
/**
* Audio file extension pattern.
*/
private const AUDIO_EXTENSIONS = '/\.(mp3|flac|m4a|aac|ogg|wav|wma|ape|opus|mka|ac3|dts|eac3|truehd|mpc|shn|tak|tta|wv)$/i';
/**
* Image and misc file extension pattern.
*/
private const IMAGE_EXTENSIONS = '/\.(nfo|sfv|nzb|srr|srs|jpg|jpeg|png|gif|bmp|tiff?|webp|pdf|txt|diz|md5|sha1|cue|log)$/i';
/**
* Ebook file extension pattern.
*/
private const EBOOK_EXTENSIONS = '/\.(epub|mobi|azw3?|pdf|djvu|cbr|cbz|fb2|lit|prc|opf)$/i';
/**
* Game/App file extension pattern.
*/
private const GAMEAPP_EXTENSIONS = '/\.(iso|bin|cue|mdf|mds|nrg|img|ccd|sub|exe|msi|dmg|pkg|apk|xap|appx|deb|rpm)$/i';
/**
* Subtitle file extension pattern.
*/
private const SUBTITLE_EXTENSIONS = '/\.(srt|sub|idx|ass|ssa|vtt|sup)$/i';
/**
* Clean a filename for PreDB matching.
*
* @param string $fileName The filename to clean
* @return string|false The cleaned filename or false if invalid
*/
public function cleanForMatching(string $fileName): string|false
{
// Strip non-printing characters
$fileName = preg_replace('/[[:^print:]]/', '', $fileName);
if ($fileName === '' || str_starts_with($fileName, '.')) {
return false;
}
// Extract filename from path
$fileName = $this->extractFilenameFromPath($fileName);
$fileName = $this->extractNzbSplitName($fileName) ?? $fileName;
if ($this->isIgnorableForMatching($fileName)) {
return false;
}
// Remove sample/proof indicators
$fileName = preg_replace('/[\.\-_](sample|proof|subs?|thumbs?|cover|screens?)[\.\-_]?$/i', '', $fileName);
// Remove archive extensions
foreach (self::ARCHIVE_PATTERNS as $pattern) {
$fileName = preg_replace($pattern, '', $fileName);
}
// Remove media extensions
$fileName = preg_replace(self::VIDEO_EXTENSIONS, '', $fileName);
$fileName = preg_replace(self::AUDIO_EXTENSIONS, '', $fileName);
$fileName = preg_replace(self::IMAGE_EXTENSIONS, '', $fileName);
$fileName = preg_replace(self::EBOOK_EXTENSIONS, '', $fileName);
$fileName = preg_replace(self::GAMEAPP_EXTENSIONS, '', $fileName);
$fileName = preg_replace(self::SUBTITLE_EXTENSIONS, '', $fileName);
// Remove part/volume indicators
$fileName = preg_replace('/[\.\-_]?(part|vol|cd|dvd|disc|disk)\d*$/i', '', $fileName);
// Remove leading track numbers
$fileName = preg_replace('/^\d{1,3}[\.\-_\s]+(?=[A-Za-z])/', '', $fileName);
// Trim whitespace and punctuation
$fileName = trim($fileName, " \t\n\r\0\x0B.-_");
if ($fileName === '' || $this->looksLikeStructuralJunk($fileName)) {
return false;
}
return $fileName;
}
/**
* Extract filename from a path.
*
* @param string $path Full path or filename
* @return string The filename portion
*/
public function extractFilenameFromPath(string $path): string
{
if (preg_match('/[\\\\\/]([^\\\\\/]+)$/', $path, $match)) {
return $match[1];
}
return $path;
}
/**
* Normalize a candidate title.
*
* @param string $title The title to normalize
* @return string The normalized title
*/
public function normalizeCandidateTitle(string $title): string
{
$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;
// 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;
// Collapse multiple spaces/underscores
$t = preg_replace('/[\s_]+/', ' ', $t) ?? $t;
// Trim stray punctuation
return trim($t, " .-_\t\r\n");
}
/**
* Format a candidate for storage in searchname.
*/
public function formatSearchName(string $title, ?string $normalizedFallback = null): string
{
$formatted = trim($title);
$formatted = $this->extractNzbSplitName($formatted) ?? $formatted;
// Remove common media/archive extensions while keeping scene separators.
$formatted = preg_replace(self::VIDEO_EXTENSIONS, '', $formatted) ?? $formatted;
$formatted = preg_replace(self::AUDIO_EXTENSIONS, '', $formatted) ?? $formatted;
$formatted = preg_replace(self::IMAGE_EXTENSIONS, '', $formatted) ?? $formatted;
$formatted = preg_replace(self::EBOOK_EXTENSIONS, '', $formatted) ?? $formatted;
$formatted = preg_replace(self::GAMEAPP_EXTENSIONS, '', $formatted) ?? $formatted;
$formatted = preg_replace(self::SUBTITLE_EXTENSIONS, '', $formatted) ?? $formatted;
foreach (self::ARCHIVE_PATTERNS as $pattern) {
$formatted = preg_replace($pattern, '', $formatted) ?? $formatted;
}
$formatted = preg_replace('/[.\-_ ](?:part|vol|r)\d+(?:\+\d+)?$/i', '', $formatted) ?? $formatted;
$formatted = trim($formatted, " .-_\t\r\n");
if ($formatted === '') {
return $normalizedFallback ?? $this->normalizeCandidateTitle($title);
}
if (! $this->looksLikeSceneRelease($formatted)) {
return $normalizedFallback ?? $this->normalizeCandidateTitle($title);
}
$formatted = preg_replace('/[ _]+/', '.', $formatted) ?? $formatted;
$formatted = preg_replace('/\.{2,}/', '.', $formatted) ?? $formatted;
return trim($formatted, " .-_\t\r\n");
}
/**
* Check if a title is plausible for a release.
*
* @param string $title The title to check
* @return bool True if the title looks like a valid release name
*/
public 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 ($this->looksLikeHashedName($t)) {
return false;
}
// Reject if still ends with segment marker
if (preg_match('/(?:^|[.\-_ ])(?:part|vol|r)\d+(?:\+\d+)?$/i', $t)) {
return false;
}
// Reject generic installer filenames
if (preg_match('/^(setup|install|installer|patch|update|crack|keygen)\d*[\s._-]/i', $t)) {
return false;
}
// Check for valid release indicators
$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 || $hasQuality || $hasTV;
}
/**
* Check if a string looks like a hashed/obfuscated name.
*
* @param string $title The title to check
* @return bool True if the title appears to be hashed/obfuscated
*/
public function looksLikeHashedName(string $title): bool
{
return $this->isHashedOrGibberish($title);
}
/**
* Clean filename for title matching against PreDB.
*
* @param string $filename The filename to clean
* @return string The cleaned filename
*/
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);
// Remove part/volume indicators
$clean = preg_replace('/[\.\-_]?(part|vol|cd|dvd|disc|disk)\d+$/i', '', $clean);
// Remove sample/proof indicators
$clean = preg_replace('/[\.\-_](sample|proof|subs?)$/i', '', $clean);
return trim($clean, " \t\n\r\0\x0B.-_");
}
/**
* Normalize quality indicators in filenames.
*/
public function normalizeQualityIndicators(string $fileName): string
{
$qualityMap = [
'/\.4k$/i' => '.2160p',
'/\.fullhd$/i' => '.1080p',
'/\.hd$/i' => '.720p',
'/\.int$/i' => '.INTERNAL',
'/\.internal$/i' => '.INTERNAL',
];
foreach ($qualityMap as $pattern => $replacement) {
$fileName = preg_replace($pattern, $replacement, $fileName);
}
return $fileName;
}
/**
* Check if a filename looks like a scene release.
*
* @param string $filename The filename to check
* @return bool True if it appears to be a scene release
*/
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
if (! preg_match('/\-[A-Za-z0-9]{2,15}$/', $baseName)) {
return false;
}
// Check for word separation
if (! preg_match('/[._-]/', $baseName)) {
return false;
}
// Check for common scene tags
$sceneTags = [
'720p', '1080p', '2160p', '4k',
'x264', 'x265', 'hevc', 'xvid', 'divx',
'bluray', 'bdrip', 'dvdrip', 'hdtv', 'webrip', 'web-dl', 'webdl',
'aac', 'ac3', 'dts', 'flac', 'mp3',
'proper', 'repack', 'internal', 'retail',
'pal', 'ntsc', 'multi', 'dual',
];
$baseNameLower = strtolower($baseName);
foreach ($sceneTags as $tag) {
if (str_contains($baseNameLower, $tag)) {
return true;
}
}
// Check for TV episode patterns
if (preg_match('/s\d{1,2}e\d{1,3}/i', $baseName)) {
return true;
}
// Check for year pattern
if (preg_match('/[._-](19|20)\d{2}[._-]/i', $baseName)) {
return true;
}
return false;
}
public function extractNzbSplitName(string $value): ?string
{
return $this->nzbSplitUnwrapper->unwrap($value);
}
/**
* Ignore junk inputs that should never drive PreDB matches.
*/
protected function isIgnorableForMatching(string $fileName): bool
{
if (preg_match('/\.url$/i', $fileName)) {
return true;
}
return $this->looksLikeStructuralJunk((string) pathinfo($fileName, PATHINFO_FILENAME));
}
/**
* Detect low-information DVD structure names and numeric image files.
*/
protected function looksLikeStructuralJunk(string $fileName): bool
{
$normalized = strtolower(trim($fileName, " \t\n\r\0\x0B.-_"));
if ($normalized === '') {
return true;
}
foreach (self::IGNORABLE_NAME_PATTERNS as $pattern) {
if (preg_match($pattern, $normalized)) {
return true;
}
}
return false;
}
}