ReleaseFileManager::normalizeNfoEncoding()   B
last analyzed

Complexity

Conditions 7
Paths 24

Size

Total Lines 35
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 14
c 1
b 0
f 0
dl 0
loc 35
rs 8.8333
cc 7
nc 24
nop 1
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 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...
10
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
11
use App\Services\NameFixing\NameFixingService;
12
use App\Services\NameFixing\ReleaseUpdateService;
13
use App\Services\NfoService;
14
use App\Services\NNTP\NNTPService;
15
use App\Services\Nzb\NzbService;
16
use App\Services\ReleaseExtraService;
17
use App\Services\ReleaseImageService;
18
use App\Services\Releases\ReleaseBrowseService;
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
    public function __construct(
31
        private readonly ProcessingConfiguration $config,
32
        private readonly ReleaseExtraService $releaseExtra,
33
        private readonly ReleaseImageService $releaseImage,
34
        private readonly NfoService $nfo,
35
        private readonly NzbService $nzb,
36
        private readonly NameFixingService $nameFixingService
37
    ) {}
38
39
    /**
40
     * Add file information to the database.
41
     *
42
     * @throws \Exception
43
     */
44
    public function addFileInfo(
45
        array $file,
46
        ReleaseProcessingContext $context,
47
        string $supportFileRegex
48
    ): bool {
49
        if (isset($file['error'])) {
50
            if ($this->config->debugMode) {
51
                Log::debug("Error: {$file['error']} (in: {$file['source']})");
52
            }
53
54
            return false;
55
        }
56
57
        if (! isset($file['name'])) {
58
            return false;
59
        }
60
61
        // Check for password
62
        if (isset($file['pass']) && $file['pass'] === true) {
63
            $context->releaseHasPassword = true;
64
            $context->passwordStatus = ReleaseBrowseService::PASSWD_RAR;
65
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 = ReleaseBrowseService::PASSWD_RAR;
75
76
            return false;
77
        }
78
79
        // Skip support files
80
        if (preg_match(
81
            '/(?:'.$supportFileRegex.'|part\d+|[rz]\d{1,3}|zipr\d{2,3}|\d{2,3}|zipx?|zip|rar|7z|gz|bz2|xz)(\s*\.rar)?$/i',
82
            $file['name']
83
        )) {
84
            return false;
85
        }
86
87
        // Increment total file info count
88
        $context->totalFileInfo++;
89
90
        // Don't add too many files
91
        if ($context->addedFileInfo >= 11) {
92
            return false;
93
        }
94
95
        // Check if a file already exists
96
        $exists = ReleaseFile::query()
97
            ->where([
98
                'releases_id' => $context->release->id,
99
                'name' => $file['name'],
100
                'size' => $file['size'] ?? 0,
101
            ])
102
            ->first();
103
104
        if ($exists !== null) {
105
            return false;
106
        }
107
108
        // Add the file
109
        $added = ReleaseFile::addReleaseFiles(
110
            $context->release->id,
111
            $file['name'],
112
            $file['size'] ?? 0,
113
            $file['date'] ?? now(),
114
            $file['pass'] ?? 0,
115
            '',
116
            $file['crc32'] ?? ''
117
        );
118
119
        if (! empty($added)) {
120
            $context->addedFileInfo++;
121
122
            // Check for codec spam
123
            if (preg_match('#(?:^|[/\\\\])Codec[/\\\\]Setup\.exe$#i', $file['name'])) {
124
                if ($this->config->debugMode) {
125
                    Log::debug('Codec spam found, setting release to potentially passworded.');
126
                }
127
                $context->releaseHasPassword = true;
128
                $context->passwordStatus = ReleaseBrowseService::PASSWD_RAR;
129
            } elseif ($file['name'] !== '' && ! str_starts_with($file['name'], '.')) {
130
                // Run PreDB filename check
131
                $context->release['filename'] = $file['name'];
132
                $context->release['releases_id'] = $context->release->id;
133
                $this->nameFixingService->matchPreDbFiles($context->release, true, true, true);
134
            }
135
136
            return true;
137
        }
138
139
        return false;
140
    }
