*/ private const SINGLE_UPDATE_COLUMNS = [ 'predb_id', 'proc_nfo', 'proc_files', 'proc_par2', 'proc_uid', 'proc_hash16k', 'proc_srr', 'proc_crc32', ]; /** * PreDB regex pattern for scene release names. */ public const PREDB_REGEX = '/([\w.\'()\[\]-]+(?:[\s._-]+[\w.\'()\[\]-]+)+[-.][\w]+)/ui'; // Constants for name fixing status public const PROC_NFO_NONE = 0; public const PROC_NFO_DONE = 1; public const PROC_FILES_NONE = 0; public const PROC_FILES_DONE = 1; public const PROC_PAR2_NONE = 0; public const PROC_PAR2_DONE = 1; public const PROC_UID_NONE = 0; public const PROC_UID_DONE = 1; public const PROC_HASH16K_NONE = 0; public const PROC_HASH16K_DONE = 1; public const PROC_SRR_NONE = 0; public const PROC_SRR_DONE = 1; public const PROC_CRC_NONE = 0; public const PROC_CRC_DONE = 1; // Constants for overall rename status public const IS_RENAMED_NONE = 0; public const IS_RENAMED_DONE = 1; protected CategorizationService $category; protected FileNameCleaner $fileNameCleaner; protected bool $echoOutput; private readonly ReleaseSearchSyncCoordinator $searchSyncCoordinator; /** * The release ID we are trying to rename. */ protected int $relid = 0; /** * Has the current release found a new name? */ public bool $matched = false; /** * Was the check completed? */ public bool $done = false; /** * How many releases have got a new name? */ public int $fixed = 0; /** * How many releases were checked. */ public int $checked = 0; public function __construct( ?CategorizationService $category = null, ?FileNameCleaner $fileNameCleaner = null, ?ReleaseSearchSyncCoordinator $searchSyncCoordinator = null, ) { $this->category = $category ?? new CategorizationService; $this->fileNameCleaner = $fileNameCleaner ?? new FileNameCleaner; $this->searchSyncCoordinator = $searchSyncCoordinator ?? new ReleaseSearchSyncCoordinator(new PersistenceMetricsCollector); $this->echoOutput = config('nntmux.echocli'); } /** * Update the release with the new information. * * @param object|array $release The release to update * @param string $name The new name * @param string $method The method that found the name * @param bool $echo Whether to actually update the database * @param string $type The type string for logging * @param bool $nameStatus Whether to update status columns * @param bool $show Whether to show output * @param int|null $preId PreDB ID if matched * * @throws \Exception */ public function updateRelease( object|array $release, string $name, string $method, bool $echo, string $type, bool $nameStatus, bool $show, ?int $preId = 0 ): void { $preId = $preId ?? 0; if (is_array($release)) { $release = (object) $release; } // If $release does not have a releases_id, we should add it. if (! isset($release->releases_id)) { $release->releases_id = $release->id; } if ($this->relid !== $release->releases_id) { $cleanedName = (new ReleaseCleaningService)->fixerCleaner($name); // Normalize and sanity-check candidate for non-trusted sources $normalizedName = $this->fileNameCleaner->normalizeCandidateTitle($cleanedName); $newName = $this->fileNameCleaner->formatSearchName($cleanedName, $normalizedName); // Determine if the source is trusted enough to bypass plausibility checks $trustedSource = $this->isTrustedSource($type, $method, $preId); if (! $trustedSource && ! $this->fileNameCleaner->isPlausibleReleaseTitle($normalizedName)) { // Skip low-quality rename candidates for untrusted sources $this->done = true; return; } if (strtolower($newName) !== strtolower($release->searchname)) { $this->matched = true; $this->relid = (int) $release->releases_id; if ($type === 'PAR2, ') { $newName = ucwords($newName); if (preg_match('/(.+?)\.[a-z0-9]{2,3}(PAR2)?$/i', $name, $hit)) { $newName = $hit[1]; } } $this->fixed++; // Split on path separator backslash to strip any path $newName = explode('\\', $newName); $newName = preg_replace(['/^[=_.:\s-]+/', '/[=_.:\s-]+$/'], '', $newName[0]); $newTitle = substr($newName, 0, 299); $determinedCategory = null; if ($this->echoOutput && $show) { $determinedCategory = $this->category->determineCategory( $release->groups_id, $newTitle, ! empty($release->fromname) ? $release->fromname : '' ); $this->echoReleaseInfo($release, $newTitle, $determinedCategory, $type, $method); } if ($echo === true) { $this->performDatabaseUpdate($release, $newTitle, $type, $nameStatus, $preId); } } } $this->done = true; } /** * Check if the source is trusted enough to bypass plausibility checks. */ protected function isTrustedSource(string $type, string $method, int $preId): bool { return (! empty($preId) && $preId > 0) || str_starts_with($type, 'PreDB') || str_starts_with($type, 'PreDb') || $type === 'UID, ' || $type === 'PAR2 hash, ' || $type === 'CRC32, ' || $type === 'SRR, ' || stripos($method, 'Title Match') !== false || stripos($method, 'file matched source') !== false || stripos($method, 'PreDb') !== false || stripos($method, 'preDB') !== false; } /** * Echo release information to CLI. * * @param array $determinedCategory */ public function echoReleaseInfo( object $release, string $newName, array $determinedCategory, string $type, string $method ): void { $groupName = UsenetGroup::getNameByID($release->groups_id); $oldCatName = Category::getNameByID($release->categories_id); $newCatName = Category::getNameByID($determinedCategory['categories_id']); if ($type === 'PAR2, ') { echo PHP_EOL; } echo PHP_EOL; cli()->primary('Release Information:'); echo ' '.cli()->headerOver('New name: ').cli()->primary(substr($newName, 0, 100)).PHP_EOL; echo ' '.cli()->headerOver('Old name: ').cli()->primary(substr((string) $release->searchname, 0, 100)).PHP_EOL; echo ' '.cli()->headerOver('Use name: ').cli()->primary(substr((string) $release->name, 0, 100)).PHP_EOL; echo PHP_EOL; echo ' '.cli()->headerOver('New cat: ').cli()->primary($newCatName).PHP_EOL; echo ' '.cli()->headerOver('Old cat: ').cli()->primary($oldCatName).PHP_EOL; echo ' '.cli()->headerOver('Group: ').cli()->primary($groupName).PHP_EOL; echo PHP_EOL; echo ' '.cli()->headerOver('Method: ').cli()->primary($type.$method).PHP_EOL; echo ' '.cli()->headerOver('Release ID: ').cli()->primary((string) $release->releases_id).PHP_EOL; if (! empty($release->filename)) { echo ' '.cli()->headerOver('Filename: ').cli()->primary(substr((string) $release->filename, 0, 100)).PHP_EOL; } if ($type !== 'PAR2, ') { echo PHP_EOL; } } /** * Perform the actual database update. */ protected function performDatabaseUpdate( object $release, string $newTitle, string $type, bool $nameStatus, int $preId ): void { DB::transaction(function () use ($release, $newTitle, $type, $nameStatus, $preId): void { if ($nameStatus === true) { $status = $this->getStatusColumnsForType($type); $updateColumns = [ 'videos_id' => 0, 'tv_episodes_id' => 0, 'imdbid' => null, 'musicinfo_id' => '', 'consoleinfo_id' => '', 'bookinfo_id' => '', 'anidbid' => '', 'predb_id' => $preId, 'searchname' => $newTitle, ]; if (! empty($status)) { foreach ($status as $key => $stat) { $updateColumns = Arr::add($updateColumns, $key, $stat); } } Release::query() ->where('id', $release->releases_id) ->update($updateColumns); } else { Release::query() ->where('id', $release->releases_id) ->update([ 'videos_id' => 0, 'tv_episodes_id' => 0, 'imdbid' => null, 'musicinfo_id' => null, 'consoleinfo_id' => null, 'bookinfo_id' => null, 'anidbid' => null, 'predb_id' => $preId, 'searchname' => $newTitle, 'iscategorized' => 1, ]); } event(new ReleaseNameFixed( (int) $release->releases_id, (string) $release->searchname, $newTitle, (int) $release->categories_id, $release->groups_id, (string) ($release->fromname ?? '') )); }); $this->searchSyncCoordinator->request((int) $release->releases_id); } /** * Get the status columns to update for a given type. * * @return array */ protected function getStatusColumnsForType(string $type): array { return match ($type) { 'NFO, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_nfo' => 1], 'PAR2, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_par2' => 1], 'Filenames, ', 'file matched source: ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_files' => 1], 'PreDB FT Exact, ' => ['isrenamed' => 1, 'iscategorized' => 1], 'sorter, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_sorter' => 1], 'UID, ', 'Mediainfo, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_uid' => 1], 'PAR2 hash, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_hash16k' => 1], 'SRR, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_srr' => 1], 'CRC32, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_crc32' => 1], default => [], }; } /** * Update a single column in releases. */ public function updateSingleColumn(string $column, int $status, int $id): void { if (! in_array($column, self::SINGLE_UPDATE_COLUMNS, true)) { throw new InvalidArgumentException("Unsupported release status column [{$column}]."); } if ($id !== 0) { DB::update("UPDATE releases SET {$column} = ? WHERE id = ?", [$status, $id]); } } public function attachPredbId(int $releaseId, int $predbId): void { if ($releaseId === 0 || $predbId === 0) { return; } $this->updateSingleColumn('predb_id', $predbId, $releaseId); $this->relid = $releaseId; $this->matched = true; $this->done = true; } /** * Check if a release matches a PreDB entry. * * @return array */ public function checkPreDbMatch(object $release, string $textstring): ?array { if (! preg_match_all(self::PREDB_REGEX, $textstring, $hits) || preg_match('/Source\s:/i', $textstring)) { return null; } $candidates = []; foreach ($hits as $hit) { foreach ($hit as $value) { $title = trim($value); if ($title !== '') { $candidates[$title] = $title; } } } if ($candidates === []) { return null; } $candidateValues = array_values($candidates); $rows = DB::select( 'SELECT id, title FROM predb WHERE title IN ('.implode(',', array_fill(0, count($candidateValues), '?')).')', $candidateValues ); $matches = []; foreach ($rows as $row) { $matches[(string) $row->title] = ['title' => (string) $row->title, 'id' => (int) $row->id]; } foreach ($candidateValues as $candidate) { if (isset($matches[$candidate])) { return $matches[$candidate]; } } return null; } /** * Reset status variables for new processing. */ public function reset(): void { $this->done = $this->matched = false; } /** * Increment the checked counter. */ public function incrementChecked(): void { $this->checked++; } /** * Get the current statistics. * * @return array */ public function getStats(): array { return [ 'fixed' => $this->fixed, 'checked' => $this->checked, 'matched' => $this->matched, 'done' => $this->done, ]; } }