ReleaseFileManager   F
last analyzed

Complexity

Total Complexity 94

Size/Duplication

Total Lines 491
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 94
eloc 242
dl 0
loc 491
rs 2
c 0
b 0
f 0

13 Methods

Rating   Name   Duplication   Size   Complexity  
A updateSearchIndex() 0 6 2
F deleteRelease() 0 64 15
A normalizeCandidateTitle() 0 8 1
B processReleaseNameFromRar() 0 58 11
B finalizeRelease() 0 34 9
A extractReleaseNameFromFile() 0 18 3
A __construct() 0 8 1
A elasticsearch() 0 6 2
A manticore() 0 6 2
C processPar2File() 0 78 16
A processNfoFile() 0 18 4
C isPlausibleReleaseTitle() 0 27 12
C addFileInfo() 0 93 16

How to fix   Complexity   

Complex Class

Complex classes like ReleaseFileManager often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ReleaseFileManager, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Services\AdditionalProcessing;
4
5
use App\Models\MediaInfo as MediaInfoModel;
6
use App\Models\Predb;
7
use App\Models\Release;
8
use App\Models\ReleaseFile;
9
use Blacklight\Releases;
10
use App\Services\AdditionalProcessing\Config\ProcessingConfiguration;
0 ignored issues
show
Bug introduced by
The type App\Services\AdditionalP...ProcessingConfiguration was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
12
use Blacklight\ElasticSearchSiteSearch;
13
use Blacklight\ManticoreSearch;
14
use Blacklight\NameFixer;
15
use Blacklight\Nfo;
16
use Blacklight\NZB;
17
use Blacklight\ReleaseExtra;
18
use Blacklight\ReleaseImage;
19
use Illuminate\Contracts\Filesystem\FileNotFoundException;
20
use Illuminate\Support\Carbon;
21
use Illuminate\Support\Facades\File;
22
use Illuminate\Support\Facades\Log;
23
24
/**
25
 * Service for managing release-related database operations.
26
 * Handles file info, release updates, deletions, and search index updates.
27
 */