141
142
    /**
143
     * Update search indexes after adding file info.
144
     */
145
    public function updateSearchIndex(int $releaseId): void
146
    {
147
        \App\Facades\Search::updateRelease($releaseId);
148
    }
149
150
    /**
151
     * Finalize release processing with status updates.
152
     */
153
    public function finalizeRelease(ReleaseProcessingContext $context, bool $processPasswords): void
154
    {
155
        $updateRows = ['haspreview' => 0];
156
157
        // Check for existing samples
158
        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...
159
            $updateRows = ['haspreview' => 1];
160
        }
161
162
        if (File::isFile($this->releaseImage->vidSavePath.$context->release->guid.'.ogv')) {
163
            $updateRows['videostatus'] = 1;
164
        }
165
166
        if (File::isFile($this->releaseImage->jpgSavePath.$context->release->guid.'_thumb.jpg')) {
167
            $updateRows['jpgstatus'] = 1;
168
        }
169
170
        // Get file count
171
        $releaseFilesCount = ReleaseFile::whereReleasesId($context->release->id)->count('releases_id') ?? 0;
172
173
        $passwordStatus = max([$context->passwordStatus]);
174
175
        // Set to no password if processing is off
176
        if (! $processPasswords) {
177
            $context->releaseHasPassword = false;
178
        }
179
180
        // Update based on conditions
181
        if (! $context->releaseHasPassword && $context->nzbHasCompressedFile && $releaseFilesCount === 0) {
182
            Release::query()->where('id', $context->release->id)->update($updateRows);
183
        } else {
184
            $updateRows['passwordstatus'] = $processPasswords ? $passwordStatus : ReleaseBrowseService::PASSWD_NONE;
185
            $updateRows['rarinnerfilecount'] = $releaseFilesCount;
186
            Release::query()->where('id', $context->release->id)->update($updateRows);
187
        }
188
    }
189
190
    /**
191
     * Delete a broken release completely.
192
     */
193
    public function deleteRelease(Release $release): void
194
    {
195
        try {
196
            if (empty($release->id)) {
197
                return;
198
            }
199
200
            $id = (int) $release->id;
201
            $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...
202
203
            // Delete NZB file
204
            try {
205
                $nzbPath = $this->nzb->nzbPath($guid);
206
                if ($nzbPath && File::exists($nzbPath)) {
207
                    File::delete($nzbPath);
208
                }
209
            } catch (\Throwable) {
210
                // Ignore
211
            }
212
213
            // Delete preview assets
214
            try {
215
                $files = [
216
                    $this->releaseImage->imgSavePath.$guid.'_thumb.jpg',
217
                    $this->releaseImage->jpgSavePath.$guid.'_thumb.jpg',
218
                    $this->releaseImage->vidSavePath.$guid.'.ogv',
219
                ];
220
                foreach ($files as $file) {
221
                    if ($file && File::exists($file)) {
222
                        File::delete($file);
223
                    }
224
                }
225
            } catch (\Throwable) {
226
                // Ignore
227
            }
228
229
            // Delete related database rows
230
            try {
231
                ReleaseFile::where('releases_id', $id)->delete();
232
            } catch (\Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
233
            }
234
235
            try {
236
                MediaInfoModel::where('releases_id', $id)->delete();
237
            } catch (\Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
238
            }
239
240
            // Delete from search index
241
            try {
242
                \App\Facades\Search::deleteRelease($id);
243
            } catch (\Throwable) {
244
                // Ignore
245
            }
246
247
            // Delete release row
248
            try {
249
                Release::where('id', $id)->delete();
250
            } catch (\Throwable) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
251
            }
252
        } catch (\Throwable) {
253
            // Last resort: swallow any exception
254
        }
255
    }
256
257
    /**
258
     * Process PAR2 file for file info and release name matching.
259
     */
