Respect imported nzb category

This commit is contained in:
DariusIII
2026-08-03 23:49:54 +02:00
parent d09b96b525
commit de9007c9a5
4 changed files with 263 additions and 19 deletions
+125 -11
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services\Nzb;
use App\Enums\NzbImportStatus;
use App\Models\Category;
use App\Models\Predb;
use App\Models\Release;
use App\Models\Settings;
@@ -196,22 +197,38 @@ class NzbImportService
$nzbFileName = $useNzbName === true ? $this->deriveReleaseNameFromNzbPath($nzbFilePath) : '';
try {
$importStatus = $this->scanNZBFile($nzbXML, $nzbFileName, $source);
} catch (\Exception $e) {
$this->echoOut('ERROR: Problem inserting: '.$nzbFilePath);
} catch (\Throwable $exception) {
Log::error('NZB import failed while scanning or inserting a release.', [
'path' => $nzbFilePath,
'exception' => $exception,
]);
$message = 'ERROR: Problem inserting: '.$nzbFilePath;
if (! $this->browser) {
$message .= ': '.$exception->getMessage();
}
$this->echoOut($message);
$importStatus = NzbImportStatus::Failed;
}
if ($importStatus === NzbImportStatus::Inserted) {
// Try to copy the NZB to the NZB folder.
$path = null;
try {
$path = $this->nzb->getNzbPath($this->relGuid, 0, true);
$stored = $this->writeCompressedNzb($path, $nzbString);
} catch (\Throwable $exception) {
Log::error('NZB import failed while storing the compressed file.', [
'guid' => $this->relGuid,
'path' => $path,
'exception' => $exception,
]);
$stored = false;
}
// Try to compress the NZB file in the NZB folder.
$fp = gzopen($path, 'w5');
gzwrite($fp, $nzbString);
gzclose($fp);
if (! File::isFile($path)) {
$this->echoOut('ERROR: Problem compressing NZB file to: '.$path);
if (! $stored) {
$destination = $path ?? $this->relGuid;
$this->echoOut('ERROR: Problem compressing NZB file to: '.$destination);
// Remove the release.
Release::query()->where('guid', $this->relGuid)->delete();
@@ -303,6 +320,46 @@ class NzbImportService
return rtrim($name, ". \t\n\r\0\x0B");
}
protected function writeCompressedNzb(string $path, string $contents): bool
{
$handle = @gzopen($path, 'w5');
if ($handle === false) {
Log::error('Unable to open imported NZB destination for writing.', ['path' => $path]);
return false;
}
$stored = false;
try {
$written = gzwrite($handle, $contents);
if ($written !== \strlen($contents) || ! gzclose($handle)) {
Log::error('Unable to write the complete imported NZB file.', ['path' => $path]);
return false;
}
$handle = null;
$stored = File::isFile($path);
return $stored;
} catch (\Throwable $exception) {
Log::error('Imported NZB compression failed.', [
'path' => $path,
'exception' => $exception,
]);
return false;
} finally {
if (\is_resource($handle)) {
@gzclose($handle);
}
if (! $stored) {
File::delete($path);
}
}
}
/**
* Scan and process an NZB file.
*
@@ -413,10 +470,62 @@ class NzbImportService
'groupName' => $groupName,
'totalFiles' => $totalFiles,
'totalSize' => $totalSize,
'nzbCategoryId' => $this->resolveNzbCategoryId($nzbXML),
]
);
}
protected function resolveNzbCategoryId(mixed $nzbXML): ?int
{
if (! $nzbXML instanceof \SimpleXMLElement) {
return null;
}
$categoryMetadata = [];
foreach ($nzbXML->head->meta ?? [] as $meta) {
if (mb_strtolower(trim((string) $meta['type'])) !== 'category') {
continue;
}
$value = trim((string) $meta);
if ($value !== '') {
$categoryMetadata[] = $value;
}
}
if ($categoryMetadata === []) {
return null;
}
$activeCategories = Category::query()
->where('status', Category::STATUS_ACTIVE)
->get(['id', 'title']);
$resolvedCategoryIds = [];
foreach ($categoryMetadata as $value) {
if (ctype_digit($value)) {
$matchingCategories = $activeCategories->filter(
static fn (Category $category): bool => $category->id === (int) $value
);
} else {
$normalizedValue = mb_strtolower($value);
$matchingCategories = $activeCategories->filter(
static fn (Category $category): bool => mb_strtolower($category->title) === $normalizedValue
);
}
if ($matchingCategories->count() !== 1) {
return null;
}
$resolvedCategoryIds[] = (int) $matchingCategories->first()->id;
}
$resolvedCategoryIds = array_values(array_unique($resolvedCategoryIds));
return count($resolvedCategoryIds) === 1 ? $resolvedCategoryIds[0] : null;
}
/**
* Insert the NZB details into the database.
*
@@ -498,7 +607,12 @@ class NzbImportService
return NzbImportStatus::Duplicate;
}
$categoryId = $nzbDetails['nzbCategoryId'];
if (! \is_int($categoryId)) {
$determinedCategory = $this->category->determineCategory($nzbDetails['groups_id'], $cleanName, $escapedFromName);
$categoryId = (int) $determinedCategory['categories_id'];
}
$relID = Release::insertRelease(
[
'name' => $escapedSubject,
@@ -509,7 +623,7 @@ class NzbImportService
'postdate' => $nzbDetails['postDate'],
'fromname' => $escapedFromName,
'size' => $nzbDetails['totalSize'],
'categories_id' => $determinedCategory['categories_id'],
'categories_id' => $categoryId,
'isrenamed' => $renamed,
'predb_id' => $predbIdInt,
'nzbstatus' => NzbService::NZB_ADDED,
+7 -5
View File
@@ -366,16 +366,18 @@ class NzbService
*/
public function buildNzbPath(string $releaseGuid, int $levelsToSplit, bool $createIfNotExist): string
{
$nzbPath = '';
$nzbPath = $this->siteNzbPath;
for ($i = 0; $i < $levelsToSplit && $i < 32; $i++) {
$nzbPath .= $releaseGuid[$i].'/';
if ($createIfNotExist && ! File::isDirectory($nzbPath)) {
if (! File::makeDirectory($nzbPath, 0775) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue
throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath));
}
$nzbPath = $this->siteNzbPath.$nzbPath;
if ($createIfNotExist && ! File::isDirectory($nzbPath) && ! File::makeDirectory($nzbPath, 0777, true) && ! File::isDirectory($nzbPath)) { // @phpstan-ignore booleanNot.alwaysTrue
throw new \RuntimeException(sprintf('Directory "%s" was not created', $nzbPath));
File::chmod($nzbPath, 02775);
}
}
return $nzbPath;
+99
View File
@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Tests\Unit;
use App\Enums\NzbImportStatus;
use App\Models\Category;
use App\Services\Nzb\NzbImportService;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
@@ -68,6 +71,12 @@ final class NzbImportServiceTest extends TestCase
DB::purge();
DB::reconnect();
Cache::flush();
Schema::create('categories', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->string('title');
$table->integer('status');
});
}
protected function tearDown(): void
@@ -228,6 +237,66 @@ final class NzbImportServiceTest extends TestCase
$this->assertSame($expected, $service->deriveForTest($input));
}
public function test_nzb_category_metadata_resolves_active_id_and_unique_case_insensitive_title(): void
{
$this->insertCategory(2040, 'HD', Category::STATUS_ACTIVE);
$this->insertCategory(3040, 'Lossless', Category::STATUS_ACTIVE);
$this->assertSame(2040, $this->resolveNzbCategory('<meta type="category"> 2040 </meta>'));
$this->assertSame(3040, $this->resolveNzbCategory('<meta type="CATEGORY"> lossLESS </meta>'));
$this->assertSame(2040, $this->resolveNzbCategory(
'<meta type="category">2040</meta>',
' xmlns="http://www.newzbin.com/DTD/2003/nzb"'
));
}
public function test_nzb_category_metadata_rejects_unknown_inactive_disabled_and_ambiguous_values(): void
{
$this->insertCategory(2040, 'HD', Category::STATUS_ACTIVE);
$this->insertCategory(3040, 'Lossless', Category::STATUS_INACTIVE);
$this->insertCategory(5040, 'HD', Category::STATUS_ACTIVE);
$this->insertCategory(6040, 'X264', Category::STATUS_DISABLED);
$this->assertNull($this->resolveNzbCategory('<meta type="category">9999</meta>'));
$this->assertNull($this->resolveNzbCategory('<meta type="category">3040</meta>'));
$this->assertNull($this->resolveNzbCategory('<meta type="category">X264</meta>'));
$this->assertNull($this->resolveNzbCategory('<meta type="category">HD</meta>'));
}
public function test_nzb_category_metadata_rejects_conflicting_matches(): void
{
$this->insertCategory(2040, 'Movie HD', Category::STATUS_ACTIVE);
$this->insertCategory(5040, 'TV HD', Category::STATUS_ACTIVE);
$this->assertNull($this->resolveNzbCategory(
'<meta type="category">2040</meta><meta type="category">TV HD</meta>'
));
}
public function test_nzb_category_metadata_falls_back_when_category_is_missing_or_blank(): void
{
$this->assertNull($this->resolveNzbXml('<nzb><file subject="example" /></nzb>'));
$this->assertNull($this->resolveNzbCategory(''));
$this->assertNull($this->resolveNzbCategory('<meta type="category"> </meta>'));
$this->assertNull($this->resolveNzbCategory('<meta type="password">secret</meta>'));
}
public function test_compressed_nzb_write_returns_false_for_an_unwritable_destination(): void
{
$service = new class(['Browser' => true]) extends NzbImportService
{
public function writeForTest(string $path, string $contents): bool
{
return $this->writeCompressedNzb($path, $contents);
}
};
$path = sys_get_temp_dir().'/missing-'.bin2hex(random_bytes(5)).'/release.nzb.gz';
$this->assertFalse($service->writeForTest($path, '<nzb />'));
$this->assertFileDoesNotExist($path);
}
private function makeNzbFile(string $suffix): string
{
$path = sys_get_temp_dir().'/'.$suffix.'-'.bin2hex(random_bytes(5)).'.nzb';
@@ -236,6 +305,36 @@ final class NzbImportServiceTest extends TestCase
return $path;
}
private function insertCategory(int $id, string $title, int $status): void
{
DB::table('categories')->insert([
'id' => $id,
'title' => $title,
'status' => $status,
]);
}
private function resolveNzbCategory(string $headMetadata, string $nzbAttributes = ''): ?int
{
return $this->resolveNzbXml("<nzb{$nzbAttributes}><head>{$headMetadata}</head></nzb>");
}
private function resolveNzbXml(string $xml): ?int
{
$service = new class(['Browser' => true]) extends NzbImportService
{
public function resolveForTest(\SimpleXMLElement $nzb): ?int
{
return $this->resolveNzbCategoryId($nzb);
}
};
$nzb = simplexml_load_string($xml);
$this->assertInstanceOf(\SimpleXMLElement::class, $nzb);
return $service->resolveForTest($nzb);
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
+30 -1
View File
@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit;
use App\Services\Nzb\NzbService;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Tests\TestCase;
final class NzbServicePathResolutionTest extends TestCase
{
@@ -120,6 +120,35 @@ final class NzbServicePathResolutionTest extends TestCase
}
}
public function test_build_nzb_path_creates_group_writable_setgid_directories_despite_umask(): void
{
$tempDir = sys_get_temp_dir().'/nzb-path-permissions-'.uniqid('', true);
$basePath = $tempDir.'/';
mkdir($basePath, 0775, true);
$previousUmask = umask(0022);
try {
$service = $this->makeServiceWithoutConstructor();
\Closure::bind(
function (string $path): void {
$this->siteNzbPath = $path;
},
$service,
NzbService::class
)($basePath);
$path = $service->buildNzbPath('4aabfe07-daff-4d28-9d1d-d2a4ab7b6511', 4, true);
$this->assertSame($basePath.'4/a/a/b/', $path);
foreach ([$basePath.'4', $basePath.'4/a', $basePath.'4/a/a', $basePath.'4/a/a/b'] as $directory) {
$this->assertSame(02775, fileperms($directory) & 07777);
}
} finally {
umask($previousUmask);
$this->deleteDirectory($tempDir);
}
}
private function makeServiceWithoutConstructor(): NzbService
{
return (new ReflectionClass(NzbService::class))->newInstanceWithoutConstructor();