ArchiveExtractionService::scanSevenZipFilenames()   B
last analyzed

Complexity

Conditions 8
Paths 6

Size

Total Lines 31
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 31
rs 8.4444
c 0
b 0
f 0
cc 8
nc 6
nop 1
1
<?php
2
3
namespace App\Services\AdditionalProcessing;
4
5
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...
6
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
7
use Blacklight\Releases;
8
use dariusiii\rarinfo\ArchiveInfo;
9
use dariusiii\rarinfo\Par2Info;
10
use Illuminate\Support\Facades\File;
11
use Illuminate\Support\Facades\Log;
12
13
/**
14
 * Service for extracting and processing archive files (RAR, ZIP, 7z, gzip, bzip2, xz).
15
 * Handles password detection, file listing, and content extraction.
16
 */
17
class ArchiveExtractionService
18
{
19
    private ArchiveInfo $archiveInfo;
20
    private Par2Info $par2Info;
21
22
    public function __construct(
23
        private readonly ProcessingConfiguration $config
24
    ) {
25
        $this->archiveInfo = new ArchiveInfo();
26
        $this->par2Info = new Par2Info();
27
28
        // Configure external clients for ArchiveInfo
29
        if ($this->config->unrarPath) {
30
            $this->archiveInfo->setExternalClients([ArchiveInfo::TYPE_RAR => $this->config->unrarPath]);
31
        }
32
    }
33
34
    /**
35
     * Process compressed data and extract file information.
36
     *
37
     * @return array{success: bool, files: array, hasPassword: bool, passwordStatus: int}
38
     */
39
    public function processCompressedData(
40
        string $compressedData,
41
        ReleaseProcessingContext $context,
42
        string $tmpPath
43
    ): array {
44
        $result = [
45
            'success' => false,
46
            'files' => [],
47
            'hasPassword' => false,
48
            'passwordStatus' => Releases::PASSWD_NONE,
49
        ];
50
51
        $context->compressedFilesChecked++;
52
53
        // Detect archive type early
54
        $archiveType = $this->detectArchiveType($compressedData);
55
56
        // Handle 7z, gzip, bzip2, xz with external 7zip binary
57
        if (in_array($archiveType, ['7z', 'gzip', 'bzip2', 'xz'], true)) {
58
            if ($archiveType === '7z') {
59
                $sevenZipResult = $this->processSevenZipArchive($compressedData, $context, $tmpPath);
60
                if ($sevenZipResult['success'] || $sevenZipResult['hasPassword']) {
61
                    return $sevenZipResult;
62
                }
63
            }
64
65
            if ($this->config->sevenZipPath) {
66
                $extractResult = $this->extractViaSevenZip($compressedData, $archiveType, $tmpPath);
0 ignored issues
show
Bug introduced by
It seems like $archiveType can also be of type null; however, parameter $type of App\Services\AdditionalP...e::extractViaSevenZip() 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

66
                $extractResult = $this->extractViaSevenZip($compressedData, /** @scrutinizer ignore-type */ $archiveType, $tmpPath);
Loading history...
67
                if ($extractResult['success']) {
68
                    return $extractResult;
69
                }
70
            }
71
        }
72
73
        // Try ArchiveInfo for RAR/ZIP
74
        if (! $this->archiveInfo->setData($compressedData, true)) {
75
            // Handle standalone video detection
76
            $videoType = $this->detectStandaloneVideo($compressedData);
77
            if ($videoType !== null) {
78
                return [
79
                    'success' => false,
80
                    'files' => [],
81
                    'hasPassword' => false,
82
                    'passwordStatus' => Releases::PASSWD_NONE,
83
                    'standaloneVideoType' => $videoType,
84
                    'standaloneVideoData' => $compressedData,
85
                ];
86
            }
87
            return $result;
88
        }
89
90
        if ($this->archiveInfo->error !== '') {
91
            if ($this->config->debugMode) {
92
                Log::debug('ArchiveInfo Error: '.$this->archiveInfo->error);
93
            }
94
            return $result;
95
        }
96
97
        try {
98
            $dataSummary = $this->archiveInfo->getSummary(true);
99
        } catch (\Exception $e) {
100
            if ($this->config->debugMode) {
101
                Log::warning($e->getTraceAsString());
102
            }
103
            return $result;
104
        }
105
106
        // Check for encryption
107
        if (! empty($this->archiveInfo->isEncrypted)
108
            || (isset($dataSummary['is_encrypted']) && (int) $dataSummary['is_encrypted'] !== 0)
109
        ) {
110
            if ($this->config->debugMode) {
111
                Log::debug('ArchiveInfo: Compressed file has a password.');
112
            }
113
            return [
114
                'success' => false,
115
                'files' => [],
116
                'hasPassword' => true,
117
                'passwordStatus' => Releases::PASSWD_RAR,
118
            ];
119
        }
120
121
        // Prepare extraction directories
122
        $this->prepareExtractionDirectories($tmpPath);
123
124
        // Process based on archive type
125
        $archiveMarker = $this->extractArchive($compressedData, $dataSummary, $tmpPath);
126
127
        // Get file list
128
        $files = $this->archiveInfo->getArchiveFileList();
129
        if (! is_array($files) || count($files) === 0) {
130
            return $result;
131
        }
132
133
        return [
134
            'success' => true,
135
            'files' => $files,
136
            'hasPassword' => false,
137
            'passwordStatus' => Releases::PASSWD_NONE,
138
            'archiveMarker' => $archiveMarker,
139
            'dataSummary' => $dataSummary,
140
        ];
141
    }
142
143
    /**
144
     * Detect the archive type from binary signature.
145
     */
146
    public function detectArchiveType(string $data): ?string
147
    {
148
        $head6 = substr($data, 0, 6);
149
        $head4 = substr($data, 0, 4);
150
151
        // 7z signature
152
        if ($head6 === "\x37\x7A\xBC\xAF\x27\x1C" && $this->isLikely7z($data)) {
153
            return '7z';
154
        }
155
        // GZIP
156
        if (strncmp($head4, "\x1F\x8B\x08", 3) === 0) {
157
            return 'gzip';
158
        }
159
        // BZip2
160
        if (strncmp($head4, 'BZh', 3) === 0) {
161
            return 'bzip2';
162
        }
163
        // XZ
164
        if ($head6 === "\xFD7zXZ\x00") {
165
            return 'xz';
166
        }
167
        // PDF (skip)
168
        if ($head4 === '%PDF') {
169
            return 'pdf';
170
        }
171
172
        return null;
173
    }
174
175
    /**
176
     * Heuristic validation for 7z signature.
177
     */
178
    private function isLikely7z(string $data): bool
179
    {
180
        if (strlen($data) < 32) {
181
            return false;
182
        }
183
        $verMajor = ord($data[6]);
184
        $verMinor = ord($data[7]);
185
        if ($verMajor !== 0x00 || $verMinor < 0x02 || $verMinor > 0x09) {
186
            return false;
187
        }
188
        $crc = substr($data, 8, 4);
189
        if ($crc === "\x00\x00\x00\x00" || $crc === "\xFF\xFF\xFF\xFF") {
190
            return false;
191
        }
192
        return true;
193
    }
194
195
    /**
196
     * Process a 7z archive using external binary and internal header parsing.
197
     */
198
    private function processSevenZipArchive(
199
        string $compressedData,
200
        ReleaseProcessingContext $context,
0 ignored issues
show
Unused Code introduced by
The parameter $context is not used and could be removed. ( Ignorable by Annotation )

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

200
        /** @scrutinizer ignore-unused */ ReleaseProcessingContext $context,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
201
        string $tmpPath
202
    ): array {
203
        $result = [
204
            'success' => false,
205
            'files' => [],
206
            'hasPassword' => false,
207
            'passwordStatus' => Releases::PASSWD_NONE,
208
        ];
209
210
        if (! $this->config->sevenZipPath) {
211
            return $result;
212
        }
213
214
        // Try listing with external 7z binary
215
        $listed = $this->listSevenZipEntries($compressedData, $tmpPath);
216
        if (! empty($listed)) {
217
            if (! empty($listed[0]['__any_encrypted__'])) {
218
                return [
219
                    'success' => false,
220
                    'files' => [],
221
                    'hasPassword' => true,
222
                    'passwordStatus' => Releases::PASSWD_RAR,
223
                ];
224
            }
225
226
            $files = $this->filterSevenZipFiles($listed);
227
            if (! empty($files)) {
228
                return [
229
                    'success' => true,
230
                    'files' => $files,
231
                    'hasPassword' => false,
232
                    'passwordStatus' => Releases::PASSWD_NONE,
233
                    'archiveMarker' => '7z',
234
                ];
235
            }
236
        }
237
238
        // Fallback: scan for filenames in raw data
239
        $scannedNames = $this->scanSevenZipFilenames($compressedData);
240
        if (! empty($scannedNames)) {
241
            $files = array_map(fn($name) => [
242
                'name' => $name,
243
                'size' => 0,
244
                'date' => time(),
245
                'pass' => 0,
246
                'crc32' => '',
247
                'source' => '7z-scan',
248
            ], $scannedNames);
249
250
            return [
251
                'success' => true,
252
                'files' => $files,
253
                'hasPassword' => false,
254
                'passwordStatus' => Releases::PASSWD_NONE,
255
                'archiveMarker' => '7z',
256
            ];
257
        }
258
259
        return $result;
260
    }
261
262
    /**
263
     * List entries of a 7z archive using external 7z binary.
264
     */
265
    public function listSevenZipEntries(string $compressedData, string $tmpPath): array
266
    {
267
        if (! $this->config->sevenZipPath) {
268
            return [];
269
        }
270
271
        try {
272
            $tmpFile = $tmpPath.uniqid('7zlist_', true).'.7z';
273
            if (File::put($tmpFile, $compressedData) === false) {
274
                return [];
275
            }
276
277
            $cmd = [$this->config->sevenZipPath, 'l', '-slt', '-ba', '-bd', $tmpFile];
278
            $exitCode = 0;
279
            $stdout = null;
280
            $stderr = null;
281
            $ok = $this->execCommand($cmd, $exitCode, $stdout, $stderr);
282
283
            if (! $ok || $exitCode !== 0 || empty($stdout)) {
284
                // Try plain listing fallback
285
                $plainResult = $this->listSevenZipPlain($tmpFile);
286
                File::delete($tmpFile);
287
                return $plainResult;
288
            }
289
290
            File::delete($tmpFile);
291
            return $this->parseSevenZipStructuredOutput($stdout);
292
        } catch (\Throwable $e) {
293
            if ($this->config->debugMode) {
294
                Log::debug('Exception listing 7z: '.$e->getMessage());
295
            }
296
            return [];
297
        }
298
    }
299
300
    /**
301
     * Plain 7z listing fallback.
302
     */
303
    private function listSevenZipPlain(string $tmpFile): array
304
    {
305
        $cmd = [$this->config->sevenZipPath, 'l', '-ba', '-bd', $tmpFile];
306
        $exitCode = 0;
307
        $stdout = null;
308
        $stderr = null;
309
        $ok = $this->execCommand($cmd, $exitCode, $stdout, $stderr);
310
311
        if (! $ok || $exitCode !== 0 || empty($stdout)) {
312
            return [];
313
        }
314
315
        $files = [];
316
        $lines = preg_split('/\r?\n/', trim($stdout));
317
        foreach ($lines as $line) {
318
            $line = trim($line);
319
            if ($line === '' || str_starts_with($line, '-----')
320
                || str_contains($line, '   Date   ')
321
                || str_starts_with($line, 'Scanning ')
322
            ) {
323
                continue;
324
            }
325
326
            $name = null;
327
            if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+\S+\s+\d+\s+\d+\s+(\S.*)$/', $line, $m)) {
328
                $name = $m[1];
329
            } elseif (preg_match('/([A-Za-z0-9_#@()\[\]\-+&., ]+\.[A-Za-z0-9]{2,8})$/', $line, $m2)) {
330
                $name = trim($m2[1]);
331
            }
332
333
            if ($name && strlen($name) <= 300) {
334
                $files[] = ['name' => trim($name), 'size' => 0, 'encrypted' => false];
335
                if (count($files) >= 200) {
336
                    break;
337
                }
338
            }
339
        }
340
341
        return $files;
342
    }