260
    public function processPar2File(
261
        string $fileLocation,
262
        ReleaseProcessingContext $context,
263
        \dariusiii\rarinfo\Par2Info $par2Info
264
    ): bool {
265
        $par2Info->open($fileLocation);
266
267
        if ($par2Info->error) {
268
            return false;
269
        }
270
271
        $releaseInfo = Release::query()
272
            ->where('id', $context->release->id)
273
            ->select(['postdate', 'proc_pp'])
274
            ->first();
275
276
        if ($releaseInfo === null) {
277
            return false;
278
        }
279
280
        $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...
281
282
        // Only get new name if category is OTHER
283
        $foundName = true;
284
        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...
285
            && 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...
286
        ) {
287
            $foundName = false;
288
        }
289
290
        $filesAdded = 0;
291
292
        foreach ($par2Info->getFileList() as $file) {
293
            if (! isset($file['name'])) {
294
                continue;
295
            }
296
297
            if ($foundName && $filesAdded > 10) {
298
                break;
299
            }
300
301
            // Add to release files
302
            if ($this->config->addPAR2Files) {
303
                if ($filesAdded < 11
304
                    && ReleaseFile::query()
305
                        ->where(['releases_id' => $context->release->id, 'name' => $file['name']])
306
                        ->first() === null
307
                ) {
308
                    if (ReleaseFile::addReleaseFiles(
309
                        $context->release->id,
310
                        $file['name'],
311
                        $file['size'] ?? 0,
312
                        $postDate,
313
                        0,
314
                        $file['hash_16K'] ?? ''
315
                    )) {
316
                        $filesAdded++;
317
                    }
318
                }
319
            } else {
320
                $filesAdded++;
321
            }
322
323
            // Try to get a new name
324
            if (! $foundName) {
325
                $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...
326
                $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...
327
                if ($this->nameFixingService->checkName($context->release, $this->config->echoCLI, 'PAR2, ', true, true)) {
328
                    $foundName = true;
329
                }
330
            }
331
        }
332
333
        // Update file count
334
        Release::query()->where('id', $context->release->id)->increment('rarinnerfilecount', $filesAdded);
335
        $context->foundPAR2Info = true;
336
337
        return true;
338
    }
339
340
    /**
341
     * Process NFO file with enhanced detection capabilities.
342
     *
343
     * Supports multiple NFO naming conventions:
344
     * - Standard: .nfo, .diz, .info
345
     * - Alternative: file_id.diz, readme.txt, info.txt
346
     * - Scene-style: 00-groupname.nfo, groupname-releasename.nfo
347
     */
348
    public function processNfoFile(
349
        string $fileLocation,
350
        ReleaseProcessingContext $context,
351
        NNTPService $nntp
352
    ): bool {
353
        try {
354
            $data = File::get($fileLocation);
355
356
            // Try to detect and convert encoding
357
            $data = $this->normalizeNfoEncoding($data);
358
359
            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...
360
                && $this->nfo->addAlternateNfo($data, $context->release, $nntp)
361
            ) {
362
                $context->releaseHasNoNFO = false;
363
364
                return true;
365
            }
366
        } catch (FileNotFoundException $e) {
367
            Log::warning("Could not read potential NFO file: {$fileLocation} - {$e->getMessage()}");
368
        }
369
370
        return false;
371
    }
372
373
    /**
374
     * Check if a filename looks like an NFO file.
375
     *
376
     * @param  string  $filename  The filename to check.
377
     * @return bool True if the filename matches NFO patterns.
378
     */
379
    public function isNfoFilename(string $filename): bool
380
    {
381
        // Standard NFO extensions
382
        if (preg_match('/\.(?:nfo|diz|info?)$/i', $filename)) {
383
            return true;
384
        }
385
386
        // Alternative NFO filenames
387
        $nfoPatterns = [
388
            '/^(?:file[_-]?id|readme|release|info(?:rmation)?|about|notes?)\.(?:txt|diz)$/i',
389
            '/^00-[a-z0-9_-]+\.nfo$/i',           // Scene: 00-group.nfo
390
            '/^0+-[a-z0-9_-]+\.nfo$/i',           // Scene variations
391
            '/^[a-z0-9_-]+-[a-z0-9_.-]+\.nfo$/i', // Scene: group-release.nfo
392
            '/info\.txt$/i',                      // info.txt (common alternative)
393
        ];
394
395
        $basename = basename($filename);
396
        foreach ($nfoPatterns as $pattern) {
397
            if (preg_match($pattern, $basename)) {
398
                return true;
399
            }
400
        }
401
402
        return false;
403
    }
