Fix search and create nzb services

This commit is contained in:
DariusIII
2025-12-26 10:25:34 +01:00
parent f08020a79e
commit 677ba50f93
25 changed files with 610 additions and 381 deletions
+5 -4
View File
@@ -7,6 +7,7 @@ use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\BlacklistService;
use App\Services\Categorization\CategorizationService;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseCleaningService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
@@ -55,7 +56,7 @@ class NZBImport
*/
public mixed $echoCLI;
public NZB $nzb;
public NzbService $nzb;
/**
* @var string the MD5 hash of the first segment Message-ID of the NZB
@@ -69,7 +70,7 @@ class NZBImport
$this->echoCLI = config('nntmux.echocli');
$this->blacklistService = new BlacklistService;
$this->category = new CategorizationService();
$this->nzb = new NZB;
$this->nzb = app(NzbService::class);
$this->releaseCleaner = new ReleaseCleaningService;
$this->colorCli = new ColorCLI;
$this->crossPostt = Settings::settingValue('crossposttime') !== '' ? Settings::settingValue('crossposttime') : 2;
@@ -163,7 +164,7 @@ class NZBImport
if ($inserted) {
// Try to copy the NZB to the NZB folder.
$path = $this->nzb->getNZBPath($this->relGuid, 0, true);
$path = $this->nzb->getNzbPath($this->relGuid, 0, true);
// Try to compress the NZB file in the NZB folder.
$fp = gzopen($path, 'w5');
@@ -393,7 +394,7 @@ class NZBImport
'categories_id' => $determinedCategory['categories_id'],
'isrenamed' => $renamed,
'predb_id' => 0,
'nzbstatus' => NZB::NZB_ADDED,
'nzbstatus' => NzbService::NZB_ADDED,
'ishashed' => 0,
]
);
+10 -12
View File
@@ -9,6 +9,7 @@ use App\Models\ReleaseNfo;
use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\NNTP\NNTPService;
use App\Services\Nzb\NzbContentsService;
use App\Services\PostProcessService;
use dariusiii\rarinfo\Par2Info;
use dariusiii\rarinfo\SfvInfo;
@@ -478,16 +479,11 @@ class Nfo
}
if ($release->completion === 0) {
$nzbContents = new NZBContents(
[
'Echo' => $this->echo,
'NNTP' => $nntp,
'Nfo' => $this,
'Settings' => null,
'PostProcess' => app(PostProcessService::class),
]
);
$nzbContents->parseNZB($release->guid, $release->id, $release->guid);
$nzbContentsService = app(NzbContentsService::class);
$nzbContentsService->setNntp($nntp);
$nzbContentsService->setNfo($this);
$nzbContentsService->setEchoOutput($this->echo);
$nzbContentsService->parseNzb($release->guid, $release->id, $release->groups_id ?? 0);
}
return true;
@@ -534,12 +530,14 @@ class Nfo
}
// Process each release
$nzbContents = new NZBContents(['NNTP' => $nntp, 'Nfo' => $this]);
$nzbContentsService = app(NzbContentsService::class);
$nzbContentsService->setNntp($nntp);
$nzbContentsService->setNfo($this);
foreach ($releases as $release) {
try {
$groupName = UsenetGroup::getNameByID($release['groups_id']);
$fetchedBinary = $nzbContents->getNfoFromNZB($release['guid'], $release['id'], $release['groups_id'], $groupName);
$fetchedBinary = $nzbContentsService->getNfoFromNzb($release['guid'], $release['id'], $release['groups_id'], $groupName);
if ($fetchedBinary !== false) {
DB::beginTransaction();
+3 -3
View File
@@ -3,9 +3,9 @@
namespace App\Console\Commands;
use App\Models\Release;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use App\Services\Releases\ReleaseManagementService;
use Blacklight\NZB;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
@@ -79,7 +79,7 @@ class CleanNZB extends Command
private function GetReleasesWithNoNZBOnDisk($delete = false)
{
// Setup
$nzb = new NZB;
$nzb = app(NzbService::class);
$releaseManagement = app(ReleaseManagementService::class);
$checked = $deleted = 0;
@@ -91,7 +91,7 @@ class CleanNZB extends Command
echo 'Total done: '.$checked."\r";
foreach ($releases as $r) {
if (! $nzb->NZBPath($r->guid)) {
if (! $nzb->nzbPath($r->guid)) {
if ($delete) {
$releaseManagement->deleteSingleWithService(['g' => $r->guid, 'i' => $r->id], $nzb, new ReleaseImageService);
}
@@ -5,8 +5,8 @@ namespace App\Console\Commands;
use App\Facades\Search;
use App\Models\Release;
use App\Models\ReleaseFile;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use Blacklight\NZB;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -47,7 +47,7 @@ class NntmuxRemoveBadReleases extends Command
// Select releases with password status -2 and smaller and delete them. Also delete the files from the filesystem.
$badReleases = Release::query()->where('passwordstatus', '<=', -2)->get();
foreach ($badReleases as $badRelease) {
$nzbPath = (new NZB)->getNZBPath($badRelease->guid);
$nzbPath = app(NzbService::class)->getNzbPath($badRelease->guid);
File::delete($nzbPath);
(new ReleaseImageService)->delete($badRelease->guid);
// Delete from search index
@@ -8,10 +8,10 @@ use App\Models\Category;
use App\Models\Predb;
use App\Models\Release;
use App\Services\NameFixing\NameFixingService;
use App\Services\Nzb\NzbContentsService;
use App\Services\PostProcessService;
use App\Services\NNTP\NNTPService;
use Blacklight\Nfo;
use Blacklight\NZBContents;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -226,17 +226,15 @@ class ReleasesFixNamesGroup extends Command
$this->warn($errorMessage);
} else {
$Nfo = new Nfo();
$nzbcontents = new NZBContents([
'Echo' => false,
'NNTP' => $nntp,
'Nfo' => $Nfo,
'PostProcess' => app(PostProcessService::class),
]);
$nzbcontents = app(NzbContentsService::class);
$nzbcontents->setNntp($nntp);
$nzbcontents->setNfo($Nfo);
$nzbcontents->setEchoOutput(false);
}
}
if (isset($nzbcontents)) {
$nzbcontents->checkPAR2($release->guid, $release->releases_id, $release->groups_id, 1, 1);
$nzbcontents->checkPar2($release->guid, $release->releases_id, $release->groups_id, 1, 1);
}
}
+3 -3
View File
@@ -3,7 +3,7 @@
use App\Models\Country as CountryModel;
use App\Models\Release;
use App\Models\XxxInfo;
use Blacklight\NZB;
use App\Services\Nzb\NzbService;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
@@ -299,10 +299,10 @@ if (! function_exists('getStreamingZip')) {
*/
function getStreamingZip(array $guids = []): STS\ZipStream\Builder
{
$nzb = new NZB;
$nzb = app(NzbService::class);
$zipped = ZipStream::create(now()->format('Ymdhis').'.zip');
foreach ($guids as $guid) {
$nzbPath = $nzb->NZBPath($guid);
$nzbPath = $nzb->nzbPath($guid);
if ($nzbPath) {
$nzbContents = unzipGzipFile($nzbPath);
if ($nzbContents) {
@@ -14,7 +14,6 @@ use App\Models\UserDownload;
use App\Models\UserRequest;
use App\Services\Releases\ReleaseBrowseService;
use App\Services\Releases\ReleaseSearchService;
use Blacklight\NZB;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -4,7 +4,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Release;
use Blacklight\NZB;
use App\Services\Nzb\NzbParserService;
use App\Services\Nzb\NzbService;
use Illuminate\Http\JsonResponse;
class FileListApiController extends Controller
@@ -14,14 +15,15 @@ class FileListApiController extends Controller
*/
public function getFileList(string $guid): JsonResponse
{
$nzb = new NZB;
$nzb = app(NzbService::class);
$nzbParser = app(NzbParserService::class);
$rel = Release::getByGuid($guid);
if (! $rel) {
return response()->json(['error' => 'Release not found'], 404);
}
$nzbpath = $nzb->NZBPath($guid);
$nzbpath = $nzb->nzbPath($guid);
if (! file_exists($nzbpath)) {
return response()->json(['error' => 'NZB file not found'], 404);
@@ -31,7 +33,7 @@ class FileListApiController extends Controller
@readgzfile($nzbpath);
$nzbfile = ob_get_clean();
$files = $nzb->nzbFileList($nzbfile);
$files = $nzbParser->parseNzbFileList($nzbfile);
return response()->json([
'release' => [
+2 -2
View File
@@ -6,7 +6,7 @@ use App\Models\Release;
use App\Models\User;
use App\Models\UserDownload;
use App\Models\UsersRelease;
use Blacklight\NZB;
use App\Services\Nzb\NzbService;
use Exception;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Routing\ResponseFactory;
@@ -251,7 +251,7 @@ class GetNzbController extends BasePageController
string $releaseId
) {
// Get NZB file path and validate
$nzbPath = (new NZB)->getNZBPath($releaseId);
$nzbPath = app(NzbService::class)->getNzbPath($releaseId);
if (! File::exists($nzbPath)) {
return showApiError(300, 'NZB file not found!');
}
+2 -2
View File
@@ -2,10 +2,10 @@
namespace App\Models;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use App\Services\NNTP\NNTPService;
use Blacklight\ColorCLI;
use Blacklight\NZB;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -400,7 +400,7 @@ class UsenetGroup extends Model
$res->get();
$releaseManagement = app(\App\Services\Releases\ReleaseManagementService::class);
$nzb = new NZB;
$nzb = app(NzbService::class);
$releaseImage = new ReleaseImageService;
foreach ($res as $row) {
$releaseManagement->deleteSingleWithService(
@@ -11,11 +11,12 @@ use App\Services\AdditionalProcessing\NzbContentParser;
use App\Services\AdditionalProcessing\ReleaseFileManager;
use App\Services\AdditionalProcessing\UsenetDownloadService;
use App\Services\Categorization\CategorizationService;
use App\Services\Nzb\NzbParserService;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use App\Services\TempWorkspaceService;
use App\Services\NameFixing\NameFixingService;
use Blacklight\Nfo;
use Blacklight\NZB;
use App\Services\ReleaseExtraService;
use Illuminate\Support\ServiceProvider;
@@ -50,7 +51,8 @@ class AdditionalProcessingServiceProvider extends ServiceProvider
$this->app->singleton(NzbContentParser::class, function ($app) {
$config = $app->make(ProcessingConfiguration::class);
return new NzbContentParser(
new NZB(),
$app->make(NzbService::class),
$app->make(NzbParserService::class),
$config->debugMode,
$config->echoCLI
);
@@ -77,7 +79,7 @@ class AdditionalProcessingServiceProvider extends ServiceProvider
$app->make(ReleaseExtraService::class),
new ReleaseImageService(),
new Nfo(),
new NZB(),
$app->make(NzbService::class),
new NameFixingService()
);
});
@@ -2,7 +2,8 @@
namespace App\Services\AdditionalProcessing;
use Blacklight\NZB;
use App\Services\Nzb\NzbParserService;
use App\Services\Nzb\NzbService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -13,7 +14,8 @@ use Illuminate\Support\Facades\Log;
class NzbContentParser
{
public function __construct(
private readonly NZB $nzb,
private readonly NzbService $nzb,
private readonly NzbParserService $nzbParser,
private readonly bool $debugMode = false,
private readonly bool $echoCLI = false
) {}
@@ -26,7 +28,7 @@ class NzbContentParser
*/
public function parseNzb(string $guid): array
{
$nzbPath = $this->nzb->NZBPath($guid);
$nzbPath = $this->nzb->nzbPath($guid);
if ($nzbPath === false) {
return ['contents' => [], 'error' => 'NZB not found for GUID: '.$guid];
}
@@ -41,12 +43,12 @@ class NzbContentParser
}
// Get a list of files in the NZB
$fileList = $this->nzb->nzbFileList($nzbContents, ['no-file-key' => false, 'strip-count' => true]);
$fileList = $this->nzbParser->parseNzbFileList($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]);
$fileList = $this->nzbParser->parseNzbFileList($repaired, ['no-file-key' => false, 'strip-count' => true]);
}
if (count($fileList) === 0) {
return ['contents' => [], 'error' => 'NZB is potentially broken for GUID: '.$guid];
@@ -257,7 +259,7 @@ class NzbContentParser
*/
public function getNzbPath(string $guid): string|false
{
return $this->nzb->NZBPath($guid);
return $this->nzb->nzbPath($guid);
}
}
@@ -6,6 +6,7 @@ use App\Models\MediaInfo as MediaInfoModel;
use App\Models\Predb;
use App\Models\Release;
use App\Models\ReleaseFile;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use App\Services\Releases\ReleaseBrowseService;
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
@@ -14,7 +15,6 @@ use App\Services\NameFixing\NameFixingService;
use App\Services\NameFixing\ReleaseUpdateService;
use App\Services\NNTP\NNTPService;
use Blacklight\Nfo;
use Blacklight\NZB;
use App\Services\ReleaseExtraService;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Carbon;
@@ -33,7 +33,7 @@ class ReleaseFileManager
private readonly ReleaseExtraService $releaseExtra,
private readonly ReleaseImageService $releaseImage,
private readonly Nfo $nfo,
private readonly NZB $nzb,
private readonly NzbService $nzb,
private readonly NameFixingService $nameFixingService
) {}
@@ -199,7 +199,7 @@ class ReleaseFileManager
// Delete NZB file
try {
$nzbPath = $this->nzb->NZBPath($guid);
$nzbPath = $this->nzb->nzbPath($guid);
if ($nzbPath && File::exists($nzbPath)) {
File::delete($nzbPath);
}
@@ -943,10 +943,10 @@ class NameFixingService
if ($total > 0) {
$this->_totalReleases = $total;
$this->colorCLI->info(number_format($total) . ' releases to process.');
$nzbContents = new \Blacklight\NZBContents();
$nzbContentsService = app(\App\Services\Nzb\NzbContentsService::class);
foreach ($releases as $release) {
if ($nzbContents->checkPAR2($release->guid, $release->releases_id, $release->groups_id, (int) $nameStatus, (int) $show)) {
if ($nzbContentsService->checkPar2($release->guid, $release->releases_id, $release->groups_id, (int) $nameStatus, (int) $show)) {
$this->updateService->fixed++;
}
+110 -142
View File
@@ -2,43 +2,51 @@
declare(strict_types=1);
namespace Blacklight;
namespace App\Services\Nzb;
use App\Models\Release;
use App\Models\Settings;
use App\Services\NNTP\NNTPService;
use App\Services\PostProcessService;
use Blacklight\Nfo;
/**
* Gets information contained within the NZB.
*
* Class NZBContents
* Service for processing NZB contents - extracting NFO files, PAR2 information,
* and calculating release completion from NZB files.
*/
class NZBContents
class NzbContentsService
{
protected NzbService $nzbService;
protected NzbParserService $parserService;
protected NNTPService $nntp;
protected Nfo $nfo;
protected PostProcessService $postProcessService;
protected NZB $nzb;
protected bool $lookupPar2;
protected bool $lookuppar2;
protected bool $echoOutput;
protected bool $echooutput;
protected bool $alternateNntp;
protected bool $alternateNNTP;
public function __construct()
{
$this->echooutput = (bool) config('nntmux.echocli');
$this->nntp = new NNTPService();
$this->nfo = new Nfo();
$this->postProcessService = app(PostProcessService::class);
$this->nzb = new NZB();
$this->lookuppar2 = (int) Settings::settingValue('lookuppar2') === 1;
$this->alternateNNTP = (bool) config('nntmux_nntp.use_alternate_nntp_server');
public function __construct(
?NzbService $nzbService = null,
?NzbParserService $parserService = null,
?NNTPService $nntp = null,
?Nfo $nfo = null,
?PostProcessService $postProcessService = null
) {
$this->echoOutput = (bool) config('nntmux.echocli');
$this->nzbService = $nzbService ?? app(NzbService::class);
$this->parserService = $parserService ?? app(NzbParserService::class);
$this->nntp = $nntp ?? new NNTPService();
$this->nfo = $nfo ?? new Nfo();
$this->postProcessService = $postProcessService ?? app(PostProcessService::class);
$this->lookupPar2 = (int) Settings::settingValue('lookuppar2') === 1;
$this->alternateNntp = (bool) config('nntmux_nntp.use_alternate_nntp_server');
}
/**
@@ -52,14 +60,14 @@ class NZBContents
*
* @throws \Exception If NNTP operations fail.
*/
public function getNfoFromNZB(string $guid, int $relID, int $groupID, string $groupName): string|false
public function getNfoFromNzb(string $guid, int $relID, int $groupID, string $groupName): string|false
{
// Step 1: Attempt to find a potential NFO message ID
$messageID = $this->parseNZB($guid, $relID, $groupID, true);
$messageID = $this->parseNzb($guid, $relID, $groupID, true);
// If no NFO message ID found
if ($messageID === false || ! isset($messageID['id'])) {
if ($this->echooutput) {
if ($this->echoOutput) {
echo '-';
}
// Make sure we set status to NFO_NONFO
@@ -69,13 +77,13 @@ class NZBContents
}
// Step 2: Attempt to download the potential NFO content
$fetchedBinary = $this->nntp->getMessages($groupName, $messageID['id'], $this->alternateNNTP);
$fetchedBinary = $this->nntp->getMessages($groupName, $messageID['id'], $this->alternateNntp);
// Check if download failed
if ($this->nntp->isError($fetchedBinary)) {
// NFO download failed, decrement attempts to allow retries
Release::query()->where('id', $relID)->decrement('nfostatus');
if ($this->echooutput) {
if ($this->echoOutput) {
echo 'f';
}
@@ -85,7 +93,7 @@ class NZBContents
// Step 3: Verify if the downloaded content is actually an NFO file
if ($this->nfo->isNFO($fetchedBinary, $guid)) {
// NFO verification successful
if ($this->echooutput) {
if ($this->echoOutput) {
// Show if it was found via explicit name (+) or potentially hidden (*)
echo $messageID['hidden'] === false ? '+' : '*';
}
@@ -94,7 +102,7 @@ class NZBContents
}
// Step 4: Handle verification failure - not a valid NFO
if ($this->echooutput) {
if ($this->echoOutput) {
echo '-';
}
Release::query()->where('id', $relID)->update(['nfostatus' => Nfo::NFO_NONFO]);
@@ -105,8 +113,6 @@ class NZBContents
/**
* Gets the completion from the NZB, optionally looks if there is an NFO/PAR2 file.
*
* This version includes improved regex for PAR2 file detection.
*
* @param string $guid The release GUID.
* @param int $relID The release ID.
* @param int $groupID The group ID.
@@ -115,7 +121,7 @@ class NZBContents
*
* @throws \Exception If NNTP operations fail.
*/
public function parseNZB($guid, $relID, $groupID, bool $nfoCheck = false): bool|array
public function parseNzb(string $guid, int $relID, int $groupID, bool $nfoCheck = false): bool|array
{
$nzbFile = $this->loadNzb($guid);
if ($nzbFile === false) {
@@ -124,8 +130,8 @@ class NZBContents
$messageID = $hiddenID = '';
$actualParts = $artificialParts = 0;
// Initialize foundPAR2 based on settings; if lookuppar2 is false, we don't need to find one.
$foundPAR2 = $this->lookuppar2 === false;
// Initialize foundPAR2 based on settings; if lookupPar2 is false, we don't need to find one.
$foundPAR2 = $this->lookupPar2 === false;
// Initialize NFO flags based on whether we are checking for NFOs.
$foundNFO = $hiddenNFO = $nfoCheck === false;
$nfoMessageId = null; // Store potential NFO message ID here
@@ -143,28 +149,16 @@ class NZBContents
}
$subject = (string) $nzbContents->attributes()->subject;
if (preg_match('/(?:[(\[])?(\d+)[\/)\\]](\d+)[)\]]?$/', $subject, $parts)) {
// Improve artificial parts calculation robustness (e.g., "[15/20]", "(15/20)")
if (isset($parts[2]) && (int) $parts[2] > 0) {
// Use the total count from the subject if available and seems valid
$artificialParts += (int) $parts[2];
}
} elseif (preg_match('/(\d+)\)$/', $subject, $parts)) {
// Fallback to original simple check if the more robust one fails
$artificialParts += (int) $parts[1];
}
// Calculate artificial parts from subject
$artificialParts += $this->parserService->extractPartsTotal($subject);
// --- NFO Detection ---
// Check for explicit NFO files first (with enhanced patterns)
if ($nfoCheck && ! $foundNFO && isset($firstSegmentId)) {
// Standard NFO extensions
if (preg_match('/\.\b(nfo|diz|info?)\b(?![.-])/i', $subject)) {
$nfoMessageId = ['hidden' => false, 'id' => $firstSegmentId, 'priority' => 1];
$foundNFO = true;
}
// Alternative NFO naming patterns (group-specific or obfuscated)
elseif (preg_match('/(?:^|["\s])(?:file(?:_?id)?|readme|release|info(?:rmation)?|about|desc(?:ription)?|notes?|read\.?me|00-|000-|0-|_-_).*?\.(?:txt|nfo|diz)(?:["\s]|$)/i', $subject)) {
$nfoMessageId = ['hidden' => false, 'id' => $firstSegmentId, 'priority' => 2];
$nfoDetection = $this->parserService->detectNfoFile($subject);
if ($nfoDetection !== false) {
$nfoMessageId = ['hidden' => $nfoDetection['hidden'], 'id' => $firstSegmentId, 'priority' => $nfoDetection['priority']];
$foundNFO = true;
}
}
@@ -172,67 +166,16 @@ class NZBContents
// Check for potential "hidden" NFOs with improved detection
// Only consider this if an explicit NFO wasn't found yet
if ($nfoCheck && ! $foundNFO && ! $hiddenNFO && isset($firstSegmentId)) {
$isHiddenNfoCandidate = false;
// Pattern 1: Single segment files with (1/1)
if ($segmentCountInFile === 1 && preg_match('/\(1\/1\)$/i', $subject)) {
$isHiddenNfoCandidate = true;
}
// Pattern 2: Small segment count (1-2) with NFO-like names but no extension
if (! $isHiddenNfoCandidate && $segmentCountInFile <= 2 && preg_match('/(?:^|["\s])(?:nfo|info|readme|release|file_?id|about)(?:["\s]|$)/i', $subject)) {
$isHiddenNfoCandidate = true;
}
// Pattern 3: Scene-style NFO naming (group-release.nfo without extension visible)
if (! $isHiddenNfoCandidate && $segmentCountInFile === 1 && preg_match('/^[a-z0-9._-]+["\s]*\(1\/1\)/i', $subject)) {
// Check for scene-like naming pattern
if (preg_match('/^[a-z0-9]+[._-][a-z0-9._-]+["\s]*\(1\/1\)/i', $subject)) {
$isHiddenNfoCandidate = true;
}
}
// Pattern 4: Very small files (NFOs are typically small)
// Files described as very small in bytes could be NFOs
if (! $isHiddenNfoCandidate && $segmentCountInFile === 1 && preg_match('/yEnc\s*\(\d+\)\s*\[1\/1\]/i', $subject)) {
$isHiddenNfoCandidate = true;
}
if ($isHiddenNfoCandidate) {
// Enhanced exclusion: check if it's NOT likely another common file type
$excludedExtensions = '/\.(?:' .
// Executables
'exe|com|bat|cmd|scr|dll|msi|pkg|deb|rpm|apk|ipa|app|' .
// Archives
'zip|rar|[rst]\d{2}|7z|ace|tar|gz|bz2|xz|lzma|cab|iso|bin|cue|img|mdf|nrg|dmg|vhd|' .
// Audio
'mp3|flac|ogg|aac|wav|wma|m4a|opus|ape|wv|mpc|' .
// Video
'avi|mkv|mp4|mov|wmv|mpg|mpeg|ts|vob|m2ts|webm|flv|ogv|divx|xvid|' .
// Images
'jpg|jpeg|png|gif|bmp|tif|tiff|psd|webp|svg|ico|raw|cr2|nef|' .
// Documents
'pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf|epub|mobi|azw|' .
// Code
'html|htm|css|js|php|py|java|c|cpp|h|cs|sql|json|xml|yml|yaml|' .
// Data
'db|dbf|mdb|accdb|sqlite|csv|' .
// Verification
'par2?|sfv|md5|sha1|sha256|sha512|crc|' .
// Misc
'url|lnk|cfg|ini|inf|sys|tmp|bak|log|srt|sub|idx|ass|ssa|vtt' .
')\b/i';
if (! preg_match($excludedExtensions, $subject)) {
$nfoMessageId = ['hidden' => true, 'id' => $firstSegmentId, 'priority' => 10];
$hiddenNFO = true;
}
$hiddenNfoDetection = $this->parserService->detectHiddenNfoFile($subject, $segmentCountInFile);
if ($hiddenNfoDetection !== false) {
$nfoMessageId = ['hidden' => $hiddenNfoDetection['hidden'], 'id' => $firstSegmentId, 'priority' => $hiddenNfoDetection['priority']];
$hiddenNFO = true;
}
}
// --- PAR2 Detection ---
// Look specifically for the .par2 index file (often small, but not always 1/1)
if ($this->lookuppar2 && ! $foundPAR2 && isset($firstSegmentId) && preg_match('/\.par2$/i', $subject)) {
if ($this->lookupPar2 && ! $foundPAR2 && isset($firstSegmentId) && $this->parserService->detectPar2IndexFile($subject)) {
// Attempt to parse the PAR2 file using its first segment ID
if ($this->postProcessService->parsePAR2($firstSegmentId, $relID, $groupID, $this->nntp, 1) === true) {
Release::query()->where('id', $relID)->update(['proc_par2' => 1]);
@@ -242,20 +185,7 @@ class NZBContents
} // End foreach $nzbFile->file
// Calculate completion
// Avoid division by zero and handle cases where parts info might be missing/incorrect
if ($artificialParts > 0) {
$completion = min(100, ($actualParts / $artificialParts) * 100);
} elseif ($actualParts > 0) {
// If artificial parts couldn't be determined, but we have actual parts,
// we can't calculate completion accurately based on subject.
// Consider if $actualParts alone means 100% or if it's unknown.
// Setting to 100 if actual parts > 0 and artificial is 0 might be misleading.
// Let's default to 0 or another state indicating unknown completion from subject.
$completion = 0; // Or potentially set a specific status?
} else {
// If both are zero (e.g., empty NZB or parsing issue), completion is 0.
$completion = 0;
}
$completion = $this->calculateCompletion($actualParts, $artificialParts);
Release::query()->where('id', $relID)->update(['completion' => $completion]);
@@ -273,8 +203,6 @@ class NZBContents
}
// If NFO check was not requested, the function's primary goal might be just completion/PAR2 update.
// The original function returned false here. Decide if true (parsed successfully) or false is more appropriate.
// Returning false maintains original behavior when nfoCheck is false and no NFO ID is returned.
return false;
}
@@ -282,12 +210,12 @@ class NZBContents
* Loads and parses an NZB file based on a GUID.
*
* @param string $guid The release GUID to locate the NZB file
* @return \SimpleXMLElement|bool The parsed NZB file as SimpleXMLElement or false on failure
* @return \SimpleXMLElement|false The parsed NZB file as SimpleXMLElement or false on failure
*/
public function loadNzb(string $guid): \SimpleXMLElement|bool
public function loadNzb(string $guid): \SimpleXMLElement|false
{
// Fetch the NZB file path using the GUID
$nzbPath = $this->nzb->NZBPath($guid);
$nzbPath = $this->nzbService->nzbPath($guid);
if ($nzbPath === false) {
return false;
}
@@ -295,7 +223,7 @@ class NZBContents
// Attempt to decompress the NZB file
$nzbContents = unzipGzipFile($nzbPath);
if (empty($nzbContents)) {
if ($this->echooutput) {
if ($this->echoOutput) {
$perms = fileperms($nzbPath);
$formattedPerms = $perms !== false ? decoct($perms & 0777) : 'unknown';
echo PHP_EOL."Unable to decompress: {$nzbPath} - {$formattedPerms} - may have bad file permissions, skipping.".PHP_EOL;
@@ -304,22 +232,7 @@ class NZBContents
return false;
}
// Safely parse the XML content
libxml_use_internal_errors(true);
$nzbFile = simplexml_load_string($nzbContents);
if ($nzbFile === false) {
if ($this->echooutput) {
$errors = libxml_get_errors();
$errorMsg = ! empty($errors) ? ' - XML error: '.$errors[0]->message : '';
echo PHP_EOL."Unable to load NZB: {$guid} appears to be an invalid NZB{$errorMsg}, skipping.".PHP_EOL;
libxml_clear_errors();
}
return false;
}
return $nzbFile;
return $this->parserService->parseNzbXml($nzbContents, $this->echoOutput, $guid);
}
/**
@@ -327,7 +240,7 @@ class NZBContents
*
* @throws \Exception
*/
public function checkPAR2(string $guid, int $relID, int $groupID, int $nameStatus, int $show): bool
public function checkPar2(string $guid, int $relID, int $groupID, int $nameStatus, int $show): bool
{
$nzbFile = $this->loadNzb($guid);
if ($nzbFile !== false) {
@@ -349,4 +262,59 @@ class NZBContents
return false;
}
/**
* Calculate the completion percentage from actual and expected parts.
*
* @param int $actualParts The actual number of parts found
* @param int $artificialParts The expected number of parts from subject
* @return float The completion percentage (0-100)
*/
protected function calculateCompletion(int $actualParts, int $artificialParts): float
{
// Avoid division by zero and handle cases where parts info might be missing/incorrect
if ($artificialParts > 0) {
return min(100, ($actualParts / $artificialParts) * 100);
} elseif ($actualParts > 0) {
// If artificial parts couldn't be determined, but we have actual parts,
// we can't calculate completion accurately based on subject.
return 0;
}
// If both are zero (e.g., empty NZB or parsing issue), completion is 0.
return 0;
}
/**
* Set NNTP service instance.
*/
public function setNntp(NNTPService $nntp): void
{
$this->nntp = $nntp;
}
/**
* Set NFO handler instance.
*/
public function setNfo(Nfo $nfo): void
{
$this->nfo = $nfo;
}
/**
* Set echo output setting.
*/
public function setEchoOutput(bool $echo): void
{
$this->echoOutput = $echo;
}
/**
* Get echo output setting.
*/
public function getEchoOutput(): bool
{
return $this->echoOutput;
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace App\Services\Nzb;
/**
* Service for parsing and extracting information from NZB file contents.
* Handles parsing NZB XML structure and extracting file lists, extensions, sizes, etc.
*/
class NzbParserService
{
/**
* Retrieve various information on an NZB file (the subject, # of pars,
* file extensions, file sizes, file completion, group names, # of parts).
*
* @param string $nzb The NZB contents in a string.
* @param array $options Optional settings:
* - 'no-file-key': Use numeric keys instead of subject (default: true)
* - 'strip-count': Strip file/part count from subject for sorting (default: false)
* @return array $result Empty if not an NZB or the contents of the NZB.
*/
public function parseNzbFileList(string $nzb, array $options = []): array
{
$defaults = [
'no-file-key' => true,
'strip-count' => false,
];
$options += $defaults;
$i = 0;
$result = [];
if (! $nzb) {
return $result;
}
$xml = @simplexml_load_string(str_replace("\x0F", '', $nzb));
if (! $xml || strtolower($xml->getName()) !== 'nzb') {
return $result;
}
foreach ($xml->file as $file) {
// Subject.
$title = (string) $file->attributes()->subject;
if ($options['no-file-key'] === false) {
$i = $title;
if ($options['strip-count']) {
// Strip file / part count to get proper sorting.
$i = preg_replace('#\d+[- ._]?(/|\||[o0]f)[- ._]?\d+?(?![- ._]\d)#i', '', $i);
// Change .rar and .par2 to be sorted before .part0x.rar and .volxxx+xxx.par2
if (str_contains($i, '.par2') && ! preg_match('#\.vol\d+\+\d+\.par2#i', $i)) {
$i = str_replace('.par2', '.vol0.par2', $i);
} elseif (preg_match('#\.rar[^a-z0-9]#i', $i) && ! preg_match('#\.part\d+\.rar$#i', $i)) {
$i = preg_replace('#\.rar(?:[^a-z0-9])#i', '.part0.rar', $i);
}
}
}
$result[$i]['title'] = $title;
// Extensions.
if (preg_match(
'/\.(\d{2,3}|7z|ace|ai7|srr|srt|sub|aiff|asc|avi|audio|bin|bz2|'
.'c|cfc|cfm|chm|class|conf|cpp|cs|css|csv|cue|deb|divx|doc|dot|'
.'eml|enc|exe|file|gif|gz|hlp|htm|html|image|iso|jar|java|jpeg|'
.'jpg|js|lua|m|m3u|mkv|mm|mov|mp3|mp4|mpg|nfo|nzb|odc|odf|odg|odi|odp|'
.'ods|odt|ogg|par2|parity|pdf|pgp|php|pl|png|ppt|ps|py|r\d{2,3}|'
.'ram|rar|rb|rm|rpm|rtf|sfv|sig|sql|srs|swf|sxc|sxd|sxi|sxw|tar|'
.'tex|tgz|txt|vcf|video|vsd|wav|wma|wmv|xls|xml|xpi|xvid|zip7|zip)'
.'[" ](?!([\)|\-]))/i',
$title,
$ext
)
) {
if (preg_match('/\.r\d{2,3}/i', $ext[0])) {
$ext[1] = 'rar';
}
$result[$i]['ext'] = strtolower($ext[1]);
} else {
$result[$i]['ext'] = '';
}
$fileSize = $numSegments = 0;
// Parts.
if (! isset($result[$i]['segments'])) {
$result[$i]['segments'] = [];
}
// File size.
foreach ($file->segments->segment as $segment) {
$result[$i]['segments'][] = (string) $segment;
$fileSize += $segment->attributes()->bytes;
$numSegments++;
}
$result[$i]['size'] = $fileSize;
// File completion.
if (preg_match('/(\d+)\)$/', $title, $parts)) {
$result[$i]['partstotal'] = $parts[1];
}
$result[$i]['partsactual'] = $numSegments;
// Groups.
if (! isset($result[$i]['groups'])) {
$result[$i]['groups'] = [];
}
foreach ($file->groups->group as $g) {
$result[$i]['groups'][] = (string) $g;
}
if ($options['no-file-key']) {
$i++;
}
}
return $result;
}
/**
* Load and parse an NZB file from its contents string.
*
* @param string $nzbContents The decompressed NZB file contents
* @param bool $echoErrors Whether to echo errors on parse failure
* @param string $guid Optional GUID for error messages
* @return \SimpleXMLElement|false The parsed NZB file as SimpleXMLElement or false on failure
*/
public function parseNzbXml(string $nzbContents, bool $echoErrors = false, string $guid = ''): \SimpleXMLElement|false
{
if (empty($nzbContents)) {
return false;
}
// Safely parse the XML content
libxml_use_internal_errors(true);
$nzbFile = simplexml_load_string($nzbContents);
if ($nzbFile === false) {
if ($echoErrors) {
$errors = libxml_get_errors();
$errorMsg = ! empty($errors) ? ' - XML error: '.$errors[0]->message : '';
echo PHP_EOL."Unable to load NZB: {$guid} appears to be an invalid NZB{$errorMsg}, skipping.".PHP_EOL;
libxml_clear_errors();
}
return false;
}
return $nzbFile;
}
/**
* Get the file extension from a subject line.
*
* @param string $subject The file subject/name
* @return string The detected extension, or empty string if none found
*/
public function detectFileExtension(string $subject): string
{
if (preg_match(
'/\.(\d{2,3}|7z|ace|ai7|srr|srt|sub|aiff|asc|avi|audio|bin|bz2|'
.'c|cfc|cfm|chm|class|conf|cpp|cs|css|csv|cue|deb|divx|doc|dot|'
.'eml|enc|exe|file|gif|gz|hlp|htm|html|image|iso|jar|java|jpeg|'
.'jpg|js|lua|m|m3u|mkv|mm|mov|mp3|mp4|mpg|nfo|nzb|odc|odf|odg|odi|odp|'
.'ods|odt|ogg|par2|parity|pdf|pgp|php|pl|png|ppt|ps|py|r\d{2,3}|'
.'ram|rar|rb|rm|rpm|rtf|sfv|sig|sql|srs|swf|sxc|sxd|sxi|sxw|tar|'
.'tex|tgz|txt|vcf|video|vsd|wav|wma|wmv|xls|xml|xpi|xvid|zip7|zip)'
.'[" ](?!([\)|\-]))/i',
$subject,
$ext
)
) {
if (preg_match('/\.r\d{2,3}/i', $ext[0])) {
return 'rar';
}
return strtolower($ext[1]);
}
return '';
}
/**
* Check if a subject indicates an NFO file.
*
* @param string $subject The file subject
* @return array|false Returns array with detection info or false if not an NFO
*/
public function detectNfoFile(string $subject): array|false
{
// Standard NFO extensions
if (preg_match('/\.\b(nfo|diz|info?)\b(?![.-])/i', $subject)) {
return ['hidden' => false, 'priority' => 1];
}
// Alternative NFO naming patterns (group-specific or obfuscated)
if (preg_match('/(?:^|["\s])(?:file(?:_?id)?|readme|release|info(?:rmation)?|about|desc(?:ription)?|notes?|read\.?me|00-|000-|0-|_-_).*?\.(?:txt|nfo|diz)(?:["\s]|$)/i', $subject)) {
return ['hidden' => false, 'priority' => 2];
}
return false;
}
/**
* Check if a subject might indicate a hidden NFO file.
*
* @param string $subject The file subject
* @param int $segmentCount The number of segments in the file
* @return array|false Returns array with detection info or false if not a hidden NFO
*/
public function detectHiddenNfoFile(string $subject, int $segmentCount): array|false
{
$isHiddenNfoCandidate = false;
// Pattern 1: Single segment files with (1/1)
if ($segmentCount === 1 && preg_match('/\(1\/1\)$/i', $subject)) {
$isHiddenNfoCandidate = true;
}
// Pattern 2: Small segment count (1-2) with NFO-like names but no extension
if (! $isHiddenNfoCandidate && $segmentCount <= 2 && preg_match('/(?:^|["\s])(?:nfo|info|readme|release|file_?id|about)(?:["\s]|$)/i', $subject)) {
$isHiddenNfoCandidate = true;
}
// Pattern 3: Scene-style NFO naming (group-release.nfo without extension visible)
if (! $isHiddenNfoCandidate && $segmentCount === 1 && preg_match('/^[a-z0-9._-]+["\s]*\(1\/1\)/i', $subject)) {
// Check for scene-like naming pattern
if (preg_match('/^[a-z0-9]+[._-][a-z0-9._-]+["\s]*\(1\/1\)/i', $subject)) {
$isHiddenNfoCandidate = true;
}
}
// Pattern 4: Very small files (NFOs are typically small)
// Files described as very small in bytes could be NFOs
if (! $isHiddenNfoCandidate && $segmentCount === 1 && preg_match('/yEnc\s*\(\d+\)\s*\[1\/1\]/i', $subject)) {
$isHiddenNfoCandidate = true;
}
if (! $isHiddenNfoCandidate) {
return false;
}
// Enhanced exclusion: check if it's NOT likely another common file type
$excludedExtensions = '/\.(?:' .
// Executables
'exe|com|bat|cmd|scr|dll|msi|pkg|deb|rpm|apk|ipa|app|' .
// Archives
'zip|rar|[rst]\d{2}|7z|ace|tar|gz|bz2|xz|lzma|cab|iso|bin|cue|img|mdf|nrg|dmg|vhd|' .
// Audio
'mp3|flac|ogg|aac|wav|wma|m4a|opus|ape|wv|mpc|' .
// Video
'avi|mkv|mp4|mov|wmv|mpg|mpeg|ts|vob|m2ts|webm|flv|ogv|divx|xvid|' .
// Images
'jpg|jpeg|png|gif|bmp|tif|tiff|psd|webp|svg|ico|raw|cr2|nef|' .
// Documents
'pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf|epub|mobi|azw|' .
// Code
'html|htm|css|js|php|py|java|c|cpp|h|cs|sql|json|xml|yml|yaml|' .
// Data
'db|dbf|mdb|accdb|sqlite|csv|' .
// Verification
'par2?|sfv|md5|sha1|sha256|sha512|crc|' .
// Misc
'url|lnk|cfg|ini|inf|sys|tmp|bak|log|srt|sub|idx|ass|ssa|vtt' .
')\b/i';
if (preg_match($excludedExtensions, $subject)) {
return false;
}
return ['hidden' => true, 'priority' => 10];
}
/**
* Check if a subject indicates a PAR2 index file.
*
* @param string $subject The file subject
* @return bool True if it's a PAR2 index file
*/
public function detectPar2IndexFile(string $subject): bool
{
return (bool) preg_match('/\.par2$/i', $subject);
}
/**
* Calculate artificial parts from a subject line.
*
* @param string $subject The file subject
* @return int The estimated total parts, or 0 if not determinable
*/
public function extractPartsTotal(string $subject): int
{
// Improve artificial parts calculation robustness (e.g., "[15/20]", "(15/20)")
if (preg_match('/(?:[(\[])?(\d+)[\/)\\]](\d+)[)\]]?$/', $subject, $parts)) {
if (isset($parts[2]) && (int) $parts[2] > 0) {
return (int) $parts[2];
}
}
// Fallback to original simple check
if (preg_match('/(\d+)\)$/', $subject, $parts)) {
return (int) $parts[1];
}
return 0;
}
}
+62 -128
View File
@@ -1,6 +1,8 @@
<?php
namespace Blacklight;
declare(strict_types=1);
namespace App\Services\Nzb;
use App\Models\Binary;
use App\Models\Collection;
@@ -12,13 +14,10 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* Class for reading and writing NZB files on the hard disk,
* building folder paths to store the NZB files.
*
*
* Class NZB
* Service for managing NZB files on disk.
* Handles reading, writing, and path management for NZB files.
*/
class NZB
class NzbService
{
public const NZB_NONE = 0; // Release has no NZB file yet.
@@ -42,25 +41,13 @@ class NZB
*/
protected string $siteNzbPath;
/**
* Group id when writing NZBs.
*/
protected int $groupID;
public \PDO $pdo;
protected bool $_debug = false;
/**
* String used for head in NZB XML file.
*/
protected string $_nzbCommentString;
protected string $nzbCommentString;
protected string $_siteCommentString;
protected string $siteCommentString;
/**
* NZB constructor.
*/
public function __construct()
{
$nzbSplitLevel = (int) Settings::settingValue('nzbsplitlevel');
@@ -69,17 +56,19 @@ class NZB
if (! Str::endsWith($this->siteNzbPath, '/')) {
$this->siteNzbPath .= '/';
}
$this->_nzbCommentString = sprintf(
$this->nzbCommentString = sprintf(
'NZB Generated by: NNTmux %s',
now()->format('F j, Y, g:i a O')
);
$this->_siteCommentString = sprintf(
$this->siteCommentString = sprintf(
'NZB downloaded from %s',
config('app.name')
);
}
/**
* Write an NZB file for a release.
*
* @throws \Throwable
*/
public function writeNzbForReleaseId(Release $release): bool
@@ -93,7 +82,7 @@ class NZB
return false;
}
$XMLWriter = new \XMLWriter;
$XMLWriter = new \XMLWriter();
$XMLWriter->openMemory();
$XMLWriter->setIndent(true);
$XMLWriter->setIndentString(' ');
@@ -103,7 +92,7 @@ class NZB
$XMLWriter->startDocument('1.0', 'UTF-8');
$XMLWriter->startDtd(self::NZB_DTD_NAME, self::NZB_DTD_PUBLIC, self::NZB_DTD_EXTERNAL);
$XMLWriter->endDtd();
$XMLWriter->writeComment($this->_nzbCommentString);
$XMLWriter->writeComment($this->nzbCommentString);
$XMLWriter->startElement('nzb');
$XMLWriter->writeAttribute('xmlns', self::NZB_XML_NS);
@@ -167,10 +156,10 @@ class NZB
$XMLWriter->endElement(); // file
}
}
$XMLWriter->writeComment($this->_siteCommentString);
$XMLWriter->writeComment($this->siteCommentString);
$XMLWriter->endElement(); // nzb
$XMLWriter->endDocument();
$path = ($this->buildNZBPath($release->guid, $this->nzbSplitLevel, true).$release->guid.'.nzb.gz');
$path = ($this->buildNzbPath($release->guid, $this->nzbSplitLevel, true).$release->guid.'.nzb.gz');
$fp = gzopen($path, 'wb7');
if (! $fp) {
return false;
@@ -206,7 +195,7 @@ class NZB
* @param bool $createIfNotExist Create the folder if it doesn't exist.
* @return string $nzbpath The path to store the NZB file.
*/
public function buildNZBPath(string $releaseGuid, int $levelsToSplit, bool $createIfNotExist): string
public function buildNzbPath(string $releaseGuid, int $levelsToSplit, bool $createIfNotExist): string
{
$nzbPath = '';
@@ -231,132 +220,77 @@ class NZB
* @param bool $createIfNotExist Create the folder if it doesn't exist. (optional)
* @return string Path+filename.
*/
public function getNZBPath(string $releaseGuid, int $levelsToSplit = 0, bool $createIfNotExist = false): string
public function getNzbPath(string $releaseGuid, int $levelsToSplit = 0, bool $createIfNotExist = false): string
{
if ($levelsToSplit === 0) {
$levelsToSplit = $this->nzbSplitLevel;
}
return $this->buildNZBPath($releaseGuid, $levelsToSplit, $createIfNotExist).$releaseGuid.'.nzb.gz';
return $this->buildNzbPath($releaseGuid, $levelsToSplit, $createIfNotExist).$releaseGuid.'.nzb.gz';
}
/**
* Determine is an NZB exists, returning the path+filename, if not return false.
* Determine if an NZB exists, returning the path+filename, if not return false.
*
* @param string $releaseGuid The guid of the release.
* @return false|string On success: (string) Path+file name of the nzb.
* On failure: false .
* On failure: false.
*/
public function NZBPath(string $releaseGuid): bool|string
public function nzbPath(string $releaseGuid): bool|string
{
$nzbFile = $this->getNZBPath($releaseGuid);
$nzbFile = $this->getNzbPath($releaseGuid);
return File::isFile($nzbFile) ? $nzbFile : false;
}
/**
* Retrieve various information on a NZB file (the subject, # of pars,
* file extensions, file sizes, file completion, group names, # of parts).
* Read and decompress an NZB file contents.
*
* @param string $nzb The NZB contents in a string.
* @return array $result Empty if not an NZB or the contents of the NZB.
* @param string $releaseGuid The release GUID
* @return string|false The decompressed NZB contents or false on failure
*/
public function nzbFileList(string $nzb, array $options = []): array
public function readNzbContents(string $releaseGuid): string|false
{
$defaults = [
'no-file-key' => true,
'strip-count' => false,
];
$options += $defaults;
$i = 0;
$result = [];
if (! $nzb) {
return $result;
$nzbPath = $this->nzbPath($releaseGuid);
if ($nzbPath === false) {
return false;
}
$xml = @simplexml_load_string(str_replace("\x0F", '', $nzb));
if (! $xml || strtolower($xml->getName()) !== 'nzb') {
return $result;
$contents = unzipGzipFile($nzbPath);
return ! empty($contents) ? $contents : false;
}
/**
* Delete an NZB file.
*
* @param string $releaseGuid The release GUID
* @return bool True if deleted, false otherwise
*/
public function deleteNzb(string $releaseGuid): bool
{
$nzbPath = $this->nzbPath($releaseGuid);
if ($nzbPath === false) {
return false;
}
foreach ($xml->file as $file) {
// Subject.
$title = (string) $file->attributes()->subject;
return File::delete($nzbPath);
}
if ($options['no-file-key'] === false) {
$i = $title;
if ($options['strip-count']) {
// Strip file / part count to get proper sorting.
$i = preg_replace('#\d+[- ._]?(/|\||[o0]f)[- ._]?\d+?(?![- ._]\d)#i', '', $i);
// Change .rar and .par2 to be sorted before .part0x.rar and .volxxx+xxx.par2
if (str_contains($i, '.par2') && ! preg_match('#\.vol\d+\+\d+\.par2#i', $i)) {
$i = str_replace('.par2', '.vol0.par2', $i);
} elseif (preg_match('#\.rar[^a-z0-9]#i', $i) && ! preg_match('#\.part\d+\.rar$#i', $i)) {
$i = preg_replace('#\.rar(?:[^a-z0-9])#i', '.part0.rar', $i);
}
}
}
/**
* Get the default NZB split level.
*/
public function getNzbSplitLevel(): int
{
return $this->nzbSplitLevel;
}
$result[$i]['title'] = $title;
// Extensions.
if (preg_match(
'/\.(\d{2,3}|7z|ace|ai7|srr|srt|sub|aiff|asc|avi|audio|bin|bz2|'
.'c|cfc|cfm|chm|class|conf|cpp|cs|css|csv|cue|deb|divx|doc|dot|'
.'eml|enc|exe|file|gif|gz|hlp|htm|html|image|iso|jar|java|jpeg|'
.'jpg|js|lua|m|m3u|mkv|mm|mov|mp3|mp4|mpg|nfo|nzb|odc|odf|odg|odi|odp|'
.'ods|odt|ogg|par2|parity|pdf|pgp|php|pl|png|ppt|ps|py|r\d{2,3}|'
.'ram|rar|rb|rm|rpm|rtf|sfv|sig|sql|srs|swf|sxc|sxd|sxi|sxw|tar|'
.'tex|tgz|txt|vcf|video|vsd|wav|wma|wmv|xls|xml|xpi|xvid|zip7|zip)'
.'[" ](?!([\)|\-]))/i',
$title,
$ext
)
) {
if (preg_match('/\.r\d{2,3}/i', $ext[0])) {
$ext[1] = 'rar';
}
$result[$i]['ext'] = strtolower($ext[1]);
} else {
$result[$i]['ext'] = '';
}
$fileSize = $numSegments = 0;
// Parts.
if (! isset($result[$i]['segments'])) {
$result[$i]['segments'] = [];
}
// File size.
foreach ($file->segments->segment as $segment) {
$result[$i]['segments'][] = (string) $segment;
$fileSize += $segment->attributes()->bytes;
$numSegments++;
}
$result[$i]['size'] = $fileSize;
// File completion.
if (preg_match('/(\d+)\)$/', $title, $parts)) {
$result[$i]['partstotal'] = $parts[1];
}
$result[$i]['partsactual'] = $numSegments;
// Groups.
if (! isset($result[$i]['groups'])) {
$result[$i]['groups'] = [];
}
foreach ($file->groups->group as $g) {
$result[$i]['groups'][] = (string) $g;
}
if ($options['no-file-key']) {
$i++;
}
}
return $result;
/**
* Get the base NZB storage path.
*/
public function getSiteNzbPath(): string
{
return $this->siteNzbPath;
}
}
+2 -2
View File
@@ -11,9 +11,9 @@ use App\Models\ReleaseRegex;
use App\Models\ReleasesGroups;
use App\Models\UsenetGroup;
use App\Services\Categorization\CategorizationService;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseCleaningService;
use Blacklight\ColorCLI;
use Blacklight\NZB;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -113,7 +113,7 @@ class ReleaseCreationService
'categories_id' => $determinedCategory['categories_id'] ?? Category::OTHER_MISC,
'isrenamed' => $properName === true ? 1 : 0,
'predb_id' => $preID === false ? 0 : $preID,
'nzbstatus' => NZB::NZB_NONE,
'nzbstatus' => NzbService::NZB_NONE,
'ishashed' => preg_match('/^[a-fA-F0-9]{32}\b|^[a-fA-F0-9]{40}\b|^[a-fA-F0-9]{64}\b|^[a-fA-F0-9]{96}\b|^[a-fA-F0-9]{128}\b/i', $searchName) ? 1 : 0,
]);
+5 -5
View File
@@ -13,6 +13,7 @@ use App\Models\Release;
use App\Models\Settings;
use App\Models\UsenetGroup;
use App\Services\Categorization\CategorizationService;
use App\Services\Nzb\NzbService;
use App\Services\Releases\ReleaseManagementService;
use App\Support\DTOs\ProcessReleasesSettings;
use App\Support\DTOs\ReleaseCreationResult;
@@ -20,7 +21,6 @@ use App\Support\DTOs\ReleaseDeleteStats;
use App\Services\NNTP\NNTPService;
use Blacklight\ColorCLI;
use Blacklight\Genres;
use Blacklight\NZB;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -49,7 +49,7 @@ final class ReleaseProcessingService
private bool $echoCLI;
private readonly ProcessReleasesSettings $settings;
private readonly ColorCLI $colorCLI;
private readonly NZB $nzb;
private readonly NzbService $nzb;
private readonly ReleaseCleaningService $releaseCleaning;
private readonly ReleaseManagementService $releaseManagement;
private readonly ReleaseImageService $releaseImage;
@@ -59,7 +59,7 @@ final class ReleaseProcessingService
public function __construct(
?ColorCLI $colorCLI = null,
?NZB $nzb = null,
?NzbService $nzb = null,
?ReleaseCleaningService $releaseCleaning = null,
?ReleaseManagementService $releaseManagement = null,
?ReleaseImageService $releaseImage = null,
@@ -70,7 +70,7 @@ final class ReleaseProcessingService
$this->echoCLI = (bool) config('nntmux.echocli');
$this->colorCLI = $colorCLI ?? new ColorCLI();
$this->nzb = $nzb ?? new NZB();
$this->nzb = $nzb ?? app(NzbService::class);
$this->releaseCleaning = $releaseCleaning ?? new ReleaseCleaningService();
$this->releaseManagement = $releaseManagement ?? app(ReleaseManagementService::class);
$this->releaseImage = $releaseImage ?? new ReleaseImageService();
@@ -482,7 +482,7 @@ final class ReleaseProcessingService
$query = Release::query()
->with('category.parent')
->where('nzbstatus', '=', NZB::NZB_NONE)
->where('nzbstatus', '=', NzbService::NZB_NONE)
->select(['id', 'guid', 'name', 'categories_id']);
if (!empty($groupID)) {
+4 -4
View File
@@ -7,9 +7,9 @@ namespace App\Services;
use App\Enums\BlacklistConstants;
use App\Models\Category;
use App\Models\Settings;
use App\Services\Nzb\NzbService;
use App\Services\Releases\ReleaseManagementService;
use Blacklight\ColorCLI;
use Blacklight\NZB;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
@@ -48,7 +48,7 @@ class ReleaseRemoverService
protected string $query = '';
protected ReleaseManagementService $releaseManagement;
protected array $result = [];
private NZB $nzb;
private NzbService $nzb;
private ReleaseImageService $releaseImage;
/**
@@ -59,12 +59,12 @@ class ReleaseRemoverService
public function __construct(
?ColorCLI $colorCLI = null,
?ReleaseManagementService $releaseManagement = null,
?NZB $nzb = null,
?NzbService $nzb = null,
?ReleaseImageService $releaseImage = null
) {
$this->colorCLI = $colorCLI ?? new ColorCLI;
$this->releaseManagement = $releaseManagement ?? app(ReleaseManagementService::class);
$this->nzb = $nzb ?? new NZB;
$this->nzb = $nzb ?? app(NzbService::class);
$this->releaseImage = $releaseImage ?? new ReleaseImageService;
$this->echoCLI = config('nntmux.echocli');
@@ -4,8 +4,8 @@ namespace App\Services\Releases;
use App\Facades\Search;
use App\Models\Release;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use Blacklight\NZB;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
@@ -25,7 +25,7 @@ class ReleaseManagementService
{
$list = (array) $list;
$nzb = new NZB;
$nzb = app(NzbService::class);
$releaseImage = new ReleaseImageService;
foreach ($list as $identifier) {
@@ -41,10 +41,10 @@ class ReleaseManagementService
*
* @throws \Exception
*/
public function deleteSingle(array $identifiers, NZB $nzb, ReleaseImageService $releaseImage): void
public function deleteSingle(array $identifiers, NzbService $nzb, ReleaseImageService $releaseImage): void
{
// Delete NZB from disk.
$nzbPath = $nzb->NZBPath($identifiers['g']);
$nzbPath = $nzb->nzbPath($identifiers['g']);
if (! empty($nzbPath)) {
File::delete($nzbPath);
}
@@ -76,7 +76,7 @@ class ReleaseManagementService
*
* @throws \Exception
*/
public function deleteSingleWithService(array $identifiers, NZB $nzb, ReleaseImageService $releaseImage): void
public function deleteSingleWithService(array $identifiers, NzbService $nzb, ReleaseImageService $releaseImage): void
{
$this->deleteSingle($identifiers, $nzb, $releaseImage);
}
+44 -31
View File
@@ -326,37 +326,23 @@ class ReleaseSearchService
}
if (! $hasValidSiteIds) {
// Build search name with season/episode for the full-text search
if (! empty($series) && (int) $series < 1900) {
$searchName .= sprintf(' S%s', str_pad($series, 2, '0', STR_PAD_LEFT));
$seriesNum = (int) preg_replace('/^s0*/i', '', $series);
$conditions[] = sprintf('tve.series = %d', $seriesNum);
$needsEpisodeJoin = true;
if (! empty($episode) && ! str_contains($episode, '/')) {
$searchName .= sprintf('E%s', str_pad($episode, 2, '0', STR_PAD_LEFT));
$episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
$conditions[] = sprintf('tve.episode = %d', $episodeNum);
}
} elseif (! empty($airDate)) {
$searchName .= ' '.str_replace(['/', '-', '.', '_'], ' ', $airDate);
$conditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
$needsEpisodeJoin = true;
}
}
// Try Elasticsearch first if enabled
if (config('nntmux.elasticsearch_enabled') === true) {
$searchResult = $this->elasticSearch->indexSearchTMA($searchName, $limit);
}
// Use the unified Search facade
$searchResult = Search::searchReleases(['searchname' => $searchName], $limit);
// Fall back to Manticore if Elasticsearch didn't return results
if (empty($searchResult)) {
$searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $searchName, ['searchname']);
if (! empty($searchResult)) {
$searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
}
}
// Fall back to MySQL if both Elasticsearch and Manticore failed (only if enabled)
// Fall back to MySQL if search engine failed (only if enabled)
if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
$searchResult = $this->performMySQLSearch(['searchname' => $searchName], $limit);
}
@@ -366,6 +352,43 @@ class ReleaseSearchService
}
$conditions[] = sprintf('r.id IN (%s)', implode(',', array_map('intval', $searchResult)));
// Try to add episode conditions if season/episode data is provided and no valid site IDs
// This will filter results to only those with matching episode data in tv_episodes table
// If this results in no matches, we'll fall back to results without episode conditions
if (! $hasValidSiteIds && (! empty($series) || ! empty($airDate))) {
$episodeConditions = [];
if (! empty($series) && (int) $series < 1900) {
$seriesNum = (int) preg_replace('/^s0*/i', '', $series);
$episodeConditions[] = sprintf('tve.series = %d', $seriesNum);
if (! empty($episode) && ! str_contains($episode, '/')) {
$episodeNum = (int) preg_replace('/^e0*/i', '', $episode);
$episodeConditions[] = sprintf('tve.episode = %d', $episodeNum);
}
} elseif (! empty($airDate)) {
$episodeConditions[] = sprintf('DATE(tve.firstaired) = %s', escapeString($airDate));
}
if (! empty($episodeConditions)) {
// Check if any of the found releases have matching episode data
$checkSql = sprintf(
'SELECT r.id FROM releases r INNER JOIN tv_episodes tve ON r.tv_episodes_id = tve.id WHERE r.id IN (%s) AND %s LIMIT 1',
implode(',', array_map('intval', $searchResult)),
implode(' AND ', $episodeConditions)
);
$hasEpisodeMatches = Release::fromQuery($checkSql);
if ($hasEpisodeMatches->isNotEmpty()) {
// Some releases have matching episode data, add the conditions
foreach ($episodeConditions as $cond) {
$conditions[] = $cond;
}
$needsEpisodeJoin = true;
}
// If no matches with episode data, don't add episode conditions
// The search will return results based on searchname match only
}
}
}
$catQuery = Category::getCategorySearch($cat, 'tv');
@@ -492,20 +515,10 @@ class ReleaseSearchService
}
$searchResult = [];
if (! empty($name)) {
// Try Elasticsearch first if enabled
if (config('nntmux.elasticsearch_enabled') === true) {
$searchResult = $this->elasticSearch->indexSearchTMA($name, $limit);
}
// Use the unified Search facade
$searchResult = Search::searchReleases(['searchname' => $name], $limit);
// Fall back to Manticore if Elasticsearch didn't return results
if (empty($searchResult)) {
$searchResult = $this->manticoreSearch->searchIndexes('releases_rt', $name, ['searchname']);
if (! empty($searchResult)) {
$searchResult = Arr::wrap(Arr::get($searchResult, 'id'));
}
}
// Fall back to MySQL if both Elasticsearch and Manticore failed (only if enabled)
// Fall back to MySQL if search engine failed (only if enabled)
if (empty($searchResult) && config('nntmux.mysql_search_fallback', false) === true) {
$searchResult = $this->performMySQLSearch(['searchname' => $name], $limit);
}
+6 -4
View File
@@ -3,9 +3,10 @@
require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php';
use App\Models\Release;
use App\Services\Nzb\NzbParserService;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use Blacklight\ColorCLI;
use Blacklight\NZB;
use Blacklight\Releases;
use Illuminate\Support\Facades\File;
@@ -24,7 +25,8 @@ if (! File::isDirectory($dir) && ! File::makeDirectory($dir)) {
}
$releases = new Releases;
$nzb = new NZB;
$nzb = app(NzbService::class);
$nzbParser = app(NzbParserService::class);
$releaseImage = new ReleaseImageService;
$timestart = now()->toRfc2822String();
@@ -41,7 +43,7 @@ foreach ($itr as $filePath) {
$guid = stristr($filePath->getFilename(), '.nzb.gz', true);
if (File::isFile($filePath) && $guid) {
$nzbfile = unzipGzipFile($filePath);
$nzbContents = $nzb->nzbFileList($nzbfile, ['no-file-key' => false, 'strip-count' => true]);
$nzbContents = $nzbParser->parseNzbFileList($nzbfile, ['no-file-key' => false, 'strip-count' => true]);
if (! $nzbfile || ! @simplexml_load_string($nzbfile) || count($nzbContents) === 0) {
if ($argv[1] === 'move') {
rename($filePath, $dir.$guid.'.nzb.gz');
@@ -62,7 +64,7 @@ $checked = $deleted = 0;
$res = Release::query()->select(['id', 'guid', 'nzbstatus'])->get();
foreach ($res as $row) {
$nzbpath = $nzb->getNZBPath($row->guid);
$nzbpath = $nzb->getNzbPath($row->guid);
if (! File::isFile($nzbpath)) {
$deleted++;
$releases->deleteSingle(['g' => $row->guid, 'i' => $row->id], $nzb, $releaseImage);
+3 -3
View File
@@ -3,14 +3,14 @@
require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php';
use App\Models\Settings;
use App\Services\Nzb\NzbService;
use Blacklight\ColorCLI;
use Blacklight\NZB;
if (! isset($argv[1]) || ! isset($argv[2])) {
exit("ERROR: You must supply the level you want to reorganize it to, and the source directory (You would use: 3 .../newznab/resources/nzb/ to move it to 3 levels deep)\n");
}
$nzb = new NZB;
$nzb = app(NzbService::class);
$consoleTools = new ColorCLI;
$newLevel = $argv[1];
@@ -28,7 +28,7 @@ foreach ($objects as $filestoprocess => $nzbFile) {
continue;
}
$newFileName = $nzb->getNZBPath(
$newFileName = $nzb->getNzbPath(
str_replace('.nzb.gz', '', $nzbFile->getBasename()),
$newLevel,
true
+2 -2
View File
@@ -6,9 +6,9 @@
require_once dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bootstrap/autoload.php';
use App\Models\Release;
use App\Services\Nzb\NzbService;
use App\Services\ReleaseImageService;
use Blacklight\ColorCLI;
use Blacklight\NZB;
use Blacklight\Releases;
use Illuminate\Support\Facades\DB;
@@ -19,7 +19,7 @@ $path2preview = storage_path('covers/preview');
if (isset($argv[1]) && ($argv[1] === 'true' || $argv[1] === 'check')) {
$releases = new Releases;
$nzb = new NZB;
$nzb = app(NzbService::class);
$releaseImage = new ReleaseImageService;
$consoletools = new ColorCLI;
$couldbe = $argv[1] === 'true' ? $couldbe = 'were ' : 'could be ';