Files
newznab-tmux/app/Services/NameFixing/ReleaseUpdateService.php
T
2026-08-11 10:43:22 +02:00

465 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\NameFixing;
use App\Events\ReleaseNameFixed;
use App\Models\Category;
use App\Models\Release;
use App\Models\UsenetGroup;
use App\Services\AdditionalProcessing\ReleaseSearchSyncCoordinator;
use App\Services\AdditionalProcessing\State\PersistenceMetricsCollector;
use App\Services\Categorization\CategorizationService;
use App\Services\ReleaseCleaningService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Service for updating releases with new names.
*
* Handles the actual database updates and category re-determination
* when a release is renamed.
*/
class ReleaseUpdateService
{
/**
* @var list<string>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
public function getStats(): array
{
return [
'fixed' => $this->fixed,
'checked' => $this->checked,
'matched' => $this->matched,
'done' => $this->done,
];
}
}