343
344
    /**
345
     * Parse structured 7z output.
346
     */
347
    private function parseSevenZipStructuredOutput(string $output): array
348
    {
349
        $blocks = preg_split('/\n\n+/u', trim($output));
350
        $files = [];
351
        $anyEncrypted = false;
352
353
        foreach ($blocks as $block) {
354
            $lines = preg_split('/\r?\n/', trim($block));
355
            $row = [];
356
            foreach ($lines as $line) {
357
                $kv = explode(' = ', $line, 2);
358
                if (count($kv) === 2) {
359
                    $row[$kv[0]] = $kv[1];
360
                }
361
            }
362
363
            if (empty($row['Path'])) {
364
                continue;
365
            }
366
367
            $attr = $row['Attributes'] ?? '';
368
            if (str_contains($attr, 'D')) {
369
                continue; // directory
370
            }
371
372
            $encrypted = ($row['Encrypted'] ?? '') === '+';
373
            if ($encrypted) {
374
                $anyEncrypted = true;
375
            }
376
377
            $size = isset($row['Size']) && ctype_digit($row['Size']) ? (int) $row['Size'] : 0;
378
            $files[] = ['name' => $row['Path'], 'size' => $size, 'encrypted' => $encrypted];
379
380
            if (count($files) >= 200) {
381
                break;
382
            }
383
        }
384
385
        if ($anyEncrypted && isset($files[0])) {
386
            $files[0]['__any_encrypted__'] = true;
387
        }
388
389
        return $files;
390
    }