404
405
    /**
406
     * Normalize NFO encoding to UTF-8.
407
     *
408
     * NFO files often use CP437 (DOS) encoding for ASCII art.
409
     * This method attempts to detect and convert various encodings.
410
     *
411
     * @param  string  $data  Raw NFO data.
412
     * @return string UTF-8 encoded NFO data.
413
     */
414
    protected function normalizeNfoEncoding(string $data): string
415
    {
416
        // Check for UTF-8 BOM and remove it
417
        if (str_starts_with($data, "\xEF\xBB\xBF")) {
418
            $data = substr($data, 3);
419
        }
420
421
        // Check for UTF-16 BOM
422
        if (str_starts_with($data, "\xFF\xFE")) {
423
            // UTF-16 LE
424
            $data = mb_convert_encoding(substr($data, 2), 'UTF-8', 'UTF-16LE');
425
        } elseif (str_starts_with($data, "\xFE\xFF")) {
426
            // UTF-16 BE
427
            $data = mb_convert_encoding(substr($data, 2), 'UTF-8', 'UTF-16BE');
428
        }
429
430
        // If already valid UTF-8, return as-is
431
        if (mb_check_encoding($data, 'UTF-8')) {
432
            return $data;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $data could return the type array which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
433
        }
434
435
        // Try CP437 (DOS encoding - common for scene NFOs with ASCII art)
436
        // Use the cp437toUTF helper function
437
        if (function_exists('cp437toUTF')) {
438
            return cp437toUTF($data);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type array; however, parameter $string of cp437toUTF() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

438
            return cp437toUTF(/** @scrutinizer ignore-type */ $data);
Loading history...
439
        }
440
441
        // Fallback: try ISO-8859-1 (Latin-1)
442
        $converted = @mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1');
443
        if ($converted !== false) {
444
            return $converted;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $converted could return the type array which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
445
        }
446
447
        // Last resort: force UTF-8 with error handling
448
        return mb_convert_encoding($data, 'UTF-8', 'UTF-8');
0 ignored issues
show
Bug Best Practice introduced by
The expression return mb_convert_encodi...data, 'UTF-8', 'UTF-8') could return the type array which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
449
    }
450
451
    /**
452
     * Handle release name extraction from RAR file content.
453
     */
454
    public function processReleaseNameFromRar(
455
        array $dataSummary,
456
        ReleaseProcessingContext $context
457
    ): void {
458
        $fileData = $dataSummary['file_list'] ?? [];
459
        if (empty($fileData)) {
460
            return;
461
        }
462
463
        $rarFileName = array_column($fileData, 'name');
464
        if (empty($rarFileName[0])) {
465
            return;
466
        }
467
468
        $extractedName = $this->extractReleaseNameFromFile($rarFileName[0]);
469
470
        if ($extractedName !== null) {
471
            $preCheck = Predb::whereTitle($extractedName)->first();
472
            $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...
473
            $candidate = $preCheck->title ?? $extractedName;
474
            $candidate = $this->normalizeCandidateTitle($candidate);
475
476
            if ($this->isPlausibleReleaseTitle($candidate)) {
477
                (new ReleaseUpdateService)->updateRelease(
478
                    $context->release,
479
                    $candidate,
480
                    'RarInfo FileName Match',
481
                    true,
482
                    'Filenames, ',
483
                    true,
484
                    true,
485
                    $context->release->preid
0 ignored issues
show
Bug introduced by
It seems like $context->release->preid can also be of type App\Models\Predb and Illuminate\Database\Eloq...gHasThroughRelationship; however, parameter $preId of App\Services\NameFixing\...ervice::updateRelease() does only seem to accept integer|null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

485
                    /** @scrutinizer ignore-type */ $context->release->preid
Loading history...
486
                );
487
            } elseif ($this->config->debugMode) {
488
                Log::debug('RarInfo: Ignored low-quality candidate "'.$candidate.'" from inner file name.');
489
            }
490
        } elseif (! empty($dataSummary['archives'][$rarFileName[0]]['file_list'])) {
491
            // Try nested archive
492
            $archiveData = $dataSummary['archives'][$rarFileName[0]]['file_list'];
493
            $archiveFileName = array_column($archiveData, 'name');
494
            $extractedName = $this->extractReleaseNameFromFile($archiveFileName[0] ?? '');
495
496
            if ($extractedName !== null) {
497
                $preCheck = Predb::whereTitle($extractedName)->first();
498
                $context->release->preid = $preCheck !== null ? $preCheck->value('id') : 0;
499
                $candidate = $preCheck->title ?? $extractedName;
500
                $candidate = $this->normalizeCandidateTitle($candidate);
501
502
                if ($this->isPlausibleReleaseTitle($candidate)) {
503
                    (new ReleaseUpdateService)->updateRelease(
504
                        $context->release,
505
                        $candidate,
506
                        'RarInfo FileName Match',
507
                        true,
508
                        'Filenames, ',
509
                        true,
510
                        true,
511
                        $context->release->preid
512
                    );
513
                }
514
            }
515
        }
516
    }
