mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Respect imported nzb category
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user