391
392
    /**
393
     * Filter 7z files using extension whitelist.
394
     */
395
    private function filterSevenZipFiles(array $files): array
396
    {
397
        $allowedExtensions = $this->getAllowedExtensions();
398
        $filtered = [];
399
400
        foreach ($files as $entry) {
401
            if (! empty($entry['__any_encrypted__'])) {
402
                continue;
403
            }
404
405
            $name = $entry['name'] ?? '';
406
            if ($name === '' || strlen($name) > 300) {
407
                continue;
408
            }
409
410
            $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($name, App\Serv...ing\PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() 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

410
            $ext = strtolower(/** @scrutinizer ignore-type */ pathinfo($name, PATHINFO_EXTENSION));
Loading history...
411
            if (! in_array($ext, $allowedExtensions, true)) {
412
                continue;
413
            }
414
415
            $base = pathinfo($name, PATHINFO_FILENAME);
416
            $letterCount = preg_match_all('/[a-z]/i', $base);
0 ignored issues
show
Bug introduced by
It seems like $base can also be of type array; however, parameter $subject of preg_match_all() 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

416
            $letterCount = preg_match_all('/[a-z]/i', /** @scrutinizer ignore-type */ $base);
Loading history...
417
            if ($letterCount <= 5) {
418
                continue;
419
            }
420
421
            $filtered[] = [
422
                'name' => $name,
423
                'size' => $entry['size'] ?? 0,
424
                'date' => time(),
425
                'pass' => 0,
426
                'crc32' => '',
427
                'source' => '7z-list',
428
            ];
429
430
            if (count($filtered) >= 50) {
431
                break;
432
            }
433
        }
434
435
        return $filtered;
436
    }
437
438
    /**
439
     * Scan for filenames in 7z raw data.
440
     */
441
    private function scanSevenZipFilenames(string $data): array
442
    {
443
        $slice = substr($data, 0, 8 * 1024 * 1024);
444
        $converted = preg_replace('/([\x20-\x7E])\x00/', '$1', $slice) ?? $slice;
445
        $converted = str_replace("\x00", ' ', $converted);
446
447
        $exts = '7z|rar|zip|gz|bz2|xz|tar|tgz|mp4|mkv|avi|mpg|mpeg|mov|ts|wmv|flv|m4v|mp3|flac|ogg|wav|aac|aiff|ape|mka|nfo|txt|diz|pdf|epub|mobi|jpg|jpeg|png|gif|sfv|par2|exe|dll|srt|sub|idx|iso|bin|cue|mds|mdf';
448
        $regex = '~(?:[A-Za-z0-9 _.+&@#,()!-]{0,120}[\\/])?([A-Za-z0-9 _.+&@#,()!-]{2,160}\.(?:'.$exts.'))~i';
449
        preg_match_all($regex, $converted, $matches, PREG_SET_ORDER);
450
451
        if (empty($matches)) {
452
            return [];
453
        }
454
455
        $names = [];
456
        foreach ($matches as $match) {
457
            $candidate = preg_replace('/ {2,}/', ' ', $match[1]);
458
            if ($candidate === '' || strlen($candidate) < 5 || substr_count($candidate, '.') > 10) {
459
                continue;
460
            }
461
            $candidate = trim($candidate, " .-\t\n\r");
462
            $lower = strtolower($candidate);
463
            if (! isset($names[$lower])) {
464
                $names[$lower] = $candidate;
465
                if (count($names) >= 80) {
466
                    break;
467
                }
468
            }
469
        }
470
471
        return array_values($names);
472
    }
473
474
    /**
475
     * Extract using 7zip binary.
476
     */
477
    public function extractViaSevenZip(string $compressedData, string $type, string $tmpPath): array
478
    {
479
        $result = [
480
            'success' => false,
481
            'files' => [],
482
            'hasPassword' => false,
483
            'passwordStatus' => Releases::PASSWD_NONE,
484
        ];
485
486
        if ($this->config->extractUsingRarInfo || ! $this->config->sevenZipPath) {
487
            return $result;
488
        }
489
490
        try {
491
            $extMap = ['7z' => '7z', 'gzip' => 'gz', 'bzip2' => 'bz2', 'xz' => 'xz'];
492
            $markerMap = ['7z' => '7z', 'gzip' => 'g', 'bzip2' => 'b', 'xz' => 'x'];
493
            $ext = $extMap[$type] ?? 'dat';
494
            $marker = $markerMap[$type] ?? $type;
495
496
            $extractDir = $tmpPath.'un7z/'.uniqid('', true).'/';
497
            if (! File::isDirectory($extractDir)) {
498
                File::makeDirectory($extractDir, 0777, true, true);
499
            }
500
501
            $fileName = $tmpPath.uniqid('', true).'.'.$ext;
502
            File::put($fileName, $compressedData);
503
504
            $cmd = [$this->config->sevenZipPath, 'e', '-y', '-bd', '-o'.$extractDir, $fileName];
505
            $exitCode = 0;
506
            $stdout = null;
507
            $stderr = null;
508
            $this->execCommand($cmd, $exitCode, $stdout, $stderr);
509
510
            $files = [];
511
            if (File::isDirectory($extractDir)) {
512
                foreach (File::allFiles($extractDir) as $f) {
513
                    $files[] = [
514
                        'name' => $f->getFilename(),
515
                        'size' => $f->getSize(),
516
                        'date' => time(),
517
                        'pass' => 0,
518
                        'crc32' => '',
519
                        'source' => $type,
520
                    ];
521
                }
522
            }
523
524
            File::delete($fileName);
525
526
            if (! empty($files)) {
527
                return [
528
                    'success' => true,
529
                    'files' => $this->filterExtractedFiles($files),
530
                    'hasPassword' => false,
531
                    'passwordStatus' => Releases::PASSWD_NONE,
532
                    'archiveMarker' => $marker,
533
                ];
534
            }
535
        } catch (\Throwable $e) {
536
            if ($this->config->debugMode) {
537
                Log::warning(strtoupper($type).' extraction exception: '.$e->getMessage());
538
            }
539
        }
540
541
        return $result;
542
    }
543
544
    /**
545
     * Filter extracted files by allowed extensions.
546
     */
547
    private function filterExtractedFiles(array $files): array
548
    {
549
        $allowedExtensions = $this->getAllowedExtensions();
550
        $filtered = [];
551
552
        foreach ($files as $file) {
553
            $name = $file['name'] ?? '';
554
            $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($name, App\Serv...ing\PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() 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

554
            $ext = strtolower(/** @scrutinizer ignore-type */ pathinfo($name, PATHINFO_EXTENSION));
Loading history...
555
556
            if (! in_array($ext, $allowedExtensions, true)) {
557
                continue;
558
            }
559
560
            $base = pathinfo($name, PATHINFO_FILENAME);
561
            $letterCount = preg_match_all('/[a-z]/i', $base);
0 ignored issues
show
Bug introduced by
It seems like $base can also be of type array; however, parameter $subject of preg_match_all() 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

561
            $letterCount = preg_match_all('/[a-z]/i', /** @scrutinizer ignore-type */ $base);
Loading history...
562
            if ($letterCount <= 5) {
563
                continue;
564
            }
565
566
            $filtered[] = $file;
567
        }
568
569
        return $filtered;
570
    }
571
572
    /**
573
     * Get list of allowed file extensions.
574
     */
575
    private function getAllowedExtensions(): array
576
    {
577
        return [
578
            // NFO and info files (prioritized for extraction)
579
            'nfo', 'diz', 'inf', 'txt',
580
            // Subtitles
581
            'srt', 'sub', 'idx', 'ass', 'ssa', 'vtt',
582
            // Video
583
            'mkv', 'mpeg', 'avi', 'mp4', 'm4v', 'mov', 'wmv', 'flv', 'ts', 'vob', 'm2ts', 'webm',
584
            // Audio
585
            'mp3', 'm4a', 'flac', 'ogg', 'aac', 'wav', 'wma', 'opus', 'ape',
586
            // Images
587
            'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp',
588
            // Documents
589
            'epub', 'pdf', 'cbz', 'cbr', 'djvu', 'mobi', 'azw', 'azw3',
590
            // Executables (for software releases)
591
            'exe', 'msi',
592
        ];
593
    }
594
595
    /**
596
     * Check if a file is an NFO or info file.
597
     *
598
     * @param string $filename The filename to check.
599
     * @return bool True if it's an NFO-like file.
600
     */
601
    public function isNfoFile(string $filename): bool
602
    {
603
        $basename = strtolower(basename($filename));
604
605
        // Standard NFO extensions
606
        if (preg_match('/\.(nfo|diz|inf)$/i', $basename)) {
607
            return true;
608
        }
609
610
        // Common NFO alternative names
611
        $nfoNames = [
612
            'file_id.diz', 'fileid.diz', 'file-id.diz',
613
            'readme.txt', 'readme.1st', 'read.me', 'readmenow.txt',
614
            'info.txt', 'information.txt', 'about.txt', 'notes.txt',
615
            'release.txt', 'release.nfo',
616
        ];
617
618
        if (in_array($basename, $nfoNames, true)) {
619
            return true;
620
        }
621
622
        // Scene-style NFO naming: 00-groupname.nfo, group-release.nfo
623
        if (preg_match('/^(?:00?-[a-z0-9_-]+|[a-z0-9]+-[a-z0-9._-]+)\.(?:nfo|txt)$/i', $basename)) {
624
            return true;
625
        }
626
627
        return false;
628
    }
629
630
    /**
631
     * Sort files to prioritize NFO files for processing.
632
     *
633
     * @param array $files Array of file info arrays.
634
     * @return array Sorted array with NFO files first.
635
     */
636
    public function sortFilesWithNfoPriority(array $files): array
637
    {
638
        usort($files, function ($a, $b) {
639
            $aIsNfo = $this->isNfoFile($a['name'] ?? '');
640
            $bIsNfo = $this->isNfoFile($b['name'] ?? '');
641
642
            if ($aIsNfo && ! $bIsNfo) {
643
                return -1;
644
            }
645
            if (! $aIsNfo && $bIsNfo) {
646
                return 1;
647
            }
648
649
            return 0;
650
        });
651
652
        return $files;
653
    }
654
655
    /**
656
     * Prepare extraction directories.
657
     */
658
    private function prepareExtractionDirectories(string $tmpPath): void
659
    {
660
        if ($this->config->extractUsingRarInfo) {
661
            return;
662
        }
663
664
        try {
665
            if ($this->config->unrarPath) {
666
                $unrarDir = $tmpPath.'unrar/';
667
                if (! File::isDirectory($unrarDir)) {
668
                    File::makeDirectory($unrarDir, 0777, true, true);
669
                }
670
            }
671
            $unzipDir = $tmpPath.'unzip/';
672
            if (! File::isDirectory($unzipDir)) {
673
                File::makeDirectory($unzipDir, 0777, true, true);
674
            }
675
        } catch (\Throwable $e) {
676
            if ($this->config->debugMode) {
677
                Log::warning('Failed ensuring extraction subdirectories: '.$e->getMessage());
678
            }
679
        }
680
    }
681
682
    /**
683
     * Extract archive based on type.
684
     */
685
    private function extractArchive(string $compressedData, array $dataSummary, string $tmpPath): string
686
    {
687
        $killString = $this->config->getKillString();
688
689
        switch ($dataSummary['main_type']) {
690
            case ArchiveInfo::TYPE_RAR:
691
                if (! $this->config->extractUsingRarInfo && $this->config->unrarPath) {
692
                    $fileName = $tmpPath.uniqid('', true).'.rar';
693
                    File::put($fileName, $compressedData);
694
                    runCmd($killString.$this->config->unrarPath.'" e -ai -ep -c- -id -inul -kb -or -p- -r -y "'.$fileName.'" "'.$tmpPath.'unrar/"');
695
                    File::delete($fileName);
696
                }
697
                return 'r';
698
699
            case ArchiveInfo::TYPE_ZIP:
700
                if (! $this->config->extractUsingRarInfo && $this->config->unzipPath) {
701
                    $fileName = $tmpPath.uniqid('', true).'.zip';
702
                    File::put($fileName, $compressedData);
703
                    runCmd($this->config->unzipPath.' -o "'.$fileName.'" -d "'.$tmpPath.'unzip/"');
704
                    File::delete($fileName);
705
                }
706
                return 'z';
707
        }
708
709
        return '';
710
    }
711
712
    /**
713
     * Detect standalone video from binary data.
714
     */
715
    public function detectStandaloneVideo(string $data): ?string
716
    {
717
        $len = strlen($data);
718
        if ($len < 16) {
719
            return null;
720
        }
721
722
        // AVI
723
        if (strncmp($data, 'RIFF', 4) === 0 && substr($data, 8, 4) === 'AVI ') {
724
            return 'avi';
725
        }
726
        // Matroska / WebM
727
        if (strncmp($data, "\x1A\x45\xDF\xA3", 4) === 0) {
728
            return 'mkv';
729
        }
730
        // MPEG
731
        $sig4 = substr($data, 0, 4);
732
        if ($sig4 === "\x00\x00\x01\xBA" || $sig4 === "\x00\x00\x01\xB3") {
733
            return 'mpg';
734
        }
735
        // Transport Stream
736
        if ($len >= 188 * 5) {
737
            $isTs = true;
738
            for ($i = 0; $i < 5; $i++) {
739
                if (! isset($data[188 * $i]) || $data[188 * $i] !== "\x47") {
740
                    $isTs = false;
741
                    break;
742
                }
743
            }
744
            if ($isTs) {
745
                return 'mpg';
746
            }
747
        }
748
        // MP4/MOV
749
        if ($len >= 12 && substr($data, 4, 4) === 'ftyp') {
750
            $brands = ['isom', 'iso2', 'avc1', 'mp41', 'mp42', 'dash', 'MSNV', 'qt  ', 'M4V ', 'M4P ', 'M4B ', 'M4A '];
751
            if (in_array(substr($data, 8, 4), $brands, true)) {
752
                return 'mp4';
753
            }
754
        }
755
756
        return null;
757
    }
758
759
    /**
760
     * Get PAR2 info parser.
761
     */
762
    public function getPar2Info(): Par2Info
763
    {
764
        return $this->par2Info;
765
    }
766
767
    /**
768
     * Get archive info handler.
769
     */
770
    public function getArchiveInfo(): ArchiveInfo
771
    {
772
        return $this->archiveInfo;
773
    }
774
775
    /**
776
     * Execute a command with output capture.
777
     */
778
    private function execCommand(array $cmd, ?int &$exitCode, ?string &$stdout, ?string &$stderr): bool
779
    {
780
        $descriptorSpec = [
781
            1 => ['pipe', 'w'],
782
            2 => ['pipe', 'w'],
783
        ];
784
785
        $process = @proc_open($cmd, $descriptorSpec, $pipes, null, null, ['bypass_shell' => true]);
786
        if (! is_resource($process)) {
787
            $exitCode = -1;
788
            return false;
789
        }
790
791
        $stdout = stream_get_contents($pipes[1]);
792
        fclose($pipes[1]);
793
        $stderr = stream_get_contents($pipes[2]);
794
        fclose($pipes[2]);
795
        $exitCode = proc_close($process);
796
797
        return $exitCode === 0;
798
    }
799
}
800
801