28
class ReleaseFileManager
29
{
30
    private ?ManticoreSearch $manticore = null;
31
    private ?ElasticSearchSiteSearch $elasticsearch = null;
32
33
    public function __construct(
34
        private readonly ProcessingConfiguration $config,
35
        private readonly ReleaseExtra $releaseExtra,
36
        private readonly ReleaseImage $releaseImage,
37
        private readonly Nfo $nfo,
38
        private readonly NZB $nzb,
39
        private readonly NameFixer $nameFixer
40
    ) {}
41
42
    /**
43
     * Add file information to the database.
44
     * @throws \Exception
45
     */
46
    public function addFileInfo(
47
        array $file,
48
        ReleaseProcessingContext $context,
49
        string $supportFileRegex
50
    ): bool {
51
        if (isset($file['error'])) {
52
            if ($this->config->debugMode) {
53
                Log::debug("Error: {$file['error']} (in: {$file['source']})");
54
            }
55
            return false;
56
        }
57
58
        if (! isset($file['name'])) {
59
            return false;
60
        }
61
62
        // Check for password
63
        if (isset($file['pass']) && $file['pass'] === true) {
64
            $context->releaseHasPassword = true;
65
            $context->passwordStatus = Releases::PASSWD_RAR;
66
            return false;
67
        }
68
69
        // Check inner file blacklist
70
        if ($this->config->innerFileBlacklist !== false
71
            && preg_match($this->config->innerFileBlacklist, $file['name'])
72
        ) {
73
            $context->releaseHasPassword = true;
74
            $context->passwordStatus = Releases::PASSWD_RAR;
75
            return false;
76
        }
77
78
        // Skip support files
79
        if (preg_match(
80
            '/(?:'.$supportFileRegex.'|part\d+|[rz]\d{1,3}|zipr\d{2,3}|\d{2,3}|zipx?|zip|rar|7z|gz|bz2|xz)(\s*\.rar)?$/i',
81
            $file['name']
82
        )) {
83
            return false;
84
        }
85
86
        // Increment total file info count
87
        $context->totalFileInfo++;
88
89
        // Don't add too many files
90
        if ($context->addedFileInfo >= 11) {
91
            return false;
92
        }
93
94
        // Check if a file already exists
95
        $exists = ReleaseFile::query()
96
            ->where([
97
                'releases_id' => $context->release->id,
98
                'name' => $file['name'],
99
                'size' => $file['size'],
100
            ])
101
            ->first();
102
103
        if ($exists !== null) {
104
            return false;
105
        }
106
107
        // Add the file
108
        $added = ReleaseFile::addReleaseFiles(
109
            $context->release->id,
110
            $file['name'],
111
            $file['size'],
112
            $file['date'],
113
            $file['pass'] ?? 0,
114
            '',
115
            $file['crc32'] ?? ''
116
        );
117
118
        if (! empty($added)) {
119
            $context->addedFileInfo++;
120
121
            // Check for codec spam
122
            if (preg_match('#(?:^|[/\\\\])Codec[/\\\\]Setup\.exe$#i', $file['name'])) {
123
                if ($this->config->debugMode) {
124
                    Log::debug('Codec spam found, setting release to potentially passworded.');
125
                }
126
                $context->releaseHasPassword = true;
127
                $context->passwordStatus = Releases::PASSWD_RAR;
128
            } elseif ($file['name'] !== '' && ! str_starts_with($file['name'], '.')) {
129
                // Run PreDB filename check
130
                $context->release['filename'] = $file['name'];
131
                $context->release['releases_id'] = $context->release->id;
132
                $this->nameFixer->matchPreDbFiles($context->release, 1, 1, true);
133
            }
134
135
            return true;
136
        }
137
138
        return false;
139
    }
140
141
    /**
142
     * Update search indexes after adding file info.
143
     */
144
    public function updateSearchIndex(int $releaseId): void
145
    {
146
        if ($this->config->elasticsearchEnabled) {
147
            $this->elasticsearch()->updateRelease($releaseId);
148
        } else {
149
            $this->manticore()->updateRelease($releaseId);
150
        }
151
    }
152
153
    /**
154
     * Finalize release processing with status updates.
155
     */
156
    public function finalizeRelease(ReleaseProcessingContext $context, bool $processPasswords): void
157
    {
158
        $updateRows = ['haspreview' => 0];
159
160
        // Check for existing samples
161
        if (File::isFile($this->releaseImage->imgSavePath.$context->release->guid.'_thumb.jpg')) {
0 ignored issues
show
Bug introduced by
The property guid does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
162
            $updateRows = ['haspreview' => 1];
163
        }
164
165
        if (File::isFile($this->releaseImage->vidSavePath.$context->release->guid.'.ogv')) {
166
            $updateRows['videostatus'] = 1;
167
        }
168
169
        if (File::isFile($this->releaseImage->jpgSavePath.$context->release->guid.'_thumb.jpg')) {
170
            $updateRows['jpgstatus'] = 1;
171
        }
172
173
        // Get file count
174
        $releaseFilesCount = ReleaseFile::whereReleasesId($context->release->id)->count('releases_id') ?? 0;
175
176
        $passwordStatus = max([$context->passwordStatus]);
177
178
        // Set to no password if processing is off
179
        if (! $processPasswords) {
180
            $context->releaseHasPassword = false;
181
        }
182
183
        // Update based on conditions
184
        if (! $context->releaseHasPassword && $context->nzbHasCompressedFile && $releaseFilesCount === 0) {
185
            Release::query()->where('id', $context->release->id)->update($updateRows);
186
        } else {
187
            $updateRows['passwordstatus'] = $processPasswords ? $passwordStatus : Releases::PASSWD_NONE;
188
            $updateRows['rarinnerfilecount'] = $releaseFilesCount;
189
            Release::query()->where('id', $context->release->id)->update($updateRows);
190
        }
191
    }
192
193
    /**
194
     * Delete a broken release completely.
195
     */
196
    public function deleteRelease(Release $release): void
197
    {
198
        try {
199
            if (empty($release->id)) {
200
                return;
201
            }
202
203
            $id = (int) $release->id;
204
            $guid = $release->guid ?? '';
0 ignored issues
show
Bug introduced by
The property guid does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
205
206
            // Delete NZB file
207
            try {
208
                $nzbPath = $this->nzb->NZBPath($guid);
209
                if ($nzbPath && File::exists($nzbPath)) {
210
                    File::delete($nzbPath);
211
                }
212
            } catch (\Throwable) {
213
                // Ignore
214
            }
215
216
            // Delete preview assets
217
            try {
218
                $files = [
219
                    $this->releaseImage->imgSavePath.$guid.'_thumb.jpg',
220
                    $this->releaseImage->jpgSavePath.$guid.'_thumb.jpg',
221
                    $this->releaseImage->vidSavePath.$guid.'.ogv',
222
                ];
223
                foreach ($files as $file) {
224
                    if ($file && File::exists($file)) {
225
                        File::delete($file);
226
                    }
227
                }
228
            } catch (\Throwable) {
229
                // Ignore
230
            }
231
232
            // Delete related database rows
233
            try {
234
                ReleaseFile::where('releases_id', $id)->delete();
235
            } catch (\Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
236
            }
237
238
            try {
239
                MediaInfoModel::where('releases_id', $id)->delete();
240
            } catch (\Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
241
            }
242
243
            // Delete from search index
244
            try {
245
                if ($this->config->elasticsearchEnabled) {
246
                    $this->elasticsearch()->deleteRelease($id);
247
                } else {
248
                    $this->manticore()->deleteRelease(['i' => $id, 'g' => $guid]);
249
                }
250
            } catch (\Throwable) {
251
                // Ignore
252
            }
253
254
            // Delete release row
255
            try {
256
                Release::where('id', $id)->delete();
257
            } catch (\Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
258
            }
259
        } catch (\Throwable) {
260
            // Last resort: swallow any exception
261
        }
262
    }
263
264
    /**
265
     * Process PAR2 file for file info and release name matching.
266
     */
267
    public function processPar2File(
268
        string $fileLocation,
269
        ReleaseProcessingContext $context,
270
        \dariusiii\rarinfo\Par2Info $par2Info
271
    ): bool {
272
        $par2Info->open($fileLocation);
273
274
        if ($par2Info->error) {
275
            return false;
276
        }
277
278
        $releaseInfo = Release::query()
279
            ->where('id', $context->release->id)
280
            ->select(['postdate', 'proc_pp'])
281
            ->first();
282
283
        if ($releaseInfo === null) {
284
            return false;
285
        }
286
287
        $postDate = Carbon::createFromFormat('Y-m-d H:i:s', $releaseInfo->postdate)->getTimestamp();
0 ignored issues
show
Bug introduced by
The property postdate does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
288
289
        // Only get new name if category is OTHER
290
        $foundName = true;
291
        if ((int) $releaseInfo->proc_pp === 0 && $this->config->renamePar2
0 ignored issues
show
Bug introduced by
The property proc_pp does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
292
            && in_array((int) $context->release->categories_id, \App\Models\Category::OTHERS_GROUP, false)
0 ignored issues
show
Bug introduced by
The property categories_id does not exist on App\Models\Release. Did you mean category_ids?
Loading history...
293
        ) {
294
            $foundName = false;
295
        }
296
297
        $filesAdded = 0;
298
299
        foreach ($par2Info->getFileList() as $file) {
300
            if (! isset($file['name'])) {
301
                continue;
302
            }
303
304
            if ($foundName && $filesAdded > 10) {
305
                break;
306
            }
307
308
            // Add to release files
309
            if ($this->config->addPAR2Files) {
310
                if ($filesAdded < 11
311
                    && ReleaseFile::query()
312
                        ->where(['releases_id' => $context->release->id, 'name' => $file['name']])
313
                        ->first() === null
314
                ) {
315
                    if (ReleaseFile::addReleaseFiles(
316
                        $context->release->id,
317
                        $file['name'],
318
                        $file['size'],
319
                        $postDate,
320
                        0,
321
                        $file['hash_16K']
322
                    )) {
323
                        $filesAdded++;
324
                    }
325
                }
326
            } else {
327
                $filesAdded++;
328
            }
329
330
            // Try to get a new name
331
            if (! $foundName) {
332
                $context->release->textstring = $file['name'];
0 ignored issues
show
Bug introduced by
The property textstring does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
333
                $context->release->releases_id = $context->release->id;
0 ignored issues
show
Bug introduced by
The property releases_id does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
334
                if ($this->nameFixer->checkName($context->release, $this->config->echoCLI, 'PAR2, ', 1, 1)) {
335
                    $foundName = true;
336
                }
337
            }
338
        }
339
340
        // Update file count
341
        Release::query()->where('id', $context->release->id)->increment('rarinnerfilecount', $filesAdded);
342
        $context->foundPAR2Info = true;
343
344
        return true;
345
    }
346
347
    /**
348
     * Process NFO file.
349
     */
350
    public function processNfoFile(
351
        string $fileLocation,
352
        ReleaseProcessingContext $context,
353
        \Blacklight\NNTP $nntp
354
    ): bool {
355
        try {
356
            $data = File::get($fileLocation);
357
            if ($this->nfo->isNFO($data, $context->release->guid)
0 ignored issues
show
Bug introduced by
The property guid does not seem to exist on App\Models\Release. Are you sure there is no database migration missing?

Checks if undeclared accessed properties appear in database migrations and if the creating migration is correct.

Loading history...
358
                && $this->nfo->addAlternateNfo($data, $context->release, $nntp)
359
            ) {
360
                $context->releaseHasNoNFO = false;
361
                return true;
362
            }
363
        } catch (FileNotFoundException $e) {
364
            Log::warning("Could not read potential NFO file: {$fileLocation} - {$e->getMessage()}");
365
        }
366
367
        return false;
368
    }
369
370
    /**
371
     * Handle release name extraction from RAR file content.
372
     */
373
    public function processReleaseNameFromRar(
374
        array $dataSummary,
375
        ReleaseProcessingContext $context
376
    ): void {
377
        $fileData = $dataSummary['file_list'] ?? [];
378
        if (empty($fileData)) {
379
            return;
380
        }
381
382
        $rarFileName = array_column($fileData, 'name');
383
        if (empty($rarFileName[0])) {
384
            return;
385
        }
386
387
        $extractedName = $this->extractReleaseNameFromFile($rarFileName[0]);
388
389
        if ($extractedName !== null) {
390
            $preCheck = Predb::whereTitle($extractedName)->first();
391
            $context->release->preid = $preCheck !== null ? $preCheck->value('id') : 0;
0 ignored issues
show
Bug introduced by
The property preid does not exist on App\Models\Release. Did you mean predb?
Loading history...
392
            $candidate = $preCheck->title ?? $extractedName;
393
            $candidate = $this->normalizeCandidateTitle($candidate);
394
395
            if ($this->isPlausibleReleaseTitle($candidate)) {
396
                (new NameFixer())->updateRelease(
397
                    $context->release,
398
                    $candidate,
399
                    'RarInfo FileName Match',
400
                    true,
401
                    'Filenames, ',
402
                    true,
403
                    true,
404
                    $context->release->preid
405
                );
406
            } elseif ($this->config->debugMode) {
407
                Log::debug('RarInfo: Ignored low-quality candidate "'.$candidate.'" from inner file name.');
408
            }
409
        } elseif (! empty($dataSummary['archives'][$rarFileName[0]]['file_list'])) {
410
            // Try nested archive
411
            $archiveData = $dataSummary['archives'][$rarFileName[0]]['file_list'];
412
            $archiveFileName = array_column($archiveData, 'name');
413
            $extractedName = $this->extractReleaseNameFromFile($archiveFileName[0] ?? '');
414
415
            if ($extractedName !== null) {
416
                $preCheck = Predb::whereTitle($extractedName)->first();
417
                $context->release->preid = $preCheck !== null ? $preCheck->value('id') : 0;
418
                $candidate = $preCheck->title ?? $extractedName;
419
                $candidate = $this->normalizeCandidateTitle($candidate);
420
421
                if ($this->isPlausibleReleaseTitle($candidate)) {
422
                    (new NameFixer())->updateRelease(
423
                        $context->release,
424
                        $candidate,
425
                        'RarInfo FileName Match',
426
                        true,
427
                        'Filenames, ',
428
                        true,
429
                        true,
430
                        $context->release->preid
431
                    );
432
                }
433
            }
434
        }
435
    }
436
437
    /**
438
     * Extract the release name from a filename.
439
     */
440
    private function extractReleaseNameFromFile(string $filename): ?string
441
    {
442
        $basename = basename($filename);
443
        $cleaned = preg_replace(
444
            '/\.(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',
445
            '',
446
            $basename
447
        );
448
449
        if (preg_match('/^(.+[-.][A-Za-z0-9_]{2,})$/i', $cleaned, $match)) {
450
            return ucwords($match[1], '.-_ ');
451
        }
452
453
        if (preg_match(NameFixer::PREDB_REGEX, $cleaned, $hit)) {
454
            return ucwords($hit[0], '.');
455
        }
456
457
        return null;
458
    }
459
460
    /**
461
     * Normalize a candidate title.
462
     */
463
    private function normalizeCandidateTitle(string $title): string
464
    {
465
        $t = trim($title);
466
        $t = preg_replace('/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx)$/i', '', $t) ?? $t;
467
        $t = preg_replace('/\.(par2?|nfo|sfv|nzb|rar|zip|r\d{2,3}|pkg|exe|msi|jpe?g|png|gif|bmp)$/i', '', $t) ?? $t;
468
        $t = preg_replace('/[.\-_ ](?:part|vol|r)\d+(?:\+\d+)?$/i', '', $t) ?? $t;
469
        $t = preg_replace('/[\s_]+/', ' ', $t) ?? $t;
470
        return trim($t, " .-_\t\r\n");
471
    }
472
473
    /**
474
     * Check if a title is plausible for release naming.
475
     */
476
    private function isPlausibleReleaseTitle(string $title): bool
477
    {
478
        $t = trim($title);
479
        if ($t === '' || strlen($t) < 12) {
480
            return false;
481
        }
482
483
        $wordCount = preg_match_all('/[A-Za-z0-9]{3,}/', $t);
484
        if ($wordCount < 2) {
485
            return false;
486
        }
487
488
        if (preg_match('/(?:^|[.\-_ ])(?:part|vol|r)\d+(?:\+\d+)?$/i', $t)) {
489
            return false;
490
        }
491
492
        if (preg_match('/^(setup|install|installer|patch|update|crack|keygen)\d*[\s._-]/i', $t)) {
493
            return false;
494
        }
495
496
        $hasGroupSuffix = (bool) preg_match('/[-.][A-Za-z0-9]{2,}$/', $t);
497
        $hasYear = (bool) preg_match('/\b(19|20)\d{2}\b/', $t);
498
        $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);
499
        $hasTV = (bool) preg_match('/\bS\d{1,2}[Eex]\d{1,3}\b/i', $t);
500
        $hasXXX = (bool) preg_match('/\bXXX\b/i', $t);
501
502
        return $hasGroupSuffix || ($hasTV && $hasQuality) || ($hasYear && ($hasQuality || $hasTV)) || $hasXXX;
503
    }
504
505
    private function manticore(): ManticoreSearch
506
    {
507
        if ($this->manticore === null) {
508
            $this->manticore = new ManticoreSearch();
509
        }
510
        return $this->manticore;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->manticore could return the type null which is incompatible with the type-hinted return Blacklight\ManticoreSearch. Consider adding an additional type-check to rule them out.
Loading history...
511
    }
512
513
    private function elasticsearch(): ElasticSearchSiteSearch
514
    {
515
        if ($this->elasticsearch === null) {
516
            $this->elasticsearch = new ElasticSearchSiteSearch();
517
        }
518
        return $this->elasticsearch;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->elasticsearch could return the type null which is incompatible with the type-hinted return Blacklight\ElasticSearchSiteSearch. Consider adding an additional type-check to rule them out.
Loading history...
519
    }
520
}
521
522