Refactor ProcessAdditional

This commit is contained in:
DariusIII
2025-12-11 16:53:10 +01:00
parent 25b80291b9
commit 0402282346
12 changed files with 3427 additions and 3177 deletions
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
View File
@@ -1,6 +1,7 @@
<?php
return [
App\Providers\AdditionalProcessingServiceProvider::class,
App\Providers\AppServiceProvider::class,
App\Providers\CategorizationServiceProvider::class,
App\Providers\ForumServiceProvider::class,