mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Refactor ProcessAdditional
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\AdditionalProcessing\AdditionalProcessingOrchestrator;
|
||||
use App\Services\AdditionalProcessing\ArchiveExtractionService;
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\ConsoleOutputService;
|
||||
use App\Services\AdditionalProcessing\MediaExtractionService;
|
||||
use App\Services\AdditionalProcessing\NzbContentParser;
|
||||
use App\Services\AdditionalProcessing\ReleaseFileManager;
|
||||
use App\Services\AdditionalProcessing\UsenetDownloadService;
|
||||
use App\Services\Categorization\CategorizationService;
|
||||
use App\Services\TempWorkspaceService;
|
||||
use Blacklight\NameFixer;
|
||||
use Blacklight\Nfo;
|
||||
use Blacklight\NZB;
|
||||
use Blacklight\ReleaseExtra;
|
||||
use Blacklight\ReleaseImage;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* Service provider for Additional Processing services.
|
||||
* Registers all the refactored processing services with proper dependency injection.
|
||||
*/
|
||||
class AdditionalProcessingServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Configuration is a singleton since it loads settings once
|
||||
$this->app->singleton(ProcessingConfiguration::class, function () {
|
||||
return new ProcessingConfiguration();
|
||||
});
|
||||
|
||||
// Console output service
|
||||
$this->app->singleton(ConsoleOutputService::class, function ($app) {
|
||||
$config = $app->make(ProcessingConfiguration::class);
|
||||
return new ConsoleOutputService($config->echoCLI);
|
||||
});
|
||||
|
||||
// NZB content parser
|
||||
$this->app->singleton(NzbContentParser::class, function ($app) {
|
||||
$config = $app->make(ProcessingConfiguration::class);
|
||||
return new NzbContentParser(
|
||||
new NZB(),
|
||||
$config->debugMode,
|
||||
$config->echoCLI
|
||||
);
|
||||
});
|
||||
|
||||
// Archive extraction service
|
||||
$this->app->singleton(ArchiveExtractionService::class, function ($app) {
|
||||
return new ArchiveExtractionService(
|
||||
$app->make(ProcessingConfiguration::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Usenet download service
|
||||
$this->app->singleton(UsenetDownloadService::class, function ($app) {
|
||||
return new UsenetDownloadService(
|
||||
$app->make(ProcessingConfiguration::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Release file manager
|
||||
$this->app->singleton(ReleaseFileManager::class, function ($app) {
|
||||
return new ReleaseFileManager(
|
||||
$app->make(ProcessingConfiguration::class),
|
||||
new ReleaseExtra(),
|
||||
new ReleaseImage(),
|
||||
new Nfo(),
|
||||
new NZB(),
|
||||
new NameFixer()
|
||||
);
|
||||
});
|
||||
|
||||
// Media extraction service
|
||||
$this->app->singleton(MediaExtractionService::class, function ($app) {
|
||||
return new MediaExtractionService(
|
||||
$app->make(ProcessingConfiguration::class),
|
||||
new ReleaseImage(),
|
||||
new ReleaseExtra(),
|
||||
new CategorizationService()
|
||||
);
|
||||
});
|
||||
|
||||
// Temp workspace service (might already be registered elsewhere)
|
||||
$this->app->singleton(TempWorkspaceService::class, function () {
|
||||
return new TempWorkspaceService();
|
||||
});
|
||||
|
||||
// Main orchestrator
|
||||
$this->app->singleton(AdditionalProcessingOrchestrator::class, function ($app) {
|
||||
return new AdditionalProcessingOrchestrator(
|
||||
$app->make(ProcessingConfiguration::class),
|
||||
$app->make(NzbContentParser::class),
|
||||
$app->make(ArchiveExtractionService::class),
|
||||
$app->make(MediaExtractionService::class),
|
||||
$app->make(UsenetDownloadService::class),
|
||||
$app->make(ReleaseFileManager::class),
|
||||
$app->make(TempWorkspaceService::class),
|
||||
$app->make(ConsoleOutputService::class)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
|
||||
use App\Models\Release;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
|
||||
use App\Services\TempWorkspaceService;
|
||||
use Blacklight\Releases;
|
||||
use Blacklight\utility\Utility;
|
||||
use Exception;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Main orchestrator for additional release post-processing.
|
||||
* Coordinates all processing services to handle NZB parsing, archive extraction,
|
||||
* media processing, and release updates.
|
||||
*/
|
||||
class AdditionalProcessingOrchestrator
|
||||
{
|
||||
public const int MAX_COMPRESSED_FILES_TO_CHECK = 10;
|
||||
|
||||
private Collection $releases;
|
||||
private int $totalReleases = 0;
|
||||
private string $mainTmpPath = '';
|
||||
private array $triedCompressedMids = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config,
|
||||
private readonly NzbContentParser $nzbParser,
|
||||
private readonly ArchiveExtractionService $archiveService,
|
||||
private readonly MediaExtractionService $mediaService,
|
||||
private readonly UsenetDownloadService $downloadService,
|
||||
private readonly ReleaseFileManager $releaseManager,
|
||||
private readonly TempWorkspaceService $tempWorkspace,
|
||||
private readonly ConsoleOutputService $output
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start the additional processing.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function start(string $groupID = '', string $guidChar = ''): void
|
||||
{
|
||||
$this->setupTempPath($guidChar, $groupID);
|
||||
$this->fetchReleases($groupID, $guidChar);
|
||||
|
||||
if ($this->totalReleases > 0) {
|
||||
$this->output->echoDescription($this->totalReleases);
|
||||
$this->processReleases();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single release by GUID.
|
||||
*/
|
||||
public function processSingleGuid(string $guid): bool
|
||||
{
|
||||
try {
|
||||
$release = Release::where('guid', $guid)->first();
|
||||
if ($release === null) {
|
||||
$this->output->warning('Release not found for GUID: '.$guid);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->releases = collect([$release]);
|
||||
$this->totalReleases = 1;
|
||||
$guidChar = $release->leftguid ?? substr($release->guid, 0, 1);
|
||||
$groupID = '';
|
||||
$this->setupTempPath($guidChar, $groupID);
|
||||
$this->processReleases();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::error('processSingleGuid failed: '.$e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the main temp path.
|
||||
*/
|
||||
private function setupTempPath(string &$guidChar, string &$groupID): void
|
||||
{
|
||||
$this->mainTmpPath = $this->tempWorkspace->ensureMainTempPath(
|
||||
$this->config->tmpUnrarPath,
|
||||
$guidChar,
|
||||
$groupID
|
||||
);
|
||||
$this->tempWorkspace->clearDirectory($this->mainTmpPath, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch releases for processing.
|
||||
*/
|
||||
private function fetchReleases(int|string $groupID, string $guidChar): void
|
||||
{
|
||||
$sqlParts = [];
|
||||
$bindings = [];
|
||||
|
||||
$sqlParts[] = 'SELECT r.id AS id, r.id AS releases_id, r.guid, r.name, r.size, r.groups_id, r.nfostatus, r.fromname,';
|
||||
$sqlParts[] = 'r.completion, r.categories_id, r.searchname, r.predb_id, c.disablepreview';
|
||||
$sqlParts[] = 'FROM releases r';
|
||||
$sqlParts[] = 'LEFT JOIN categories c ON c.id = r.categories_id';
|
||||
$sqlParts[] = 'WHERE r.passwordstatus = ?';
|
||||
$bindings[] = -1;
|
||||
$sqlParts[] = 'AND r.nzbstatus = ?';
|
||||
$bindings[] = 1;
|
||||
$sqlParts[] = 'AND r.haspreview = ?';
|
||||
$bindings[] = -1;
|
||||
$sqlParts[] = 'AND c.disablepreview = ?';
|
||||
$bindings[] = 0;
|
||||
|
||||
if ($this->config->maxSizeGB > 0) {
|
||||
$sqlParts[] = 'AND r.size < ?';
|
||||
$bindings[] = $this->config->maxSizeGB * 1073741824;
|
||||
}
|
||||
if ($this->config->minSizeMB > 0) {
|
||||
$sqlParts[] = 'AND r.size > ?';
|
||||
$bindings[] = $this->config->minSizeMB * 1048576;
|
||||
}
|
||||
if (! empty($groupID)) {
|
||||
$sqlParts[] = 'AND r.groups_id = ?';
|
||||
$bindings[] = $groupID;
|
||||
}
|
||||
if (! empty($guidChar)) {
|
||||
$sqlParts[] = 'AND r.leftguid = ?';
|
||||
$bindings[] = $guidChar;
|
||||
}
|
||||
|
||||
$sqlParts[] = 'ORDER BY r.passwordstatus ASC, r.postdate DESC';
|
||||
$limit = $this->config->queryLimit > 0 ? $this->config->queryLimit : 25;
|
||||
$sqlParts[] = 'LIMIT '.$limit;
|
||||
|
||||
$rawResults = DB::select(implode(' ', $sqlParts), $bindings);
|
||||
$attributeArrays = array_map(static fn ($row) => (array) $row, $rawResults);
|
||||
$this->releases = Release::hydrate($attributeArrays);
|
||||
$this->totalReleases = $this->releases->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all fetched releases.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function processReleases(): void
|
||||
{
|
||||
foreach ($this->releases as $release) {
|
||||
$context = new ReleaseProcessingContext($release);
|
||||
|
||||
$this->output->echoReleaseStart($release->id, $release->size);
|
||||
cli_set_process_title(PHP_BINARY.' '.__DIR__.'/AdditionalProcessingOrchestrator.php ReleaseID: '.$release->id);
|
||||
|
||||
// Create temp folder for this release
|
||||
try {
|
||||
$context->tmpPath = $this->tempWorkspace->createReleaseTempFolder($this->mainTmpPath, $release->guid);
|
||||
} catch (\Throwable $e) {
|
||||
$this->output->warning('Unable to create directory: '.$e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse NZB contents
|
||||
$nzbResult = $this->nzbParser->parseNzb($release->guid);
|
||||
if ($nzbResult['error'] !== null) {
|
||||
$this->output->warning($nzbResult['error']);
|
||||
$this->releaseManager->deleteRelease($release);
|
||||
continue;
|
||||
}
|
||||
$context->nzbContents = $nzbResult['contents'];
|
||||
|
||||
// Initialize context
|
||||
$this->initializeContext($context);
|
||||
|
||||
// Extract message IDs from NZB
|
||||
$messageIds = $this->nzbParser->extractMessageIDs(
|
||||
$context->nzbContents,
|
||||
$context->releaseGroupName,
|
||||
$this->config->segmentsToDownload,
|
||||
$this->config->processThumbnails,
|
||||
$this->config->processJPGSample,
|
||||
$this->config->processMediaInfo,
|
||||
$this->config->processAudioInfo,
|
||||
$this->config->audioFileRegex,
|
||||
$this->config->videoFileRegex,
|
||||
$this->config->supportFileRegex,
|
||||
$this->config->ignoreBookRegex
|
||||
);
|
||||
|
||||
$context->nzbHasCompressedFile = $messageIds['hasCompressedFile'];
|
||||
$context->sampleMessageIDs = $messageIds['sampleMessageIDs'];
|
||||
$context->jpgMessageIDs = $messageIds['jpgMessageIDs'];
|
||||
$context->mediaInfoMessageIDs = $messageIds['mediaInfoMessageID'];
|
||||
$context->audioInfoMessageIDs = $messageIds['audioInfoMessageID'];
|
||||
$context->audioInfoExtension = $messageIds['audioInfoExtension'];
|
||||
|
||||
// Check for book flood
|
||||
$bookFlood = $messageIds['bookFileCount'] > 80
|
||||
&& ($messageIds['bookFileCount'] * 2) >= count($context->nzbContents);
|
||||
|
||||
// Process message ID downloads
|
||||
if ($this->config->processPasswords || $this->config->processThumbnails
|
||||
|| $this->config->processMediaInfo || $this->config->processAudioInfo
|
||||
|| $this->config->processVideo
|
||||
) {
|
||||
$this->processMessageIDDownloads($context);
|
||||
|
||||
// Process compressed files
|
||||
if (! $bookFlood && $context->nzbHasCompressedFile) {
|
||||
$this->processNZBCompressedFiles($context, false);
|
||||
|
||||
if ($this->config->fetchLastFiles) {
|
||||
$this->processNZBCompressedFiles($context, true);
|
||||
}
|
||||
|
||||
if (! $context->releaseHasPassword) {
|
||||
$this->processExtractedFiles($context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize
|
||||
$this->releaseManager->finalizeRelease($context, $this->config->processPasswords);
|
||||
|
||||
// Cleanup
|
||||
$this->tempWorkspace->clearDirectory($context->tmpPath, false);
|
||||
}
|
||||
|
||||
$this->output->endOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the processing context.
|
||||
*/
|
||||
private function initializeContext(ReleaseProcessingContext $context): void
|
||||
{
|
||||
$context->initializeFromConfig(
|
||||
$this->config->processVideo,
|
||||
$this->config->processMediaInfo,
|
||||
$this->config->processAudioInfo,
|
||||
$this->config->processAudioSample,
|
||||
$this->config->processJPGSample,
|
||||
$this->config->processThumbnails
|
||||
);
|
||||
|
||||
$context->passwordStatus = Releases::PASSWD_NONE;
|
||||
$context->releaseHasPassword = false;
|
||||
$context->releaseGroupName = UsenetGroup::getNameByID($context->release->groups_id);
|
||||
$context->releaseHasNoNFO = (int) $context->release->nfostatus !== 1;
|
||||
$context->resetMessageIDs();
|
||||
$context->resetCounters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process message ID downloads (sample, media info, audio, JPG).
|
||||
*/
|
||||
private function processMessageIDDownloads(ReleaseProcessingContext $context): void
|
||||
{
|
||||
// Sample download
|
||||
if ((! $context->foundSample || ! $context->foundVideo) && ! empty($context->sampleMessageIDs)) {
|
||||
$result = $this->downloadService->downloadSample(
|
||||
$context->sampleMessageIDs,
|
||||
$context->releaseGroupName,
|
||||
$context->release->id
|
||||
);
|
||||
|
||||
if ($result['success'] && $this->downloadService->meetsMinimumSize($result['data'])) {
|
||||
$this->output->echoSampleDownload();
|
||||
$fileLocation = $context->tmpPath.'sample_'.random_int(0, 99999).'.avi';
|
||||
File::put($fileLocation, $result['data']);
|
||||
|
||||
if (! $context->foundSample) {
|
||||
if ($this->mediaService->getSample($fileLocation, $context->tmpPath, $context->release->guid)) {
|
||||
$context->foundSample = true;
|
||||
$this->output->echoSampleCreated();
|
||||
}
|
||||
}
|
||||
if (! $context->foundVideo) {
|
||||
if ($this->mediaService->getVideo($fileLocation, $context->tmpPath, $context->release->guid)) {
|
||||
$context->foundVideo = true;
|
||||
$this->output->echoVideoCreated();
|
||||
}
|
||||
}
|
||||
} elseif (! $result['success']) {
|
||||
$this->output->echoSampleFailure();
|
||||
}
|
||||
}
|
||||
|
||||
// Media info download
|
||||
if ((! $context->foundMediaInfo || ! $context->foundSample || ! $context->foundVideo)
|
||||
&& ! empty($context->mediaInfoMessageIDs)
|
||||
) {
|
||||
$result = $this->downloadService->downloadMediaInfo(
|
||||
$context->mediaInfoMessageIDs,
|
||||
$context->releaseGroupName,
|
||||
$context->release->id
|
||||
);
|
||||
|
||||
if ($result['success'] && $this->downloadService->meetsMinimumSize($result['data'])) {
|
||||
$this->output->echoMediaInfoDownload();
|
||||
$fileLocation = $context->tmpPath.'media.avi';
|
||||
File::put($fileLocation, $result['data']);
|
||||
|
||||
if (! $context->foundMediaInfo) {
|
||||
if ($this->mediaService->getMediaInfo($fileLocation, $context->release->id)) {
|
||||
$context->foundMediaInfo = true;
|
||||
$this->output->echoMediaInfoAdded();
|
||||
}
|
||||
}
|
||||
if (! $context->foundSample) {
|
||||
if ($this->mediaService->getSample($fileLocation, $context->tmpPath, $context->release->guid)) {
|
||||
$context->foundSample = true;
|
||||
$this->output->echoSampleCreated();
|
||||
}
|
||||
}
|
||||
if (! $context->foundVideo) {
|
||||
if ($this->mediaService->getVideo($fileLocation, $context->tmpPath, $context->release->guid)) {
|
||||
$context->foundVideo = true;
|
||||
$this->output->echoVideoCreated();
|
||||
}
|
||||
}
|
||||
} elseif (! $result['success']) {
|
||||
$this->output->echoMediaInfoFailure();
|
||||
}
|
||||
}
|
||||
|
||||
// Audio info download
|
||||
if ((! $context->foundAudioInfo || ! $context->foundAudioSample)
|
||||
&& ! empty($context->audioInfoMessageIDs)
|
||||
) {
|
||||
$result = $this->downloadService->downloadAudio(
|
||||
$context->audioInfoMessageIDs,
|
||||
$context->releaseGroupName,
|
||||
$context->release->id
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->output->echoAudioDownload();
|
||||
$fileLocation = $context->tmpPath.'audio.'.$context->audioInfoExtension;
|
||||
File::put($fileLocation, $result['data']);
|
||||
|
||||
$audioResult = $this->mediaService->getAudioInfo(
|
||||
$fileLocation,
|
||||
$context->audioInfoExtension,
|
||||
$context,
|
||||
$context->tmpPath
|
||||
);
|
||||
|
||||
if ($audioResult['audioInfo']) {
|
||||
$this->output->echoAudioInfoAdded();
|
||||
}
|
||||
if ($audioResult['audioSample']) {
|
||||
$this->output->echoAudioSampleCreated();
|
||||
}
|
||||
} elseif (! $result['success']) {
|
||||
$this->output->echoAudioFailure();
|
||||
}
|
||||
}
|
||||
|
||||
// JPG download
|
||||
if (! $context->foundJPGSample && ! empty($context->jpgMessageIDs)) {
|
||||
$result = $this->downloadService->downloadJPG(
|
||||
$context->jpgMessageIDs,
|
||||
$context->releaseGroupName,
|
||||
$context->release->id
|
||||
);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->output->echoJpgDownload();
|
||||
$fileLocation = $context->tmpPath.'samplepicture.jpg';
|
||||
File::put($fileLocation, $result['data']);
|
||||
|
||||
if ($this->mediaService->isJpegData($fileLocation)) {
|
||||
if ($this->mediaService->getJPGSample($fileLocation, $context->release->guid)) {
|
||||
$context->foundJPGSample = true;
|
||||
$this->output->echoJpgSaved();
|
||||
}
|
||||
}
|
||||
File::delete($fileLocation);
|
||||
} elseif (! $result['success']) {
|
||||
$this->output->echoJpgFailure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process compressed files in the NZB.
|
||||
*/
|
||||
private function processNZBCompressedFiles(ReleaseProcessingContext $context, bool $reverse): void
|
||||
{
|
||||
if ($context->groupUnavailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nzbContents = $context->nzbContents;
|
||||
if ($reverse) {
|
||||
krsort($nzbContents);
|
||||
} else {
|
||||
$this->triedCompressedMids = [];
|
||||
}
|
||||
|
||||
$failed = $downloaded = 0;
|
||||
|
||||
foreach ($nzbContents as $nzbFile) {
|
||||
if ($downloaded >= $this->config->maximumRarSegments) {
|
||||
break;
|
||||
}
|
||||
if ($failed >= $this->config->maximumRarPasswordChecks) {
|
||||
break;
|
||||
}
|
||||
if ($context->releaseHasPassword || $context->groupUnavailable) {
|
||||
break;
|
||||
}
|
||||
|
||||
$title = $nzbFile['title'] ?? '';
|
||||
if (! preg_match(
|
||||
'/(\\.(part\\d+|[rz]\\d+|rar|0+|0*10?|zipr\\d{2,3}|zipx?|7z(?:\\.\\d{3})?|(?:tar\\.)?(?:gz|bz2|xz))(\\s*\\.rar)*($|[ ")]|-])|"[a-f0-9]{32}\\.[1-9]\\d{1,2}".*\\(\\d+\\/\\d{2,}\\)$)/i',
|
||||
$title
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get message IDs
|
||||
$segments = $nzbFile['segments'] ?? [];
|
||||
$segCount = count($segments) - 1;
|
||||
$messageIDs = [];
|
||||
|
||||
foreach (range(0, $this->config->maximumRarSegments - 1) as $i) {
|
||||
if ($i > $segCount) {
|
||||
break;
|
||||
}
|
||||
$segment = (string) $segments[$i];
|
||||
if (! $reverse) {
|
||||
$this->triedCompressedMids[] = $segment;
|
||||
} elseif (in_array($segment, $this->triedCompressedMids, false)) {
|
||||
continue 2;
|
||||
}
|
||||
$messageIDs[] = $segment;
|
||||
}
|
||||
|
||||
if (empty($messageIDs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download
|
||||
$result = $this->downloadService->downloadCompressedFile(
|
||||
$messageIDs,
|
||||
$context->releaseGroupName,
|
||||
$context->release->id,
|
||||
$title
|
||||
);
|
||||
|
||||
if ($result['groupUnavailable']) {
|
||||
$context->groupUnavailable = true;
|
||||
$this->output->echoGroupUnavailable();
|
||||
break;
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->output->echoCompressedDownload();
|
||||
$downloaded++;
|
||||
|
||||
$processed = $this->processCompressedData($result['data'], $context, $reverse);
|
||||
if ($processed || $context->releaseHasPassword) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$failed++;
|
||||
$this->output->echoCompressedFailure($failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process compressed data.
|
||||
*/
|
||||
private function processCompressedData(
|
||||
string $compressedData,
|
||||
ReleaseProcessingContext $context,
|
||||
bool $reverse
|
||||
): bool {
|
||||
$result = $this->archiveService->processCompressedData(
|
||||
$compressedData,
|
||||
$context,
|
||||
$context->tmpPath
|
||||
);
|
||||
|
||||
if ($result['hasPassword']) {
|
||||
$context->releaseHasPassword = true;
|
||||
$context->passwordStatus = $result['passwordStatus'];
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle standalone video
|
||||
if (isset($result['standaloneVideoType'])) {
|
||||
$this->output->echoInlineVideo();
|
||||
$ext = $result['standaloneVideoType'];
|
||||
$fileLocation = $context->tmpPath.'inline_video_'.uniqid('', true).'.'.$ext;
|
||||
File::put($fileLocation, $result['standaloneVideoData']);
|
||||
|
||||
if (! $context->foundMediaInfo) {
|
||||
if ($this->mediaService->getMediaInfo($fileLocation, $context->release->id)) {
|
||||
$context->foundMediaInfo = true;
|
||||
$this->output->echoMediaInfoAdded();
|
||||
}
|
||||
}
|
||||
if (! $context->foundSample) {
|
||||
if ($this->mediaService->getSample($fileLocation, $context->tmpPath, $context->release->guid)) {
|
||||
$context->foundSample = true;
|
||||
$this->output->echoSampleCreated();
|
||||
}
|
||||
}
|
||||
if (! $context->foundVideo) {
|
||||
if ($this->mediaService->getVideo($fileLocation, $context->tmpPath, $context->release->guid)) {
|
||||
$context->foundVideo = true;
|
||||
$this->output->echoVideoCreated();
|
||||
}
|
||||
}
|
||||
|
||||
return $context->foundMediaInfo || $context->foundSample || $context->foundVideo;
|
||||
}
|
||||
|
||||
if (! $result['success']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Echo archive marker
|
||||
if (! empty($result['archiveMarker'])) {
|
||||
$this->output->echoArchiveMarker($result['archiveMarker']);
|
||||
}
|
||||
|
||||
// Handle reverse processing for release name extraction
|
||||
if ($reverse && ! empty($result['dataSummary'])) {
|
||||
$this->releaseManager->processReleaseNameFromRar($result['dataSummary'], $context);
|
||||
}
|
||||
|
||||
// Process file list
|
||||
foreach ($result['files'] as $file) {
|
||||
if ($context->releaseHasPassword) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($this->releaseManager->addFileInfo($file, $context, $this->config->supportFileRegex)) {
|
||||
$this->output->echoFileInfoAdded();
|
||||
}
|
||||
}
|
||||
|
||||
if ($context->addedFileInfo > 0) {
|
||||
$this->releaseManager->updateSearchIndex($context->release->id);
|
||||
}
|
||||
|
||||
return $context->totalFileInfo > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process extracted files from archives.
|
||||
*/
|
||||
private function processExtractedFiles(ReleaseProcessingContext $context): void
|
||||
{
|
||||
$nestedLevels = 0;
|
||||
|
||||
// Handle nested archives
|
||||
while ($nestedLevels < $this->config->maxNestedLevels) {
|
||||
if ($context->compressedFilesChecked >= self::MAX_COMPRESSED_FILES_TO_CHECK) {
|
||||
break;
|
||||
}
|
||||
|
||||
$foundCompressed = false;
|
||||
$pattern = '/.*\.([rz]\d{2,}|rar|zipx?|0{0,2}1|7z(?:\.\d{3})?|(?:tar\.)?(?:gz|bz2|xz))($|[^a-z0-9])/i';
|
||||
|
||||
try {
|
||||
$files = $this->tempWorkspace->listFiles($context->tmpPath, $pattern);
|
||||
} catch (\Throwable) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filePath = is_array($file) ? $file[0] : $file->getPathname();
|
||||
if (File::isFile($filePath)) {
|
||||
$rarData = @File::get($filePath);
|
||||
if (! empty($rarData)) {
|
||||
$this->processCompressedData($rarData, $context, false);
|
||||
$foundCompressed = true;
|
||||
}
|
||||
File::delete($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $foundCompressed) {
|
||||
break;
|
||||
}
|
||||
$nestedLevels++;
|
||||
}
|
||||
|
||||
// Process remaining files
|
||||
try {
|
||||
$files = $this->tempWorkspace->listFiles($context->tmpPath);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filePath = is_object($file) ? $file->getPathname() : $file;
|
||||
|
||||
if (preg_match('/[\/\\\\]\.{1,2}$/', $filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// PAR2 files
|
||||
if (! $context->foundPAR2Info && preg_match('/\.par2$/i', $filePath)) {
|
||||
$this->releaseManager->processPar2File(
|
||||
$filePath,
|
||||
$context,
|
||||
$this->archiveService->getPar2Info()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// NFO files
|
||||
if ($context->releaseHasNoNFO && preg_match('/(\.(nfo|inf|ofn)|info\.txt)$/i', $filePath)) {
|
||||
if ($this->releaseManager->processNfoFile($filePath, $context, $this->downloadService->getNNTP())) {
|
||||
$this->output->echoNfoFound();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audio files
|
||||
if ((! $context->foundAudioInfo || ! $context->foundAudioSample)
|
||||
&& preg_match('/(.*)'.$this->config->audioFileRegex.'$/i', $filePath, $fileType)
|
||||
) {
|
||||
$audioPath = $context->tmpPath.'audiofile.'.$fileType[2];
|
||||
File::move($filePath, $audioPath);
|
||||
$audioResult = $this->mediaService->getAudioInfo(
|
||||
$audioPath,
|
||||
$fileType[2],
|
||||
$context,
|
||||
$context->tmpPath
|
||||
);
|
||||
if ($audioResult['audioInfo']) {
|
||||
$this->output->echoAudioInfoAdded();
|
||||
}
|
||||
if ($audioResult['audioSample']) {
|
||||
$this->output->echoAudioSampleCreated();
|
||||
}
|
||||
File::delete($audioPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// JPG files
|
||||
if (! $context->foundJPGSample && preg_match('/\.jpe?g$/i', $filePath)) {
|
||||
if ($this->mediaService->getJPGSample($filePath, $context->release->guid)) {
|
||||
$context->foundJPGSample = true;
|
||||
$this->output->echoJpgSaved();
|
||||
}
|
||||
File::delete($filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Video files
|
||||
if ((! $context->foundSample || ! $context->foundVideo || ! $context->foundMediaInfo)
|
||||
&& preg_match('/(.*)'.$this->config->videoFileRegex.'$/i', $filePath)
|
||||
) {
|
||||
$this->mediaService->processVideoFile($filePath, $context, $context->tmpPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file magic
|
||||
$output = Utility::fileInfo($filePath);
|
||||
if (empty($output)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $context->foundJPGSample && preg_match('/^JPE?G/i', $output)) {
|
||||
if ($this->mediaService->getJPGSample($filePath, $context->release->guid)) {
|
||||
$context->foundJPGSample = true;
|
||||
$this->output->echoJpgSaved();
|
||||
}
|
||||
File::delete($filePath);
|
||||
} elseif ((! $context->foundMediaInfo || ! $context->foundSample || ! $context->foundVideo)
|
||||
&& preg_match('/Matroska data|MPEG v4|MPEG sequence, v2|\WAVI\W/i', $output)
|
||||
) {
|
||||
$this->mediaService->processVideoFile($filePath, $context, $context->tmpPath);
|
||||
} elseif ((! $context->foundAudioSample || ! $context->foundAudioInfo)
|
||||
&& preg_match('/^FLAC|layer III|Vorbis audio/i', $output, $audioType)
|
||||
) {
|
||||
$ext = match ($audioType[0]) {
|
||||
'FLAC' => 'FLAC',
|
||||
'layer III' => 'MP3',
|
||||
'Vorbis audio' => 'OGG',
|
||||
default => 'audio',
|
||||
};
|
||||
$audioPath = $context->tmpPath.'audiofile.'.$ext;
|
||||
File::move($filePath, $audioPath);
|
||||
$audioResult = $this->mediaService->getAudioInfo($audioPath, $ext, $context, $context->tmpPath);
|
||||
if ($audioResult['audioInfo']) {
|
||||
$this->output->echoAudioInfoAdded();
|
||||
}
|
||||
if ($audioResult['audioSample']) {
|
||||
$this->output->echoAudioSampleCreated();
|
||||
}
|
||||
File::delete($audioPath);
|
||||
} elseif (! $context->foundPAR2Info && stripos($output, 'Parity') === 0) {
|
||||
$this->releaseManager->processPar2File(
|
||||
$filePath,
|
||||
$context,
|
||||
$this->archiveService->getPar2Info()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the main temp path.
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if ($this->mainTmpPath !== '') {
|
||||
$this->tempWorkspace->clearDirectory($this->mainTmpPath, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
|
||||
use Blacklight\Releases;
|
||||
use dariusiii\rarinfo\ArchiveInfo;
|
||||
use dariusiii\rarinfo\Par2Info;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Service for extracting and processing archive files (RAR, ZIP, 7z, gzip, bzip2, xz).
|
||||
* Handles password detection, file listing, and content extraction.
|
||||
*/
|
||||
class ArchiveExtractionService
|
||||
{
|
||||
private ArchiveInfo $archiveInfo;
|
||||
private Par2Info $par2Info;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config
|
||||
) {
|
||||
$this->archiveInfo = new ArchiveInfo();
|
||||
$this->par2Info = new Par2Info();
|
||||
|
||||
// Configure external clients for ArchiveInfo
|
||||
if ($this->config->unrarPath) {
|
||||
$this->archiveInfo->setExternalClients([ArchiveInfo::TYPE_RAR => $this->config->unrarPath]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process compressed data and extract file information.
|
||||
*
|
||||
* @return array{success: bool, files: array, hasPassword: bool, passwordStatus: int}
|
||||
*/
|
||||
public function processCompressedData(
|
||||
string $compressedData,
|
||||
ReleaseProcessingContext $context,
|
||||
string $tmpPath
|
||||
): array {
|
||||
$result = [
|
||||
'success' => false,
|
||||
'files' => [],
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
];
|
||||
|
||||
$context->compressedFilesChecked++;
|
||||
|
||||
// Detect archive type early
|
||||
$archiveType = $this->detectArchiveType($compressedData);
|
||||
|
||||
// Handle 7z, gzip, bzip2, xz with external 7zip binary
|
||||
if (in_array($archiveType, ['7z', 'gzip', 'bzip2', 'xz'], true)) {
|
||||
if ($archiveType === '7z') {
|
||||
$sevenZipResult = $this->processSevenZipArchive($compressedData, $context, $tmpPath);
|
||||
if ($sevenZipResult['success'] || $sevenZipResult['hasPassword']) {
|
||||
return $sevenZipResult;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->config->sevenZipPath) {
|
||||
$extractResult = $this->extractViaSevenZip($compressedData, $archiveType, $tmpPath);
|
||||
if ($extractResult['success']) {
|
||||
return $extractResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try ArchiveInfo for RAR/ZIP
|
||||
if (! $this->archiveInfo->setData($compressedData, true)) {
|
||||
// Handle standalone video detection
|
||||
$videoType = $this->detectStandaloneVideo($compressedData);
|
||||
if ($videoType !== null) {
|
||||
return [
|
||||
'success' => false,
|
||||
'files' => [],
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
'standaloneVideoType' => $videoType,
|
||||
'standaloneVideoData' => $compressedData,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($this->archiveInfo->error !== '') {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('ArchiveInfo Error: '.$this->archiveInfo->error);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$dataSummary = $this->archiveInfo->getSummary(true);
|
||||
} catch (\Exception $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::warning($e->getTraceAsString());
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Check for encryption
|
||||
if (! empty($this->archiveInfo->isEncrypted)
|
||||
|| (isset($dataSummary['is_encrypted']) && (int) $dataSummary['is_encrypted'] !== 0)
|
||||
) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('ArchiveInfo: Compressed file has a password.');
|
||||
}
|
||||
return [
|
||||
'success' => false,
|
||||
'files' => [],
|
||||
'hasPassword' => true,
|
||||
'passwordStatus' => Releases::PASSWD_RAR,
|
||||
];
|
||||
}
|
||||
|
||||
// Prepare extraction directories
|
||||
$this->prepareExtractionDirectories($tmpPath);
|
||||
|
||||
// Process based on archive type
|
||||
$archiveMarker = $this->extractArchive($compressedData, $dataSummary, $tmpPath);
|
||||
|
||||
// Get file list
|
||||
$files = $this->archiveInfo->getArchiveFileList();
|
||||
if (! is_array($files) || count($files) === 0) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'files' => $files,
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
'archiveMarker' => $archiveMarker,
|
||||
'dataSummary' => $dataSummary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the archive type from binary signature.
|
||||
*/
|
||||
public function detectArchiveType(string $data): ?string
|
||||
{
|
||||
$head6 = substr($data, 0, 6);
|
||||
$head4 = substr($data, 0, 4);
|
||||
|
||||
// 7z signature
|
||||
if ($head6 === "\x37\x7A\xBC\xAF\x27\x1C" && $this->isLikely7z($data)) {
|
||||
return '7z';
|
||||
}
|
||||
// GZIP
|
||||
if (strncmp($head4, "\x1F\x8B\x08", 3) === 0) {
|
||||
return 'gzip';
|
||||
}
|
||||
// BZip2
|
||||
if (strncmp($head4, 'BZh', 3) === 0) {
|
||||
return 'bzip2';
|
||||
}
|
||||
// XZ
|
||||
if ($head6 === "\xFD7zXZ\x00") {
|
||||
return 'xz';
|
||||
}
|
||||
// PDF (skip)
|
||||
if ($head4 === '%PDF') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic validation for 7z signature.
|
||||
*/
|
||||
private function isLikely7z(string $data): bool
|
||||
{
|
||||
if (strlen($data) < 32) {
|
||||
return false;
|
||||
}
|
||||
$verMajor = ord($data[6]);
|
||||
$verMinor = ord($data[7]);
|
||||
if ($verMajor !== 0x00 || $verMinor < 0x02 || $verMinor > 0x09) {
|
||||
return false;
|
||||
}
|
||||
$crc = substr($data, 8, 4);
|
||||
if ($crc === "\x00\x00\x00\x00" || $crc === "\xFF\xFF\xFF\xFF") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a 7z archive using external binary and internal header parsing.
|
||||
*/
|
||||
private function processSevenZipArchive(
|
||||
string $compressedData,
|
||||
ReleaseProcessingContext $context,
|
||||
string $tmpPath
|
||||
): array {
|
||||
$result = [
|
||||
'success' => false,
|
||||
'files' => [],
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
];
|
||||
|
||||
if (! $this->config->sevenZipPath) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try listing with external 7z binary
|
||||
$listed = $this->listSevenZipEntries($compressedData, $tmpPath);
|
||||
if (! empty($listed)) {
|
||||
if (! empty($listed[0]['__any_encrypted__'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'files' => [],
|
||||
'hasPassword' => true,
|
||||
'passwordStatus' => Releases::PASSWD_RAR,
|
||||
];
|
||||
}
|
||||
|
||||
$files = $this->filterSevenZipFiles($listed);
|
||||
if (! empty($files)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'files' => $files,
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
'archiveMarker' => '7z',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan for filenames in raw data
|
||||
$scannedNames = $this->scanSevenZipFilenames($compressedData);
|
||||
if (! empty($scannedNames)) {
|
||||
$files = array_map(fn($name) => [
|
||||
'name' => $name,
|
||||
'size' => 0,
|
||||
'date' => time(),
|
||||
'pass' => 0,
|
||||
'crc32' => '',
|
||||
'source' => '7z-scan',
|
||||
], $scannedNames);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'files' => $files,
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
'archiveMarker' => '7z',
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* List entries of a 7z archive using external 7z binary.
|
||||
*/
|
||||
public function listSevenZipEntries(string $compressedData, string $tmpPath): array
|
||||
{
|
||||
if (! $this->config->sevenZipPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$tmpFile = $tmpPath.uniqid('7zlist_', true).'.7z';
|
||||
if (File::put($tmpFile, $compressedData) === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cmd = [$this->config->sevenZipPath, 'l', '-slt', '-ba', '-bd', $tmpFile];
|
||||
$exitCode = 0;
|
||||
$stdout = null;
|
||||
$stderr = null;
|
||||
$ok = $this->execCommand($cmd, $exitCode, $stdout, $stderr);
|
||||
|
||||
if (! $ok || $exitCode !== 0 || empty($stdout)) {
|
||||
// Try plain listing fallback
|
||||
$plainResult = $this->listSevenZipPlain($tmpFile);
|
||||
File::delete($tmpFile);
|
||||
return $plainResult;
|
||||
}
|
||||
|
||||
File::delete($tmpFile);
|
||||
return $this->parseSevenZipStructuredOutput($stdout);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('Exception listing 7z: '.$e->getMessage());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain 7z listing fallback.
|
||||
*/
|
||||
private function listSevenZipPlain(string $tmpFile): array
|
||||
{
|
||||
$cmd = [$this->config->sevenZipPath, 'l', '-ba', '-bd', $tmpFile];
|
||||
$exitCode = 0;
|
||||
$stdout = null;
|
||||
$stderr = null;
|
||||
$ok = $this->execCommand($cmd, $exitCode, $stdout, $stderr);
|
||||
|
||||
if (! $ok || $exitCode !== 0 || empty($stdout)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$lines = preg_split('/\r?\n/', trim($stdout));
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '-----')
|
||||
|| str_contains($line, ' Date ')
|
||||
|| str_starts_with($line, 'Scanning ')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = null;
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+\S+\s+\d+\s+\d+\s+(\S.*)$/', $line, $m)) {
|
||||
$name = $m[1];
|
||||
} elseif (preg_match('/([A-Za-z0-9_#@()\[\]\-+&., ]+\.[A-Za-z0-9]{2,8})$/', $line, $m2)) {
|
||||
$name = trim($m2[1]);
|
||||
}
|
||||
|
||||
if ($name && strlen($name) <= 300) {
|
||||
$files[] = ['name' => trim($name), 'size' => 0, 'encrypted' => false];
|
||||
if (count($files) >= 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse structured 7z output.
|
||||
*/
|
||||
private function parseSevenZipStructuredOutput(string $output): array
|
||||
{
|
||||
$blocks = preg_split('/\n\n+/u', trim($output));
|
||||
$files = [];
|
||||
$anyEncrypted = false;
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$lines = preg_split('/\r?\n/', trim($block));
|
||||
$row = [];
|
||||
foreach ($lines as $line) {
|
||||
$kv = explode(' = ', $line, 2);
|
||||
if (count($kv) === 2) {
|
||||
$row[$kv[0]] = $kv[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($row['Path'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attr = $row['Attributes'] ?? '';
|
||||
if (str_contains($attr, 'D')) {
|
||||
continue; // directory
|
||||
}
|
||||
|
||||
$encrypted = ($row['Encrypted'] ?? '') === '+';
|
||||
if ($encrypted) {
|
||||
$anyEncrypted = true;
|
||||
}
|
||||
|
||||
$size = isset($row['Size']) && ctype_digit($row['Size']) ? (int) $row['Size'] : 0;
|
||||
$files[] = ['name' => $row['Path'], 'size' => $size, 'encrypted' => $encrypted];
|
||||
|
||||
if (count($files) >= 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($anyEncrypted && isset($files[0])) {
|
||||
$files[0]['__any_encrypted__'] = true;
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter 7z files using extension whitelist.
|
||||
*/
|
||||
private function filterSevenZipFiles(array $files): array
|
||||
{
|
||||
$allowedExtensions = $this->getAllowedExtensions();
|
||||
$filtered = [];
|
||||
|
||||
foreach ($files as $entry) {
|
||||
if (! empty($entry['__any_encrypted__'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $entry['name'] ?? '';
|
||||
if ($name === '' || strlen($name) > 300) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (! in_array($ext, $allowedExtensions, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$base = pathinfo($name, PATHINFO_FILENAME);
|
||||
$letterCount = preg_match_all('/[a-z]/i', $base);
|
||||
if ($letterCount <= 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = [
|
||||
'name' => $name,
|
||||
'size' => $entry['size'] ?? 0,
|
||||
'date' => time(),
|
||||
'pass' => 0,
|
||||
'crc32' => '',
|
||||
'source' => '7z-list',
|
||||
];
|
||||
|
||||
if (count($filtered) >= 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for filenames in 7z raw data.
|
||||
*/
|
||||
private function scanSevenZipFilenames(string $data): array
|
||||
{
|
||||
$slice = substr($data, 0, 8 * 1024 * 1024);
|
||||
$converted = preg_replace('/([\x20-\x7E])\x00/', '$1', $slice) ?? $slice;
|
||||
$converted = str_replace("\x00", ' ', $converted);
|
||||
|
||||
$exts = '7z|rar|zip|gz|bz2|xz|tar|tgz|mp4|mkv|avi|mpg|mpeg|mov|ts|wmv|flv|m4v|mp3|flac|ogg|wav|aac|aiff|ape|mka|nfo|txt|diz|pdf|epub|mobi|jpg|jpeg|png|gif|sfv|par2|exe|dll|srt|sub|idx|iso|bin|cue|mds|mdf';
|
||||
$regex = '~(?:[A-Za-z0-9 _.+&@#,()!-]{0,120}[\\/])?([A-Za-z0-9 _.+&@#,()!-]{2,160}\.(?:'.$exts.'))~i';
|
||||
preg_match_all($regex, $converted, $matches, PREG_SET_ORDER);
|
||||
|
||||
if (empty($matches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
foreach ($matches as $match) {
|
||||
$candidate = preg_replace('/ {2,}/', ' ', $match[1]);
|
||||
if ($candidate === '' || strlen($candidate) < 5 || substr_count($candidate, '.') > 10) {
|
||||
continue;
|
||||
}
|
||||
$candidate = trim($candidate, " .-\t\n\r");
|
||||
$lower = strtolower($candidate);
|
||||
if (! isset($names[$lower])) {
|
||||
$names[$lower] = $candidate;
|
||||
if (count($names) >= 80) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract using 7zip binary.
|
||||
*/
|
||||
public function extractViaSevenZip(string $compressedData, string $type, string $tmpPath): array
|
||||
{
|
||||
$result = [
|
||||
'success' => false,
|
||||
'files' => [],
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
];
|
||||
|
||||
if ($this->config->extractUsingRarInfo || ! $this->config->sevenZipPath) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$extMap = ['7z' => '7z', 'gzip' => 'gz', 'bzip2' => 'bz2', 'xz' => 'xz'];
|
||||
$markerMap = ['7z' => '7z', 'gzip' => 'g', 'bzip2' => 'b', 'xz' => 'x'];
|
||||
$ext = $extMap[$type] ?? 'dat';
|
||||
$marker = $markerMap[$type] ?? $type;
|
||||
|
||||
$extractDir = $tmpPath.'un7z/'.uniqid('', true).'/';
|
||||
if (! File::isDirectory($extractDir)) {
|
||||
File::makeDirectory($extractDir, 0777, true, true);
|
||||
}
|
||||
|
||||
$fileName = $tmpPath.uniqid('', true).'.'.$ext;
|
||||
File::put($fileName, $compressedData);
|
||||
|
||||
$cmd = [$this->config->sevenZipPath, 'e', '-y', '-bd', '-o'.$extractDir, $fileName];
|
||||
$exitCode = 0;
|
||||
$stdout = null;
|
||||
$stderr = null;
|
||||
$this->execCommand($cmd, $exitCode, $stdout, $stderr);
|
||||
|
||||
$files = [];
|
||||
if (File::isDirectory($extractDir)) {
|
||||
foreach (File::allFiles($extractDir) as $f) {
|
||||
$files[] = [
|
||||
'name' => $f->getFilename(),
|
||||
'size' => $f->getSize(),
|
||||
'date' => time(),
|
||||
'pass' => 0,
|
||||
'crc32' => '',
|
||||
'source' => $type,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
File::delete($fileName);
|
||||
|
||||
if (! empty($files)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'files' => $this->filterExtractedFiles($files),
|
||||
'hasPassword' => false,
|
||||
'passwordStatus' => Releases::PASSWD_NONE,
|
||||
'archiveMarker' => $marker,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::warning(strtoupper($type).' extraction exception: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter extracted files by allowed extensions.
|
||||
*/
|
||||
private function filterExtractedFiles(array $files): array
|
||||
{
|
||||
$allowedExtensions = $this->getAllowedExtensions();
|
||||
$filtered = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$name = $file['name'] ?? '';
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
|
||||
if (! in_array($ext, $allowedExtensions, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$base = pathinfo($name, PATHINFO_FILENAME);
|
||||
$letterCount = preg_match_all('/[a-z]/i', $base);
|
||||
if ($letterCount <= 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = $file;
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of allowed file extensions.
|
||||
*/
|
||||
private function getAllowedExtensions(): array
|
||||
{
|
||||
return ['nfo', 'srt', 'mkv', 'mpeg', 'avi', 'jpg', 'jpeg', 'exe', 'mp4', 'mp3', 'm4a',
|
||||
'flac', 'png', 'epub', 'cbz', 'cbr', 'djvu'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare extraction directories.
|
||||
*/
|
||||
private function prepareExtractionDirectories(string $tmpPath): void
|
||||
{
|
||||
if ($this->config->extractUsingRarInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->config->unrarPath) {
|
||||
$unrarDir = $tmpPath.'unrar/';
|
||||
if (! File::isDirectory($unrarDir)) {
|
||||
File::makeDirectory($unrarDir, 0777, true, true);
|
||||
}
|
||||
}
|
||||
$unzipDir = $tmpPath.'unzip/';
|
||||
if (! File::isDirectory($unzipDir)) {
|
||||
File::makeDirectory($unzipDir, 0777, true, true);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::warning('Failed ensuring extraction subdirectories: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract archive based on type.
|
||||
*/
|
||||
private function extractArchive(string $compressedData, array $dataSummary, string $tmpPath): string
|
||||
{
|
||||
$killString = $this->config->getKillString();
|
||||
|
||||
switch ($dataSummary['main_type']) {
|
||||
case ArchiveInfo::TYPE_RAR:
|
||||
if (! $this->config->extractUsingRarInfo && $this->config->unrarPath) {
|
||||
$fileName = $tmpPath.uniqid('', true).'.rar';
|
||||
File::put($fileName, $compressedData);
|
||||
runCmd($killString.$this->config->unrarPath.'" e -ai -ep -c- -id -inul -kb -or -p- -r -y "'.$fileName.'" "'.$tmpPath.'unrar/"');
|
||||
File::delete($fileName);
|
||||
}
|
||||
return 'r';
|
||||
|
||||
case ArchiveInfo::TYPE_ZIP:
|
||||
if (! $this->config->extractUsingRarInfo && $this->config->unzipPath) {
|
||||
$fileName = $tmpPath.uniqid('', true).'.zip';
|
||||
File::put($fileName, $compressedData);
|
||||
runCmd($this->config->unzipPath.' -o "'.$fileName.'" -d "'.$tmpPath.'unzip/"');
|
||||
File::delete($fileName);
|
||||
}
|
||||
return 'z';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect standalone video from binary data.
|
||||
*/
|
||||
public function detectStandaloneVideo(string $data): ?string
|
||||
{
|
||||
$len = strlen($data);
|
||||
if ($len < 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// AVI
|
||||
if (strncmp($data, 'RIFF', 4) === 0 && substr($data, 8, 4) === 'AVI ') {
|
||||
return 'avi';
|
||||
}
|
||||
// Matroska / WebM
|
||||
if (strncmp($data, "\x1A\x45\xDF\xA3", 4) === 0) {
|
||||
return 'mkv';
|
||||
}
|
||||
// MPEG
|
||||
$sig4 = substr($data, 0, 4);
|
||||
if ($sig4 === "\x00\x00\x01\xBA" || $sig4 === "\x00\x00\x01\xB3") {
|
||||
return 'mpg';
|
||||
}
|
||||
// Transport Stream
|
||||
if ($len >= 188 * 5) {
|
||||
$isTs = true;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (! isset($data[188 * $i]) || $data[188 * $i] !== "\x47") {
|
||||
$isTs = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($isTs) {
|
||||
return 'mpg';
|
||||
}
|
||||
}
|
||||
// MP4/MOV
|
||||
if ($len >= 12 && substr($data, 4, 4) === 'ftyp') {
|
||||
$brands = ['isom', 'iso2', 'avc1', 'mp41', 'mp42', 'dash', 'MSNV', 'qt ', 'M4V ', 'M4P ', 'M4B ', 'M4A '];
|
||||
if (in_array(substr($data, 8, 4), $brands, true)) {
|
||||
return 'mp4';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PAR2 info parser.
|
||||
*/
|
||||
public function getPar2Info(): Par2Info
|
||||
{
|
||||
return $this->par2Info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archive info handler.
|
||||
*/
|
||||
public function getArchiveInfo(): ArchiveInfo
|
||||
{
|
||||
return $this->archiveInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command with output capture.
|
||||
*/
|
||||
private function execCommand(array $cmd, ?int &$exitCode, ?string &$stdout, ?string &$stderr): bool
|
||||
{
|
||||
$descriptorSpec = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = @proc_open($cmd, $descriptorSpec, $pipes, null, null, ['bypass_shell' => true]);
|
||||
if (! is_resource($process)) {
|
||||
$exitCode = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return $exitCode === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
namespace App\Services\AdditionalProcessing\Config;
|
||||
use App\Models\Settings;
|
||||
/**
|
||||
* Configuration DTO for additional post-processing.
|
||||
* Centralizes all settings loading from database and config files.
|
||||
*/
|
||||
final readonly class ProcessingConfiguration
|
||||
{
|
||||
public bool $echoCLI;
|
||||
public bool|string $innerFileBlacklist;
|
||||
public int $maxNestedLevels;
|
||||
public bool $extractUsingRarInfo;
|
||||
public bool $fetchLastFiles;
|
||||
public string|false $unrarPath;
|
||||
public string|false $unzipPath;
|
||||
public string|false $sevenZipPath;
|
||||
public string|false $timeoutPath;
|
||||
public int $timeoutSeconds;
|
||||
public int $queryLimit;
|
||||
public int $segmentsToDownload;
|
||||
public int $maximumRarSegments;
|
||||
public int $maximumRarPasswordChecks;
|
||||
public int $maxSizeGB;
|
||||
public int $minSizeMB;
|
||||
public bool $alternateNNTP;
|
||||
public int $ffmpegDuration;
|
||||
public bool $addPAR2Files;
|
||||
public bool $processVideo;
|
||||
public bool $processThumbnails;
|
||||
public bool $processAudioSample;
|
||||
public bool $processJPGSample;
|
||||
public bool $processMediaInfo;
|
||||
public bool $processAudioInfo;
|
||||
public bool $processPasswords;
|
||||
public string $audioSavePath;
|
||||
public string $tmpUnrarPath;
|
||||
public bool $debugMode;
|
||||
public bool $elasticsearchEnabled;
|
||||
public bool $renameMusicMediaInfo;
|
||||
public bool $renamePar2;
|
||||
public string|false $ffmpegPath;
|
||||
public string|false $mediaInfoPath;
|
||||
// Regex patterns
|
||||
public string $audioFileRegex;
|
||||
public string $ignoreBookRegex;
|
||||
public string $supportFileRegex;
|
||||
public string $videoFileRegex;
|
||||
public function __construct()
|
||||
{
|
||||
$this->echoCLI = (bool) config('nntmux.echocli');
|
||||
$this->innerFileBlacklist = Settings::settingValue('innerfileblacklist') === ''
|
||||
? false
|
||||
: Settings::settingValue('innerfileblacklist');
|
||||
$this->maxNestedLevels = (int) Settings::settingValue('maxnestedlevels') ?: 3;
|
||||
$this->extractUsingRarInfo = (int) Settings::settingValue('extractusingrarinfo') !== 0;
|
||||
$this->fetchLastFiles = (bool) config('nntmux_settings.fetch_last_file');
|
||||
$this->unrarPath = config('nntmux_settings.unrar_path') ?: false;
|
||||
$this->unzipPath = config('nntmux_settings.unzip_path') ?: false;
|
||||
$this->sevenZipPath = config('nntmux_settings.7zip_path') ?: false;
|
||||
$this->timeoutPath = config('nntmux_settings.timeout_path') ?: false;
|
||||
$this->timeoutSeconds = (int) Settings::settingValue('timeoutseconds');
|
||||
$this->queryLimit = (int) (Settings::settingValue('maxaddprocessed') ?: 25);
|
||||
$this->segmentsToDownload = (int) (Settings::settingValue('segmentstodownload') ?: 2);
|
||||
$this->maximumRarSegments = (int) (Settings::settingValue('maxpartsprocessed') ?: 3);
|
||||
$this->maximumRarPasswordChecks = max((int) (Settings::settingValue('passchkattempts') ?: 1), 1);
|
||||
$this->maxSizeGB = (int) (Settings::settingValue('maxsizetopostprocess') ?: 100);
|
||||
$this->minSizeMB = (int) (Settings::settingValue('minsizetopostprocess') ?: 100);
|
||||
$this->alternateNNTP = (bool) config('nntmux_nntp.use_alternate_nntp_server');
|
||||
$this->ffmpegDuration = (int) (Settings::settingValue('ffmpeg_duration') ?: 5);
|
||||
$this->addPAR2Files = (bool) config('nntmux_settings.add_par2');
|
||||
$this->ffmpegPath = config('nntmux_settings.ffmpeg_path') ?: false;
|
||||
$this->mediaInfoPath = config('nntmux_settings.mediainfo_path') ?: false;
|
||||
if (! $this->ffmpegPath) {
|
||||
$this->processAudioSample = false;
|
||||
$this->processThumbnails = false;
|
||||
$this->processVideo = false;
|
||||
} else {
|
||||
$this->processAudioSample = (int) Settings::settingValue('saveaudiopreview') !== 0;
|
||||
$this->processThumbnails = (int) Settings::settingValue('processthumbnails') !== 0;
|
||||
$this->processVideo = (int) Settings::settingValue('processvideos') !== 0;
|
||||
}
|
||||
$this->processJPGSample = (int) Settings::settingValue('processjpg') !== 0;
|
||||
$this->processMediaInfo = (bool) $this->mediaInfoPath;
|
||||
$this->processAudioInfo = $this->processMediaInfo;
|
||||
$this->processPasswords = config('nntmux_settings.check_passworded_rars') === true
|
||||
&& ! empty(config('nntmux_settings.unrar_path'));
|
||||
$this->audioSavePath = config('nntmux_settings.covers_path').'/audiosample/';
|
||||
$this->tmpUnrarPath = config('nntmux.tmp_unrar_path');
|
||||
$this->debugMode = (bool) config('app.debug');
|
||||
$this->elasticsearchEnabled = config('nntmux.elasticsearch_enabled') === true;
|
||||
$this->renameMusicMediaInfo = (bool) config('nntmux.rename_music_mediainfo');
|
||||
$this->renamePar2 = (bool) config('nntmux.rename_par2');
|
||||
// Regex patterns
|
||||
$this->audioFileRegex = '\\.(AAC|AIFF|APE|AC3|ASF|DTS|FLAC|MKA|MKS|MP2|MP3|RA|OGG|OGM|W64|WAV|WMA)';
|
||||
$this->ignoreBookRegex = '/\\b(epub|lit|mobi|pdf|sipdf|html)\\b.*\\.rar(?!.{20,})/i';
|
||||
$this->supportFileRegex = '\\.(?:vol\\d{1,3}\\+\\d{1,3}|par2|srs|sfv|nzb)';
|
||||
$this->videoFileRegex = '\\.(AVI|F4V|IFO|M1V|M2V|M4V|MKV|MOV|MP4|MPEG|MPG|MPGV|MPV|OGV|QT|RM|RMVB|TS|VOB|WMV)';
|
||||
}
|
||||
/**
|
||||
* Build the kill string for timeout command wrapper.
|
||||
*/
|
||||
public function getKillString(): string
|
||||
{
|
||||
if ($this->timeoutPath && $this->timeoutSeconds > 0) {
|
||||
return '"'.$this->timeoutPath.'" --foreground --signal=KILL '.$this->timeoutSeconds.' "';
|
||||
}
|
||||
return '"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
use Blacklight\ColorCLI;
|
||||
/**
|
||||
* Service for CLI output during additional processing.
|
||||
* Centralizes all echo/output functionality.
|
||||
*/
|
||||
class ConsoleOutputService
|
||||
{
|
||||
private ColorCLI $colorCLI;
|
||||
public function __construct(
|
||||
private readonly bool $echoCLI = false
|
||||
) {
|
||||
$this->colorCLI = new ColorCLI();
|
||||
}
|
||||
public function echo(string $message, string $type = 'primary'): void
|
||||
{
|
||||
if ($this->echoCLI) {
|
||||
$this->colorCLI->$type($message);
|
||||
}
|
||||
}
|
||||
public function debug(string $message): void
|
||||
{
|
||||
if ($this->echoCLI && config('app.env') === 'local' && config('app.debug') === true) {
|
||||
$this->colorCLI->debug('DEBUG: '.$message);
|
||||
}
|
||||
}
|
||||
public function echoDescription(int $totalReleases): void
|
||||
{
|
||||
if ($totalReleases > 1 && $this->echoCLI) {
|
||||
$this->echo(
|
||||
PHP_EOL.
|
||||
'Additional post-processing, started at: '.
|
||||
now()->format('D M d, Y G:i a').
|
||||
PHP_EOL.
|
||||
'Downloaded: (xB)=yEnc article, (cB)=compressed part'.
|
||||
PHP_EOL.
|
||||
'Failures: fC#=Compressed(part #), fS=Sample, fM=Media(video), fA=Audio, fJ=JPEG, G=Missing group'.
|
||||
PHP_EOL.
|
||||
'Processing: r=RAR, z=ZIP, 7z=7zip (names/entries), g=GZIP, b=BZIP2, x=XZ, (vRAW)=Inline video detected'.
|
||||
PHP_EOL.
|
||||
'Added: s=Sample image, j=JPEG image, A=Audio sample, a=Audio MediaInfo, v=Video sample'.
|
||||
PHP_EOL.
|
||||
'Added: m=Video MediaInfo, n=NFO, ^=Inner file details (RAR/ZIP/7z/etc)'.
|
||||
'',
|
||||
'header'
|
||||
);
|
||||
}
|
||||
}
|
||||
public function echoReleaseStart(int $releaseId, int $size): void
|
||||
{
|
||||
$this->echo(PHP_EOL.'['.$releaseId.']['.human_filesize($size, 1).']', 'primaryOver');
|
||||
}
|
||||
public function echoCompressedDownload(): void { $this->echo('(cB)', 'primaryOver'); }
|
||||
public function echoCompressedFailure(int $failCount): void { $this->echo('fC'.$failCount, 'warningOver'); }
|
||||
public function echoGroupUnavailable(): void { $this->echo('G', 'warningOver'); }
|
||||
public function echoSampleDownload(): void { $this->echo('(sB)', 'primaryOver'); }
|
||||
public function echoSampleFailure(): void { $this->echo('fS', 'warningOver'); }
|
||||
public function echoMediaInfoDownload(): void { $this->echo('(mB)', 'primaryOver'); }
|
||||
public function echoMediaInfoFailure(): void { $this->echo('fM', 'warningOver'); }
|
||||
public function echoAudioDownload(): void { $this->echo('(aB)', 'primaryOver'); }
|
||||
public function echoAudioFailure(): void { $this->echo('fA', 'warningOver'); }
|
||||
public function echoJpgDownload(): void { $this->echo('(jB)', 'primaryOver'); }
|
||||
public function echoJpgFailure(): void { $this->echo('fJ', 'warningOver'); }
|
||||
public function echoArchiveMarker(string $marker): void { $this->echo($marker, 'primaryOver'); }
|
||||
public function echoFileInfoAdded(): void { $this->echo('^', 'primaryOver'); }
|
||||
public function echoSampleCreated(): void { $this->echo('s', 'primaryOver'); }
|
||||
public function echoJpgSaved(): void { $this->echo('j', 'primaryOver'); }
|
||||
public function echoAudioSampleCreated(): void { $this->echo('A', 'primaryOver'); }
|
||||
public function echoAudioInfoAdded(): void { $this->echo('a', 'primaryOver'); }
|
||||
public function echoVideoCreated(): void { $this->echo('v', 'primaryOver'); }
|
||||
public function echoMediaInfoAdded(): void { $this->echo('m', 'primaryOver'); }
|
||||
public function echoNfoFound(): void { $this->echo('n', 'primaryOver'); }
|
||||
public function echoInlineVideo(): void { $this->echo('(vRAW)', 'primaryOver'); }
|
||||
public function warning(string $message): void { $this->echo($message, 'warning'); }
|
||||
public function echoReleaseDeleted(int $releaseId): void { $this->echo('Deleted broken release ID '.$releaseId, 'warningOver'); }
|
||||
public function endOutput(): void
|
||||
{
|
||||
if ($this->echoCLI) {
|
||||
echo PHP_EOL;
|
||||
}
|
||||
}
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->echoCLI;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
namespace App\Services\AdditionalProcessing\DTO;
|
||||
use App\Models\Release;
|
||||
/**
|
||||
* Mutable context object that holds the processing state for a single release.
|
||||
* Passed between services during processing to share state.
|
||||
*/
|
||||
class ReleaseProcessingContext
|
||||
{
|
||||
// The release being processed
|
||||
public Release $release;
|
||||
// Processing state flags
|
||||
public bool $foundVideo = false;
|
||||
public bool $foundMediaInfo = false;
|
||||
public bool $foundAudioInfo = false;
|
||||
public bool $foundAudioSample = false;
|
||||
public bool $foundJPGSample = false;
|
||||
public bool $foundSample = false;
|
||||
public bool $foundPAR2Info = false;
|
||||
// Password state
|
||||
public int $passwordStatus = 0;
|
||||
public bool $releaseHasPassword = false;
|
||||
// NFO state
|
||||
public bool $releaseHasNoNFO = false;
|
||||
// Group state
|
||||
public string $releaseGroupName = '';
|
||||
public bool $groupUnavailable = false;
|
||||
// NZB state
|
||||
public bool $nzbHasCompressedFile = false;
|
||||
public array $nzbContents = [];
|
||||
// Message IDs for downloading
|
||||
public array $sampleMessageIDs = [];
|
||||
public array $jpgMessageIDs = [];
|
||||
public string|array $mediaInfoMessageIDs = [];
|
||||
public string|array $audioInfoMessageIDs = [];
|
||||
public array $rarFileMessageIDs = [];
|
||||
public string $audioInfoExtension = '';
|
||||
// File info counters
|
||||
public int $addedFileInfo = 0;
|
||||
public int $totalFileInfo = 0;
|
||||
public int $compressedFilesChecked = 0;
|
||||
// Temp path for this release
|
||||
public string $tmpPath = '';
|
||||
public function __construct(Release $release)
|
||||
{
|
||||
$this->release = $release;
|
||||
}
|
||||
/**
|
||||
* Initialize processing state based on configuration flags.
|
||||
* Sets "found" flags to true if processing is disabled (to skip those steps).
|
||||
*/
|
||||
public function initializeFromConfig(
|
||||
bool $processVideo,
|
||||
bool $processMediaInfo,
|
||||
bool $processAudioInfo,
|
||||
bool $processAudioSample,
|
||||
bool $processJPGSample,
|
||||
bool $processThumbnails
|
||||
): void {
|
||||
$this->foundVideo = ! $processVideo;
|
||||
$this->foundMediaInfo = ! $processMediaInfo;
|
||||
$this->foundAudioInfo = ! $processAudioInfo;
|
||||
$this->foundAudioSample = ! $processAudioSample;
|
||||
$this->foundJPGSample = ! $processJPGSample;
|
||||
$this->foundSample = ! $processThumbnails;
|
||||
$this->foundPAR2Info = false;
|
||||
}
|
||||
/**
|
||||
* Reset message ID arrays for a fresh processing run.
|
||||
*/
|
||||
public function resetMessageIDs(): void
|
||||
{
|
||||
$this->sampleMessageIDs = [];
|
||||
$this->jpgMessageIDs = [];
|
||||
$this->mediaInfoMessageIDs = [];
|
||||
$this->audioInfoMessageIDs = [];
|
||||
$this->rarFileMessageIDs = [];
|
||||
$this->audioInfoExtension = '';
|
||||
}
|
||||
/**
|
||||
* Reset file counters.
|
||||
*/
|
||||
public function resetCounters(): void
|
||||
{
|
||||
$this->addedFileInfo = 0;
|
||||
$this->totalFileInfo = 0;
|
||||
$this->compressedFilesChecked = 0;
|
||||
}
|
||||
/**
|
||||
* Full reset for processing a new release.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->passwordStatus = 0;
|
||||
$this->releaseHasPassword = false;
|
||||
$this->nzbHasCompressedFile = false;
|
||||
$this->groupUnavailable = false;
|
||||
$this->resetMessageIDs();
|
||||
$this->resetCounters();
|
||||
}
|
||||
/**
|
||||
* Check if more media processing is needed.
|
||||
*/
|
||||
public function needsMediaProcessing(): bool
|
||||
{
|
||||
return ! $this->foundVideo
|
||||
|| ! $this->foundMediaInfo
|
||||
|| ! $this->foundAudioInfo
|
||||
|| ! $this->foundAudioSample;
|
||||
}
|
||||
/**
|
||||
* Check if any sample is still needed.
|
||||
*/
|
||||
public function needsSample(): bool
|
||||
{
|
||||
return ! $this->foundSample || ! $this->foundJPGSample;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Release;
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
|
||||
use App\Services\Categorization\CategorizationService;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Blacklight\NameFixer;
|
||||
use Blacklight\ReleaseExtra;
|
||||
use Blacklight\ReleaseImage;
|
||||
use FFMpeg\Coordinate\Dimension;
|
||||
use FFMpeg\Coordinate\TimeCode;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\FFProbe;
|
||||
use FFMpeg\Filters\Video\ResizeFilter;
|
||||
use FFMpeg\Format\Audio\Vorbis;
|
||||
use FFMpeg\Format\Video\Ogg;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mhor\MediaInfo\MediaInfo;
|
||||
|
||||
/**
|
||||
* Service for processing media files (video, audio, images).
|
||||
* Handles sample generation, thumbnails, media info extraction, and audio processing.
|
||||
*/
|
||||
class MediaExtractionService
|
||||
{
|
||||
private ?FFMpeg $ffmpeg = null;
|
||||
private ?FFProbe $ffprobe = null;
|
||||
private ?MediaInfo $mediaInfo = null;
|
||||
private ?ManticoreSearch $manticore = null;
|
||||
private ?ElasticSearchSiteSearch $elasticsearch = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config,
|
||||
private readonly ReleaseImage $releaseImage,
|
||||
private readonly ReleaseExtra $releaseExtra,
|
||||
private readonly CategorizationService $categorize
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get video time code for sample extraction.
|
||||
*/
|
||||
public function getVideoTime(string $videoLocation): string
|
||||
{
|
||||
try {
|
||||
if (! $this->ffprobe()->isValid($videoLocation)) {
|
||||
return '';
|
||||
}
|
||||
$time = $this->ffprobe()->format($videoLocation)->get('duration');
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug($e->getMessage());
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (empty($time) || ! preg_match('/time=(\d{1,2}:\d{1,2}:)?(\d{1,2})\.(\d{1,2})\s*bitrate=/i', $time, $numbers)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($numbers[3] > 0) {
|
||||
$numbers[3]--;
|
||||
} elseif ($numbers[1] > 0) {
|
||||
$numbers[2]--;
|
||||
$numbers[3] = '99';
|
||||
}
|
||||
|
||||
return '00:00:'.str_pad($numbers[2], 2, '0', STR_PAD_LEFT).'.'.str_pad($numbers[3], 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a sample image from a video file.
|
||||
*/
|
||||
public function getSample(string $fileLocation, string $tmpPath, string $guid): bool
|
||||
{
|
||||
if (! $this->config->processThumbnails || ! File::isFile($fileLocation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fileName = $tmpPath.'zzzz'.random_int(5, 12).random_int(5, 12).'.jpg';
|
||||
$time = $this->getVideoTime($fileLocation);
|
||||
|
||||
try {
|
||||
if ($this->ffprobe()->isValid($fileLocation)) {
|
||||
$this->ffmpeg()->open($fileLocation)
|
||||
->frame(TimeCode::fromString($time === '' ? '00:00:03:00' : $time))
|
||||
->save($fileName);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::error($e->getTraceAsString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! File::isFile($fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$saved = $this->releaseImage->saveImage(
|
||||
$guid.'_thumb',
|
||||
$fileName,
|
||||
$this->releaseImage->imgSavePath,
|
||||
800,
|
||||
600
|
||||
);
|
||||
|
||||
File::delete($fileName);
|
||||
|
||||
return $saved === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a video sample clip.
|
||||
*/
|
||||
public function getVideo(string $fileLocation, string $tmpPath, string $guid): bool
|
||||
{
|
||||
if (! $this->config->processVideo || ! File::isFile($fileLocation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fileName = $tmpPath.'zzzz'.$guid.'.ogv';
|
||||
$newMethod = false;
|
||||
|
||||
// Try to get sample from end of video if duration is short
|
||||
if ($this->config->ffmpegDuration < 60) {
|
||||
$time = $this->getVideoTime($fileLocation);
|
||||
if ($time !== '' && preg_match('/(\d{2}).(\d{2})/', $time, $numbers)) {
|
||||
$newMethod = true;
|
||||
if ($numbers[1] <= $this->config->ffmpegDuration) {
|
||||
$lowestLength = '00:00:00.00';
|
||||
} else {
|
||||
$lowestLength = ($numbers[1] - $this->config->ffmpegDuration);
|
||||
$end = '.'.$numbers[2];
|
||||
$lowestLength = match (strlen($lowestLength)) {
|
||||
1 => '00:00:0'.$lowestLength.$end,
|
||||
2 => '00:00:'.$lowestLength.$end,
|
||||
default => '00:00:60.00',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->ffprobe()->isValid($fileLocation)) {
|
||||
$video = $this->ffmpeg()->open($fileLocation);
|
||||
$clip = $video->clip(
|
||||
TimeCode::fromString($lowestLength),
|
||||
TimeCode::fromSeconds($this->config->ffmpegDuration)
|
||||
);
|
||||
$format = new Ogg();
|
||||
$format->setAudioCodec('libvorbis');
|
||||
$clip->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT);
|
||||
$clip->save($format, $fileName);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::error($e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use start of video
|
||||
if (! $newMethod) {
|
||||
try {
|
||||
if ($this->ffprobe()->isValid($fileLocation)) {
|
||||
$video = $this->ffmpeg()->open($fileLocation);
|
||||
$clip = $video->clip(
|
||||
TimeCode::fromSeconds(0),
|
||||
TimeCode::fromSeconds($this->config->ffmpegDuration)
|
||||
);
|
||||
$format = new Ogg();
|
||||
$format->setAudioCodec('libvorbis');
|
||||
$clip->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT);
|
||||
$clip->save($format, $fileName);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::error($e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! File::isFile($fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newFile = $this->releaseImage->vidSavePath.$guid.'.ogv';
|
||||
|
||||
if (! @File::move($fileName, $newFile)) {
|
||||
$copied = @File::copy($fileName, $newFile);
|
||||
File::delete($fileName);
|
||||
if (! $copied) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@chmod($newFile, 0764);
|
||||
Release::query()->where('guid', $guid)->update(['videostatus' => 1]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media info from a video file.
|
||||
*/
|
||||
public function getMediaInfo(string $fileLocation, int $releaseId): bool
|
||||
{
|
||||
if (! $this->config->processMediaInfo || ! File::isFile($fileLocation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$xmlArray = $this->mediaInfo()->getInfo($fileLocation, true);
|
||||
\App\Models\MediaInfo::addData($releaseId, $xmlArray);
|
||||
$this->releaseExtra->addFromXml($releaseId, $xmlArray);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a JPG sample image.
|
||||
*/
|
||||
public function getJPGSample(string $fileLocation, string $guid): bool
|
||||
{
|
||||
$saved = $this->releaseImage->saveImage(
|
||||
$guid.'_thumb',
|
||||
$fileLocation,
|
||||
$this->releaseImage->jpgSavePath,
|
||||
650,
|
||||
650
|
||||
);
|
||||
|
||||
if ($saved === 1) {
|
||||
Release::query()->where('guid', $guid)->update(['jpgstatus' => 1]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process audio file for media info and sample.
|
||||
*
|
||||
* @return array{audioInfo: bool, audioSample: bool}
|
||||
*/
|
||||
public function getAudioInfo(
|
||||
string $fileLocation,
|
||||
string $fileExtension,
|
||||
ReleaseProcessingContext $context,
|
||||
string $tmpPath
|
||||
): array {
|
||||
$result = ['audioInfo' => false, 'audioSample' => false];
|
||||
|
||||
if (! $this->config->processAudioSample) {
|
||||
$result['audioSample'] = true;
|
||||
}
|
||||
if (! $this->config->processAudioInfo) {
|
||||
$result['audioInfo'] = true;
|
||||
}
|
||||
|
||||
$rQuery = Release::query()
|
||||
->where('proc_pp', '=', 0)
|
||||
->where('id', $context->release->id)
|
||||
->select(['searchname', 'fromname', 'categories_id', 'groups_id'])
|
||||
->first();
|
||||
|
||||
$musicParent = (string) Category::MUSIC_ROOT;
|
||||
if ($rQuery === null || ! preg_match(
|
||||
sprintf(
|
||||
'/%d\d{3}|%d|%d|%d/',
|
||||
$musicParent[0],
|
||||
Category::OTHER_MISC,
|
||||
Category::MOVIE_OTHER,
|
||||
Category::TV_OTHER
|
||||
),
|
||||
$rQuery->categories_id
|
||||
)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (! File::isFile($fileLocation)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Get media info
|
||||
if (! $result['audioInfo']) {
|
||||
try {
|
||||
$xmlArray = $this->mediaInfo()->getInfo($fileLocation, false);
|
||||
if ($xmlArray !== null) {
|
||||
foreach ($xmlArray->getAudios() as $track) {
|
||||
if ($track->get('album') !== null && $track->get('performer') !== null) {
|
||||
if ((int) $context->release->predb_id === 0 && $this->config->renameMusicMediaInfo) {
|
||||
$ext = strtoupper($fileExtension);
|
||||
|
||||
$newName = $track->get('performer')->getFullName().' - '.$track->get('album')->getFullName();
|
||||
if (! empty($track->get('recorded_date'))
|
||||
&& preg_match('/(?:19|20)\d\d/', $track->get('recorded_date')->getFullname, $Year)
|
||||
) {
|
||||
$newName .= ' ('.$Year[0].') '.$ext;
|
||||
} else {
|
||||
$newName .= ' '.$ext;
|
||||
}
|
||||
|
||||
$newCat = match ($ext) {
|
||||
'MP3' => Category::MUSIC_MP3,
|
||||
'FLAC' => Category::MUSIC_LOSSLESS,
|
||||
default => $this->categorize->determineCategory($rQuery->groups_id, $newName, $rQuery->fromname),
|
||||
};
|
||||
|
||||
$newTitle = escapeString(substr($newName, 0, 255));
|
||||
Release::whereId($context->release->id)->update([
|
||||
'searchname' => $newTitle,
|
||||
'categories_id' => is_array($newCat) ? $newCat['categories_id'] : $newCat,
|
||||
'iscategorized' => 1,
|
||||
'isrenamed' => 1,
|
||||
'proc_pp' => 1,
|
||||
]);
|
||||
|
||||
if ($this->config->elasticsearchEnabled) {
|
||||
$this->elasticsearch()->updateRelease($context->release->id);
|
||||
} else {
|
||||
$this->manticore()->updateRelease($context->release->id);
|
||||
}
|
||||
|
||||
if ($this->config->echoCLI) {
|
||||
NameFixer::echoChangedReleaseName([
|
||||
'new_name' => $newTitle,
|
||||
'old_name' => $rQuery->searchname,
|
||||
'new_category' => $newCat,
|
||||
'old_category' => $rQuery->categories_id,
|
||||
'group' => $rQuery->groups_id,
|
||||
'releases_id' => $context->release->id,
|
||||
'method' => 'MediaExtractionService->getAudioInfo',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->releaseExtra->addFromXml($context->release->id, $xmlArray);
|
||||
$result['audioInfo'] = true;
|
||||
$context->foundAudioInfo = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Create audio sample
|
||||
if (! $result['audioSample']) {
|
||||
$audioFileName = $context->release->guid.'.ogg';
|
||||
|
||||
try {
|
||||
if ($this->ffprobe()->isValid($fileLocation)) {
|
||||
$audioSample = $this->ffmpeg()->open($fileLocation);
|
||||
$format = new Vorbis();
|
||||
$audioSample->clip(TimeCode::fromSeconds(30), TimeCode::fromSeconds(30));
|
||||
$audioSample->save($format, $tmpPath.$audioFileName);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::error($e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
|
||||
if (File::isFile($tmpPath.$audioFileName)) {
|
||||
$renamed = File::move($tmpPath.$audioFileName, $this->config->audioSavePath.$audioFileName);
|
||||
if (! $renamed) {
|
||||
$copied = File::copy($tmpPath.$audioFileName, $this->config->audioSavePath.$audioFileName);
|
||||
File::delete($tmpPath.$audioFileName);
|
||||
if (! $copied) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@chmod($this->config->audioSavePath.$audioFileName, 0764);
|
||||
Release::query()->where('id', $context->release->id)->update(['audiostatus' => 1]);
|
||||
$result['audioSample'] = true;
|
||||
$context->foundAudioSample = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a video file for sample, video clip, and media info.
|
||||
*/
|
||||
public function processVideoFile(
|
||||
string $fileLocation,
|
||||
ReleaseProcessingContext $context,
|
||||
string $tmpPath
|
||||
): array {
|
||||
$result = [
|
||||
'sample' => false,
|
||||
'video' => false,
|
||||
'mediaInfo' => false,
|
||||
];
|
||||
|
||||
if (! $context->foundSample) {
|
||||
$result['sample'] = $this->getSample($fileLocation, $tmpPath, $context->release->guid);
|
||||
if ($result['sample']) {
|
||||
$context->foundSample = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only get video if sampleMessageIDs count is less than 2
|
||||
if (! $context->foundVideo && count($context->sampleMessageIDs) < 2) {
|
||||
$result['video'] = $this->getVideo($fileLocation, $tmpPath, $context->release->guid);
|
||||
if ($result['video']) {
|
||||
$context->foundVideo = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $context->foundMediaInfo) {
|
||||
$result['mediaInfo'] = $this->getMediaInfo($fileLocation, $context->release->id);
|
||||
if ($result['mediaInfo']) {
|
||||
$context->foundMediaInfo = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data appears to be a JPEG image.
|
||||
*/
|
||||
public function isJpegData(string $filePath): bool
|
||||
{
|
||||
if (! File::isFile($filePath)) {
|
||||
return false;
|
||||
}
|
||||
return exif_imagetype($filePath) === IMAGETYPE_JPEG;
|
||||
}
|
||||
|
||||
private function ffmpeg(): FFMpeg
|
||||
{
|
||||
if ($this->ffmpeg === null) {
|
||||
$timeout = $this->config->timeoutSeconds > 0 ? $this->config->timeoutSeconds : 60;
|
||||
$this->ffmpeg = FFMpeg::create(['timeout' => $timeout]);
|
||||
}
|
||||
return $this->ffmpeg;
|
||||
}
|
||||
|
||||
private function ffprobe(): FFProbe
|
||||
{
|
||||
if ($this->ffprobe === null) {
|
||||
$this->ffprobe = FFProbe::create();
|
||||
}
|
||||
return $this->ffprobe;
|
||||
}
|
||||
|
||||
private function mediaInfo(): MediaInfo
|
||||
{
|
||||
if ($this->mediaInfo === null) {
|
||||
$this->mediaInfo = new MediaInfo();
|
||||
$this->mediaInfo->setConfig('use_oldxml_mediainfo_output_format', true);
|
||||
if ($this->config->mediaInfoPath) {
|
||||
$this->mediaInfo->setConfig('command', $this->config->mediaInfoPath);
|
||||
}
|
||||
}
|
||||
return $this->mediaInfo;
|
||||
}
|
||||
|
||||
private function manticore(): ManticoreSearch
|
||||
{
|
||||
if ($this->manticore === null) {
|
||||
$this->manticore = new ManticoreSearch();
|
||||
}
|
||||
return $this->manticore;
|
||||
}
|
||||
|
||||
private function elasticsearch(): ElasticSearchSiteSearch
|
||||
{
|
||||
if ($this->elasticsearch === null) {
|
||||
$this->elasticsearch = new ElasticSearchSiteSearch();
|
||||
}
|
||||
return $this->elasticsearch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
|
||||
use Blacklight\NZB;
|
||||
use Blacklight\utility\Utility;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Service for parsing NZB file contents and extracting file metadata.
|
||||
* Handles NZB repair, file listing, and message ID extraction.
|
||||
*/
|
||||
class NzbContentParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NZB $nzb,
|
||||
private readonly bool $debugMode = false,
|
||||
private readonly bool $echoCLI = false
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse an NZB file and return its contents as an array of files.
|
||||
*
|
||||
* @param string $guid The release GUID to find the NZB for
|
||||
* @return array{contents: array, error: string|null}
|
||||
*/
|
||||
public function parseNzb(string $guid): array
|
||||
{
|
||||
$nzbPath = $this->nzb->NZBPath($guid);
|
||||
if ($nzbPath === false) {
|
||||
return ['contents' => [], 'error' => 'NZB not found for GUID: '.$guid];
|
||||
}
|
||||
|
||||
$nzbContents = Utility::unzipGzipFile($nzbPath);
|
||||
if (! $nzbContents) {
|
||||
// Try repair on raw file contents
|
||||
$nzbContents = $this->attemptRawRepair($nzbPath);
|
||||
if (! $nzbContents) {
|
||||
return ['contents' => [], 'error' => 'NZB is empty or broken for GUID: '.$guid];
|
||||
}
|
||||
}
|
||||
|
||||
// Get a list of files in the NZB
|
||||
$fileList = $this->nzb->nzbFileList($nzbContents, ['no-file-key' => false, 'strip-count' => true]);
|
||||
if (count($fileList) === 0) {
|
||||
// Attempt repair if initial parse yielded no files
|
||||
$repaired = $this->repairNzb($nzbContents, $nzbPath, $guid);
|
||||
if ($repaired !== null) {
|
||||
$fileList = $this->nzb->nzbFileList($repaired, ['no-file-key' => false, 'strip-count' => true]);
|
||||
}
|
||||
if (count($fileList) === 0) {
|
||||
return ['contents' => [], 'error' => 'NZB is potentially broken for GUID: '.$guid];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort keys naturally
|
||||
ksort($fileList, SORT_NATURAL);
|
||||
|
||||
return ['contents' => $fileList, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to repair raw file contents before XML parsing.
|
||||
*/
|
||||
private function attemptRawRepair(string $nzbPath): ?string
|
||||
{
|
||||
try {
|
||||
$rawFile = @File::get($nzbPath);
|
||||
if (! $rawFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If gzipped, attempt decompress
|
||||
if (str_ends_with(strtolower($nzbPath), '.gz')) {
|
||||
$decompressed = @gzdecode($rawFile);
|
||||
if ($decompressed !== false) {
|
||||
return $this->repairNzb($decompressed, $nzbPath, '');
|
||||
}
|
||||
} else {
|
||||
return $this->repairNzb($rawFile, $nzbPath, '');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to repair a potentially broken NZB XML string.
|
||||
*/
|
||||
public function repairNzb(string $raw, string $originalPath, string $guid): ?string
|
||||
{
|
||||
// Remove common binary / control chars except tab, newline, carriage return
|
||||
$fixed = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $raw);
|
||||
|
||||
// If missing opening <nzb ...> tag, wrap content
|
||||
if (! str_contains(strtolower($fixed), '<nzb')) {
|
||||
$fixed = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<nzb xmlns=\"http://www.newzbin.com/DTD/2003/nzb\">\n".$fixed."\n</nzb>";
|
||||
} else {
|
||||
// Ensure closing tag
|
||||
if (! preg_match('/<\/nzb>\s*$/i', $fixed)) {
|
||||
$fixed .= "\n</nzb>";
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse using libxml recovery
|
||||
$opts = LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_COMPACT | LIBXML_NONET | LIBXML_NOCDATA | LIBXML_PARSEHUGE;
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($fixed, 'SimpleXMLElement', $opts);
|
||||
$errors = libxml_get_errors();
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
|
||||
if ($xml === false || empty($xml->file)) {
|
||||
if ($this->debugMode && $this->echoCLI) {
|
||||
$guidInfo = $guid ? ' for GUID: '.$guid : '';
|
||||
echo 'NZB repair failed'.$guidInfo.' ('.count($errors).' XML errors)'.PHP_EOL;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Persist a repaired version if content changed
|
||||
try {
|
||||
if ($fixed !== $raw) {
|
||||
if (str_ends_with(strtolower($originalPath), '.gz')) {
|
||||
@File::put($originalPath, gzencode($fixed));
|
||||
} else {
|
||||
@File::put($originalPath, $fixed);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->debugMode) {
|
||||
Log::debug('Failed to persist repaired NZB: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $fixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process NZB contents to extract message IDs for different file types.
|
||||
*
|
||||
* @return array{
|
||||
* hasCompressedFile: bool,
|
||||
* sampleMessageIDs: array,
|
||||
* jpgMessageIDs: array,
|
||||
* mediaInfoMessageID: string,
|
||||
* audioInfoMessageID: string,
|
||||
* audioInfoExtension: string,
|
||||
* bookFileCount: int
|
||||
* }
|
||||
*/
|
||||
public function extractMessageIDs(
|
||||
array $nzbContents,
|
||||
string $groupName,
|
||||
int $segmentsToDownload,
|
||||
bool $processThumbnails,
|
||||
bool $processJPGSample,
|
||||
bool $processMediaInfo,
|
||||
bool $processAudioInfo,
|
||||
string $audioFileRegex,
|
||||
string $videoFileRegex,
|
||||
string $supportFileRegex,
|
||||
string $ignoreBookRegex
|
||||
): array {
|
||||
$result = [
|
||||
'hasCompressedFile' => false,
|
||||
'sampleMessageIDs' => [],
|
||||
'jpgMessageIDs' => [],
|
||||
'mediaInfoMessageID' => '',
|
||||
'audioInfoMessageID' => '',
|
||||
'audioInfoExtension' => '',
|
||||
'bookFileCount' => 0,
|
||||
];
|
||||
|
||||
foreach ($nzbContents as $file) {
|
||||
try {
|
||||
$title = $file['title'] ?? '';
|
||||
$segments = $file['segments'] ?? [];
|
||||
|
||||
// Skip support/nfo files
|
||||
if (preg_match('/(?:'.$supportFileRegex.'|nfo\\b|inf\\b|ofn\\b)($|[ ")]|-])(?!.{20,})/i', $title)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compressed file detection
|
||||
if (! $result['hasCompressedFile'] && preg_match(
|
||||
'/(\\.(part\\d+|[rz]\\d+|rar|0+|0*10?|zipr\\d{2,3}|zipx?|7z(?:\\.\\d{3})?|(?:tar\\.)?(?:gz|bz2|xz))("|\\s*\\.rar)*($|[ ")]|-])|"[a-f0-9]{32}\\.[1-9]\\d{1,2}".*\\(\\d+\\/\\d{2,}\\)$)/i',
|
||||
$title
|
||||
)) {
|
||||
$result['hasCompressedFile'] = true;
|
||||
}
|
||||
|
||||
// Look for a video sample (not an image)
|
||||
if ($processThumbnails && empty($result['sampleMessageIDs']) && ! empty($segments)
|
||||
&& stripos($title, 'sample') !== false
|
||||
&& ! preg_match('/\.jpe?g$/i', $title)
|
||||
) {
|
||||
$result['sampleMessageIDs'] = $this->extractSegments($segments, $segmentsToDownload);
|
||||
}
|
||||
|
||||
// Look for a JPG picture (not a CD cover)
|
||||
if ($processJPGSample && empty($result['jpgMessageIDs']) && ! empty($segments)
|
||||
&& ! preg_match('/flac|lossless|mp3|music|inner-sanctum|sound/i', $groupName)
|
||||
&& preg_match('/\.jpe?g[. ")\]]/i', $title)
|
||||
) {
|
||||
$result['jpgMessageIDs'] = $this->extractSegments($segments, $segmentsToDownload);
|
||||
}
|
||||
|
||||
// Look for a video file for MediaInfo (sample video)
|
||||
if ($processMediaInfo && empty($result['mediaInfoMessageID']) && ! empty($segments[0])
|
||||
&& stripos($title, 'sample') !== false
|
||||
&& preg_match('/'.$videoFileRegex.'[. ")\]]/i', $title)
|
||||
) {
|
||||
$result['mediaInfoMessageID'] = (string) $segments[0];
|
||||
}
|
||||
|
||||
// Look for an audio file
|
||||
if ($processAudioInfo && empty($result['audioInfoMessageID']) && ! empty($segments)
|
||||
&& preg_match('/'.$audioFileRegex.'[. ")\]]/i', $title, $type)
|
||||
) {
|
||||
$result['audioInfoExtension'] = $type[1];
|
||||
$result['audioInfoMessageID'] = (string) $segments[0];
|
||||
}
|
||||
|
||||
// Count book files
|
||||
if (preg_match($ignoreBookRegex, $title)) {
|
||||
$result['bookFileCount']++;
|
||||
}
|
||||
} catch (\ErrorException $e) {
|
||||
Log::debug($e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract segment message IDs up to a limit.
|
||||
*/
|
||||
private function extractSegments(array $segments, int $limit): array
|
||||
{
|
||||
$ids = [];
|
||||
$segCount = count($segments) - 1;
|
||||
for ($i = 0; $i < $limit; $i++) {
|
||||
if ($i > $segCount) {
|
||||
break;
|
||||
}
|
||||
$ids[] = (string) $segments[$i];
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the NZB path for a GUID.
|
||||
*/
|
||||
public function getNzbPath(string $guid): string|false
|
||||
{
|
||||
return $this->nzb->NZBPath($guid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
|
||||
use App\Models\MediaInfo as MediaInfoModel;
|
||||
use App\Models\Predb;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseFile;
|
||||
use Blacklight\Releases;
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
|
||||
use Blacklight\ElasticSearchSiteSearch;
|
||||
use Blacklight\ManticoreSearch;
|
||||
use Blacklight\NameFixer;
|
||||
use Blacklight\Nfo;
|
||||
use Blacklight\NZB;
|
||||
use Blacklight\ReleaseExtra;
|
||||
use Blacklight\ReleaseImage;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Service for managing release-related database operations.
|
||||
* Handles file info, release updates, deletions, and search index updates.
|
||||
*/
|
||||
class ReleaseFileManager
|
||||
{
|
||||
private ?ManticoreSearch $manticore = null;
|
||||
private ?ElasticSearchSiteSearch $elasticsearch = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config,
|
||||
private readonly ReleaseExtra $releaseExtra,
|
||||
private readonly ReleaseImage $releaseImage,
|
||||
private readonly Nfo $nfo,
|
||||
private readonly NZB $nzb,
|
||||
private readonly NameFixer $nameFixer
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Add file information to the database.
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function addFileInfo(
|
||||
array $file,
|
||||
ReleaseProcessingContext $context,
|
||||
string $supportFileRegex
|
||||
): bool {
|
||||
if (isset($file['error'])) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug("Error: {$file['error']} (in: {$file['source']})");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! isset($file['name'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for password
|
||||
if (isset($file['pass']) && $file['pass'] === true) {
|
||||
$context->releaseHasPassword = true;
|
||||
$context->passwordStatus = Releases::PASSWD_RAR;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check inner file blacklist
|
||||
if ($this->config->innerFileBlacklist !== false
|
||||
&& preg_match($this->config->innerFileBlacklist, $file['name'])
|
||||
) {
|
||||
$context->releaseHasPassword = true;
|
||||
$context->passwordStatus = Releases::PASSWD_RAR;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip support files
|
||||
if (preg_match(
|
||||
'/(?:'.$supportFileRegex.'|part\d+|[rz]\d{1,3}|zipr\d{2,3}|\d{2,3}|zipx?|zip|rar|7z|gz|bz2|xz)(\s*\.rar)?$/i',
|
||||
$file['name']
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment total file info count
|
||||
$context->totalFileInfo++;
|
||||
|
||||
// Don't add too many files
|
||||
if ($context->addedFileInfo >= 11) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if a file already exists
|
||||
$exists = ReleaseFile::query()
|
||||
->where([
|
||||
'releases_id' => $context->release->id,
|
||||
'name' => $file['name'],
|
||||
'size' => $file['size'],
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($exists !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add the file
|
||||
$added = ReleaseFile::addReleaseFiles(
|
||||
$context->release->id,
|
||||
$file['name'],
|
||||
$file['size'],
|
||||
$file['date'],
|
||||
$file['pass'] ?? 0,
|
||||
'',
|
||||
$file['crc32'] ?? ''
|
||||
);
|
||||
|
||||
if (! empty($added)) {
|
||||
$context->addedFileInfo++;
|
||||
|
||||
// Check for codec spam
|
||||
if (preg_match('#(?:^|[/\\\\])Codec[/\\\\]Setup\.exe$#i', $file['name'])) {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('Codec spam found, setting release to potentially passworded.');
|
||||
}
|
||||
$context->releaseHasPassword = true;
|
||||
$context->passwordStatus = Releases::PASSWD_RAR;
|
||||
} elseif ($file['name'] !== '' && ! str_starts_with($file['name'], '.')) {
|
||||
// Run PreDB filename check
|
||||
$context->release['filename'] = $file['name'];
|
||||
$context->release['releases_id'] = $context->release->id;
|
||||
$this->nameFixer->matchPreDbFiles($context->release, 1, 1, true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search indexes after adding file info.
|
||||
*/
|
||||
public function updateSearchIndex(int $releaseId): void
|
||||
{
|
||||
if ($this->config->elasticsearchEnabled) {
|
||||
$this->elasticsearch()->updateRelease($releaseId);
|
||||
} else {
|
||||
$this->manticore()->updateRelease($releaseId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize release processing with status updates.
|
||||
*/
|
||||
public function finalizeRelease(ReleaseProcessingContext $context, bool $processPasswords): void
|
||||
{
|
||||
$updateRows = ['haspreview' => 0];
|
||||
|
||||
// Check for existing samples
|
||||
if (File::isFile($this->releaseImage->imgSavePath.$context->release->guid.'_thumb.jpg')) {
|
||||
$updateRows = ['haspreview' => 1];
|
||||
}
|
||||
|
||||
if (File::isFile($this->releaseImage->vidSavePath.$context->release->guid.'.ogv')) {
|
||||
$updateRows['videostatus'] = 1;
|
||||
}
|
||||
|
||||
if (File::isFile($this->releaseImage->jpgSavePath.$context->release->guid.'_thumb.jpg')) {
|
||||
$updateRows['jpgstatus'] = 1;
|
||||
}
|
||||
|
||||
// Get file count
|
||||
$releaseFilesCount = ReleaseFile::whereReleasesId($context->release->id)->count('releases_id') ?? 0;
|
||||
|
||||
$passwordStatus = max([$context->passwordStatus]);
|
||||
|
||||
// Set to no password if processing is off
|
||||
if (! $processPasswords) {
|
||||
$context->releaseHasPassword = false;
|
||||
}
|
||||
|
||||
// Update based on conditions
|
||||
if (! $context->releaseHasPassword && $context->nzbHasCompressedFile && $releaseFilesCount === 0) {
|
||||
Release::query()->where('id', $context->release->id)->update($updateRows);
|
||||
} else {
|
||||
$updateRows['passwordstatus'] = $processPasswords ? $passwordStatus : Releases::PASSWD_NONE;
|
||||
$updateRows['rarinnerfilecount'] = $releaseFilesCount;
|
||||
Release::query()->where('id', $context->release->id)->update($updateRows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a broken release completely.
|
||||
*/
|
||||
public function deleteRelease(Release $release): void
|
||||
{
|
||||
try {
|
||||
if (empty($release->id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int) $release->id;
|
||||
$guid = $release->guid ?? '';
|
||||
|
||||
// Delete NZB file
|
||||
try {
|
||||
$nzbPath = $this->nzb->NZBPath($guid);
|
||||
if ($nzbPath && File::exists($nzbPath)) {
|
||||
File::delete($nzbPath);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Delete preview assets
|
||||
try {
|
||||
$files = [
|
||||
$this->releaseImage->imgSavePath.$guid.'_thumb.jpg',
|
||||
$this->releaseImage->jpgSavePath.$guid.'_thumb.jpg',
|
||||
$this->releaseImage->vidSavePath.$guid.'.ogv',
|
||||
];
|
||||
foreach ($files as $file) {
|
||||
if ($file && File::exists($file)) {
|
||||
File::delete($file);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Delete related database rows
|
||||
try {
|
||||
ReleaseFile::where('releases_id', $id)->delete();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
MediaInfoModel::where('releases_id', $id)->delete();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
// Delete from search index
|
||||
try {
|
||||
if ($this->config->elasticsearchEnabled) {
|
||||
$this->elasticsearch()->deleteRelease($id);
|
||||
} else {
|
||||
$this->manticore()->deleteRelease(['i' => $id, 'g' => $guid]);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Delete release row
|
||||
try {
|
||||
Release::where('id', $id)->delete();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Last resort: swallow any exception
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process PAR2 file for file info and release name matching.
|
||||
*/
|
||||
public function processPar2File(
|
||||
string $fileLocation,
|
||||
ReleaseProcessingContext $context,
|
||||
\dariusiii\rarinfo\Par2Info $par2Info
|
||||
): bool {
|
||||
$par2Info->open($fileLocation);
|
||||
|
||||
if ($par2Info->error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$releaseInfo = Release::query()
|
||||
->where('id', $context->release->id)
|
||||
->select(['postdate', 'proc_pp'])
|
||||
->first();
|
||||
|
||||
if ($releaseInfo === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$postDate = Carbon::createFromFormat('Y-m-d H:i:s', $releaseInfo->postdate)->getTimestamp();
|
||||
|
||||
// Only get new name if category is OTHER
|
||||
$foundName = true;
|
||||
if ((int) $releaseInfo->proc_pp === 0 && $this->config->renamePar2
|
||||
&& in_array((int) $context->release->categories_id, \App\Models\Category::OTHERS_GROUP, false)
|
||||
) {
|
||||
$foundName = false;
|
||||
}
|
||||
|
||||
$filesAdded = 0;
|
||||
|
||||
foreach ($par2Info->getFileList() as $file) {
|
||||
if (! isset($file['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($foundName && $filesAdded > 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add to release files
|
||||
if ($this->config->addPAR2Files) {
|
||||
if ($filesAdded < 11
|
||||
&& ReleaseFile::query()
|
||||
->where(['releases_id' => $context->release->id, 'name' => $file['name']])
|
||||
->first() === null
|
||||
) {
|
||||
if (ReleaseFile::addReleaseFiles(
|
||||
$context->release->id,
|
||||
$file['name'],
|
||||
$file['size'],
|
||||
$postDate,
|
||||
0,
|
||||
$file['hash_16K']
|
||||
)) {
|
||||
$filesAdded++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$filesAdded++;
|
||||
}
|
||||
|
||||
// Try to get a new name
|
||||
if (! $foundName) {
|
||||
$context->release->textstring = $file['name'];
|
||||
$context->release->releases_id = $context->release->id;
|
||||
if ($this->nameFixer->checkName($context->release, $this->config->echoCLI, 'PAR2, ', 1, 1)) {
|
||||
$foundName = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update file count
|
||||
Release::query()->where('id', $context->release->id)->increment('rarinnerfilecount', $filesAdded);
|
||||
$context->foundPAR2Info = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process NFO file.
|
||||
*/
|
||||
public function processNfoFile(
|
||||
string $fileLocation,
|
||||
ReleaseProcessingContext $context,
|
||||
\Blacklight\NNTP $nntp
|
||||
): bool {
|
||||
try {
|
||||
$data = File::get($fileLocation);
|
||||
if ($this->nfo->isNFO($data, $context->release->guid)
|
||||
&& $this->nfo->addAlternateNfo($data, $context->release, $nntp)
|
||||
) {
|
||||
$context->releaseHasNoNFO = false;
|
||||
return true;
|
||||
}
|
||||
} catch (FileNotFoundException $e) {
|
||||
Log::warning("Could not read potential NFO file: {$fileLocation} - {$e->getMessage()}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle release name extraction from RAR file content.
|
||||
*/
|
||||
public function processReleaseNameFromRar(
|
||||
array $dataSummary,
|
||||
ReleaseProcessingContext $context
|
||||
): void {
|
||||
$fileData = $dataSummary['file_list'] ?? [];
|
||||
if (empty($fileData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rarFileName = array_column($fileData, 'name');
|
||||
if (empty($rarFileName[0])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$extractedName = $this->extractReleaseNameFromFile($rarFileName[0]);
|
||||
|
||||
if ($extractedName !== null) {
|
||||
$preCheck = Predb::whereTitle($extractedName)->first();
|
||||
$context->release->preid = $preCheck !== null ? $preCheck->value('id') : 0;
|
||||
$candidate = $preCheck->title ?? $extractedName;
|
||||
$candidate = $this->normalizeCandidateTitle($candidate);
|
||||
|
||||
if ($this->isPlausibleReleaseTitle($candidate)) {
|
||||
(new NameFixer())->updateRelease(
|
||||
$context->release,
|
||||
$candidate,
|
||||
'RarInfo FileName Match',
|
||||
true,
|
||||
'Filenames, ',
|
||||
true,
|
||||
true,
|
||||
$context->release->preid
|
||||
);
|
||||
} elseif ($this->config->debugMode) {
|
||||
Log::debug('RarInfo: Ignored low-quality candidate "'.$candidate.'" from inner file name.');
|
||||
}
|
||||
} elseif (! empty($dataSummary['archives'][$rarFileName[0]]['file_list'])) {
|
||||
// Try nested archive
|
||||
$archiveData = $dataSummary['archives'][$rarFileName[0]]['file_list'];
|
||||
$archiveFileName = array_column($archiveData, 'name');
|
||||
$extractedName = $this->extractReleaseNameFromFile($archiveFileName[0] ?? '');
|
||||
|
||||
if ($extractedName !== null) {
|
||||
$preCheck = Predb::whereTitle($extractedName)->first();
|
||||
$context->release->preid = $preCheck !== null ? $preCheck->value('id') : 0;
|
||||
$candidate = $preCheck->title ?? $extractedName;
|
||||
$candidate = $this->normalizeCandidateTitle($candidate);
|
||||
|
||||
if ($this->isPlausibleReleaseTitle($candidate)) {
|
||||
(new NameFixer())->updateRelease(
|
||||
$context->release,
|
||||
$candidate,
|
||||
'RarInfo FileName Match',
|
||||
true,
|
||||
'Filenames, ',
|
||||
true,
|
||||
true,
|
||||
$context->release->preid
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the release name from a filename.
|
||||
*/
|
||||
private function extractReleaseNameFromFile(string $filename): ?string
|
||||
{
|
||||
$basename = basename($filename);
|
||||
$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',
|
||||
'',
|
||||
$basename
|
||||
);
|
||||
|
||||
if (preg_match('/^(.+[-.][A-Za-z0-9_]{2,})$/i', $cleaned, $match)) {
|
||||
return ucwords($match[1], '.-_ ');
|
||||
}
|
||||
|
||||
if (preg_match(NameFixer::PREDB_REGEX, $cleaned, $hit)) {
|
||||
return ucwords($hit[0], '.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a candidate title.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a title is plausible for release naming.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
private function manticore(): ManticoreSearch
|
||||
{
|
||||
if ($this->manticore === null) {
|
||||
$this->manticore = new ManticoreSearch();
|
||||
}
|
||||
return $this->manticore;
|
||||
}
|
||||
|
||||
private function elasticsearch(): ElasticSearchSiteSearch
|
||||
{
|
||||
if ($this->elasticsearch === null) {
|
||||
$this->elasticsearch = new ElasticSearchSiteSearch();
|
||||
}
|
||||
return $this->elasticsearch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AdditionalProcessing;
|
||||
|
||||
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
|
||||
use Blacklight\NNTP;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Service for downloading content from Usenet via NNTP.
|
||||
* Handles message ID downloading with error handling and group availability detection.
|
||||
*/
|
||||
class UsenetDownloadService
|
||||
{
|
||||
private NNTP $nntp;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProcessingConfiguration $config
|
||||
) {
|
||||
$this->nntp = new NNTP();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download binary content from usenet using message IDs.
|
||||
*
|
||||
* @param array|string $messageIDs Single or array of message IDs
|
||||
* @param string $groupName Group name for logging
|
||||
* @param int|null $releaseId Release ID for logging
|
||||
* @return array{success: bool, data: string|null, groupUnavailable: bool, error: string|null}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function downloadByMessageIDs(
|
||||
array|string $messageIDs,
|
||||
string $groupName = '',
|
||||
?int $releaseId = null
|
||||
): array {
|
||||
$result = [
|
||||
'success' => false,
|
||||
'data' => null,
|
||||
'groupUnavailable' => false,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
if (empty($messageIDs)) {
|
||||
$result['error'] = 'No message IDs provided';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Ensure array format
|
||||
if (is_string($messageIDs)) {
|
||||
$messageIDs = [$messageIDs];
|
||||
}
|
||||
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('Attempting NNTP fetch', [
|
||||
'release_id' => $releaseId,
|
||||
'message_ids' => $messageIDs,
|
||||
'group' => $groupName,
|
||||
]);
|
||||
}
|
||||
|
||||
$binary = $this->nntp->getMessagesByMessageID($messageIDs, $this->config->alternateNNTP);
|
||||
|
||||
// Handle non-string or empty response as failure
|
||||
if (! is_string($binary) || $binary === '') {
|
||||
$errorMessage = null;
|
||||
|
||||
if (is_object($binary) && method_exists($binary, 'getMessage')) {
|
||||
$errorMessage = $binary->getMessage();
|
||||
|
||||
// Check for group unavailability
|
||||
if (stripos($errorMessage, 'No such news group') !== false
|
||||
|| stripos($errorMessage, 'Group not found') !== false
|
||||
) {
|
||||
$result['groupUnavailable'] = true;
|
||||
$result['error'] = 'Group unavailable: '.$errorMessage;
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('NNTP fetch failed', [
|
||||
'release_id' => $releaseId,
|
||||
'message_ids' => $messageIDs,
|
||||
'group' => $groupName,
|
||||
'error_object' => is_object($binary) ? get_class($binary) : null,
|
||||
'error_message' => $errorMessage,
|
||||
'raw_type' => gettype($binary),
|
||||
'length' => is_string($binary) ? strlen($binary) : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$result['error'] = $errorMessage ?? 'Download failed';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['success'] = true;
|
||||
$result['data'] = $binary;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download sample video content.
|
||||
*/
|
||||
public function downloadSample(
|
||||
array $messageIDs,
|
||||
string $groupName = '',
|
||||
?int $releaseId = null
|
||||
): array {
|
||||
return $this->downloadByMessageIDs($messageIDs, $groupName, $releaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media info video content.
|
||||
*/
|
||||
public function downloadMediaInfo(
|
||||
string|array $messageID,
|
||||
string $groupName = '',
|
||||
?int $releaseId = null
|
||||
): array {
|
||||
return $this->downloadByMessageIDs($messageID, $groupName, $releaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download audio content.
|
||||
*/
|
||||
public function downloadAudio(
|
||||
string|array $messageID,
|
||||
string $groupName = '',
|
||||
?int $releaseId = null
|
||||
): array {
|
||||
return $this->downloadByMessageIDs($messageID, $groupName, $releaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download JPG content.
|
||||
*/
|
||||
public function downloadJPG(
|
||||
array $messageIDs,
|
||||
string $groupName = '',
|
||||
?int $releaseId = null
|
||||
): array {
|
||||
return $this->downloadByMessageIDs($messageIDs, $groupName, $releaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download compressed file content (RAR, ZIP, etc.).
|
||||
*
|
||||
* @param array $messageIDs Message IDs to download
|
||||
* @param string $groupName Group name for logging
|
||||
* @param int|null $releaseId Release ID for logging
|
||||
* @param string|null $fileTitle File title for logging
|
||||
* @return array{success: bool, data: string|null, groupUnavailable: bool, error: string|null}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function downloadCompressedFile(
|
||||
array $messageIDs,
|
||||
string $groupName = '',
|
||||
?int $releaseId = null,
|
||||
?string $fileTitle = null
|
||||
): array {
|
||||
if ($this->config->debugMode) {
|
||||
Log::debug('Attempting compressed fetch', [
|
||||
'release_id' => $releaseId,
|
||||
'file_title' => $fileTitle,
|
||||
'message_ids' => $messageIDs,
|
||||
'group' => $groupName,
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->downloadByMessageIDs($messageIDs, $groupName, $releaseId);
|
||||
|
||||
if (! $result['success'] && $this->config->debugMode) {
|
||||
Log::debug('Compressed fetch failed', [
|
||||
'release_id' => $releaseId,
|
||||
'file_title' => $fileTitle,
|
||||
'message_ids' => $messageIDs,
|
||||
'group' => $groupName,
|
||||
'error' => $result['error'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the NNTP client instance.
|
||||
*/
|
||||
public function getNNTP(): NNTP
|
||||
{
|
||||
return $this->nntp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the minimum content size requirement is met.
|
||||
*/
|
||||
public function meetsMinimumSize(string $data, int $minimumBytes = 40): bool
|
||||
{
|
||||
return strlen($data) > $minimumBytes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AdditionalProcessingServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\CategorizationServiceProvider::class,
|
||||
App\Providers\ForumServiceProvider::class,
|
||||
|
||||
Reference in New Issue
Block a user