517
518
    /**
519
     * Extract the release name from a filename.
520
     */
521
    private function extractReleaseNameFromFile(string $filename): ?string
522
    {
523
        $basename = basename($filename);
524
        $cleaned = preg_replace(
525
            '/\.(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',
526
            '',
527
            $basename
528
        );
529
530
        if (preg_match('/^(.+[-.][A-Za-z0-9_]{2,})$/i', $cleaned, $match)) {
531
            return ucwords($match[1], '.-_ ');
532
        }
533
534
        if (preg_match(ReleaseUpdateService::PREDB_REGEX, $cleaned, $hit)) {
535
            return ucwords($hit[0], '.');
536
        }
537
538
        return null;
539
    }
540
541
    /**
542
     * Normalize a candidate title.
543
     */
544
    private function normalizeCandidateTitle(string $title): string
545
    {
546
        $t = trim($title);
547
        $t = preg_replace('/\.(mkv|avi|mp4|m4v|mpg|mpeg|wmv|flv|mov|ts|vob|iso|divx)$/i', '', $t) ?? $t;
548
        $t = preg_replace('/\.(par2?|nfo|sfv|nzb|rar|zip|r\d{2,3}|pkg|exe|msi|jpe?g|png|gif|bmp)$/i', '', $t) ?? $t;
549
        $t = preg_replace('/[.\-_ ](?:part|vol|r)\d+(?:\+\d+)?$/i', '', $t) ?? $t;
550
        $t = preg_replace('/[\s_]+/', ' ', $t) ?? $t;
551
552
        return trim($t, " .-_\t\r\n");
553
    }
554
555
    /**
556
     * Check if a title is plausible for release naming.
557
     */
558
    private function isPlausibleReleaseTitle(string $title): bool
559
    {
560
        $t = trim($title);
561
        if ($t === '' || strlen($t) < 12) {
562
            return false;
563
        }
564
565
        $wordCount = preg_match_all('/[A-Za-z0-9]{3,}/', $t);
566
        if ($wordCount < 2) {
567
            return false;
568
        }
569
570
        if (preg_match('/(?:^|[.\-_ ])(?:part|vol|r)\d+(?:\+\d+)?$/i', $t)) {
571
            return false;
572
        }
573
574
        if (preg_match('/^(setup|install|installer|patch|update|crack|keygen)\d*[\s._-]/i', $t)) {
575
            return false;
576
        }
577
578
        $hasGroupSuffix = (bool) preg_match('/[-.][A-Za-z0-9]{2,}$/', $t);
579
        $hasYear = (bool) preg_match('/\b(19|20)\d{2}\b/', $t);
580
        $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);
581
        $hasTV = (bool) preg_match('/\bS\d{1,2}[Eex]\d{1,3}\b/i', $t);
582
        $hasXXX = (bool) preg_match('/\bXXX\b/i', $t);
583
584
        return $hasGroupSuffix || ($hasTV && $hasQuality) || ($hasYear && ($hasQuality || $hasTV)) || $hasXXX;
585
    }
586
}
587