MediaExtractionService   F
last analyzed

Complexity

Total Complexity 88

Size/Duplication

Total Lines 458
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 88
eloc 248
dl 0
loc 458
rs 2
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A getJPGSample() 0 16 2
B getVideoTime() 0 26 8
A ffmpeg() 0 7 3
A ffprobe() 0 6 2
F getAudioInfo() 0 139 26
B getSample() 0 37 8
B processVideoFile() 0 34 8
A mediaInfo() 0 10 3
F getVideo() 0 85 17
A getMediaInfo() 0 14 4
A manticore() 0 6 2
A elasticsearch() 0 6 2
A isJpegData() 0 6 2

How to fix   Complexity   

Complex Class

Complex classes like MediaExtractionService 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 MediaExtractionService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace App\Services\AdditionalProcessing;
4
5
use App\Models\Category;
6
use App\Models\Release;
7
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...
8
use App\Services\AdditionalProcessing\DTO\ReleaseProcessingContext;
9
use App\Services\Categorization\CategorizationService;
10
use Blacklight\ElasticSearchSiteSearch;
11
use Blacklight\ManticoreSearch;
12
use Blacklight\NameFixer;
13
use Blacklight\ReleaseExtra;
14
use Blacklight\ReleaseImage;
15
use FFMpeg\Coordinate\Dimension;
16
use FFMpeg\Coordinate\TimeCode;
17
use FFMpeg\FFMpeg;
18
use FFMpeg\FFProbe;
19
use FFMpeg\Filters\Video\ResizeFilter;
20
use FFMpeg\Format\Audio\Vorbis;
21
use FFMpeg\Format\Video\Ogg;
22
use Illuminate\Support\Facades\File;
23
use Illuminate\Support\Facades\Log;
24
use Mhor\MediaInfo\MediaInfo;
25
26
/**
27
 * Service for processing media files (video, audio, images).
28
 * Handles sample generation, thumbnails, media info extraction, and audio processing.
29
 */
30
class MediaExtractionService
31
{
32
    private ?FFMpeg $ffmpeg = null;
33
    private ?FFProbe $ffprobe = null;
34
    private ?MediaInfo $mediaInfo = null;
35
    private ?ManticoreSearch $manticore = null;
36
    private ?ElasticSearchSiteSearch $elasticsearch = null;
37
38
    public function __construct(
39
        private readonly ProcessingConfiguration $config,
40
        private readonly ReleaseImage $releaseImage,
41
        private readonly ReleaseExtra $releaseExtra,
42
        private readonly CategorizationService $categorize
43
    ) {}
44
45
    /**
46
     * Get video time code for sample extraction.
47
     */
48
    public function getVideoTime(string $videoLocation): string
49
    {
50
        try {
51
            if (! $this->ffprobe()->isValid($videoLocation)) {
52
                return '';
53
            }
54
            $time = $this->ffprobe()->format($videoLocation)->get('duration');
55
        } catch (\Throwable $e) {
56
            if ($this->config->debugMode) {
57
                Log::debug($e->getMessage());
58
            }
59
            return '';
60
        }
61
62
        if (empty($time) || ! preg_match('/time=(\d{1,2}:\d{1,2}:)?(\d{1,2})\.(\d{1,2})\s*bitrate=/i', $time, $numbers)) {
63
            return '';
64
        }
65
66
        if ($numbers[3] > 0) {
67
            $numbers[3]--;
68
        } elseif ($numbers[1] > 0) {
69
            $numbers[2]--;
70
            $numbers[3] = '99';
71
        }
72
73
        return '00:00:'.str_pad($numbers[2], 2, '0', STR_PAD_LEFT).'.'.str_pad($numbers[3], 2, '0', STR_PAD_LEFT);
74
    }
75
76
    /**
77
     * Extract a sample image from a video file.
78
     */
79
    public function getSample(string $fileLocation, string $tmpPath, string $guid): bool
80
    {
81
        if (! $this->config->processThumbnails || ! File::isFile($fileLocation)) {
82
            return false;
83
        }
84
85
        $fileName = $tmpPath.'zzzz'.random_int(5, 12).random_int(5, 12).'.jpg';
86
        $time = $this->getVideoTime($fileLocation);
87
88
        try {
89
            if ($this->ffprobe()->isValid($fileLocation)) {
90
                $this->ffmpeg()->open($fileLocation)
91
                    ->frame(TimeCode::fromString($time === '' ? '00:00:03:00' : $time))
0 ignored issues
show
Bug introduced by
The method frame() does not exist on FFMpeg\Media\Audio. It seems like you code against a sub-type of FFMpeg\Media\Audio such as FFMpeg\Media\Video. ( Ignorable by Annotation )

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

91
                    ->/** @scrutinizer ignore-call */ frame(TimeCode::fromString($time === '' ? '00:00:03:00' : $time))
Loading history...
92
                    ->save($fileName);
93
            }
94
        } catch (\Throwable $e) {
95
            if ($this->config->debugMode) {
96
                Log::error($e->getTraceAsString());
97
            }
98
            return false;
99
        }
100
101
        if (! File::isFile($fileName)) {
102
            return false;
103
        }
104
105
        $saved = $this->releaseImage->saveImage(
106
            $guid.'_thumb',
107
            $fileName,
108
            $this->releaseImage->imgSavePath,
109
            800,
110
            600
111
        );
112
113
        File::delete($fileName);
114
115
        return $saved === 1;
116
    }
117
118
    /**
119
     * Create a video sample clip.
120
     */
121
    public function getVideo(string $fileLocation, string $tmpPath, string $guid): bool
122
    {
123
        if (! $this->config->processVideo || ! File::isFile($fileLocation)) {
124
            return false;
125
        }
126
127
        $fileName = $tmpPath.'zzzz'.$guid.'.ogv';
128
        $newMethod = false;
129
130
        // Try to get sample from end of video if duration is short
131
        if ($this->config->ffmpegDuration < 60) {
132
            $time = $this->getVideoTime($fileLocation);
133
            if ($time !== '' && preg_match('/(\d{2}).(\d{2})/', $time, $numbers)) {
134
                $newMethod = true;
135
                if ($numbers[1] <= $this->config->ffmpegDuration) {
136
                    $lowestLength = '00:00:00.00';
137
                } else {
138
                    $lowestLength = ($numbers[1] - $this->config->ffmpegDuration);
139
                    $end = '.'.$numbers[2];
140
                    $lowestLength = match (strlen($lowestLength)) {
141
                        1 => '00:00:0'.$lowestLength.$end,
142
                        2 => '00:00:'.$lowestLength.$end,
143
                        default => '00:00:60.00',
144
                    };
145
                }
146
147
                try {
148
                    if ($this->ffprobe()->isValid($fileLocation)) {
149
                        $video = $this->ffmpeg()->open($fileLocation);
150
                        $clip = $video->clip(
0 ignored issues
show
Bug introduced by
The method clip() does not exist on FFMpeg\Media\Audio. It seems like you code against a sub-type of FFMpeg\Media\Audio such as FFMpeg\Media\Video. ( Ignorable by Annotation )

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

150
                        /** @scrutinizer ignore-call */ 
151
                        $clip = $video->clip(
Loading history...
151
                            TimeCode::fromString($lowestLength),
152
                            TimeCode::fromSeconds($this->config->ffmpegDuration)
153
                        );
154
                        $format = new Ogg();
155
                        $format->setAudioCodec('libvorbis');
156
                        $clip->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT);
157
                        $clip->save($format, $fileName);
158
                    }
159
                } catch (\Throwable $e) {
160
                    if ($this->config->debugMode) {
161
                        Log::error($e->getTraceAsString());
162
                    }
163
                }
164
            }
165
        }
166
167
        // Fallback: use start of video
168
        if (! $newMethod) {
169
            try {
170
                if ($this->ffprobe()->isValid($fileLocation)) {
171
                    $video = $this->ffmpeg()->open($fileLocation);
172
                    $clip = $video->clip(
173
                        TimeCode::fromSeconds(0),
174
                        TimeCode::fromSeconds($this->config->ffmpegDuration)
175
                    );
176
                    $format = new Ogg();
177
                    $format->setAudioCodec('libvorbis');
178
                    $clip->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT);
179
                    $clip->save($format, $fileName);
180
                }
181
            } catch (\Throwable $e) {
182
                if ($this->config->debugMode) {
183
                    Log::error($e->getTraceAsString());
184
                }
185
            }
186
        }
187
188
        if (! File::isFile($fileName)) {
189
            return false;
190
        }
191
192
        $newFile = $this->releaseImage->vidSavePath.$guid.'.ogv';
193
194
        if (! @File::move($fileName, $newFile)) {
195
            $copied = @File::copy($fileName, $newFile);
196
            File::delete($fileName);
197
            if (! $copied) {
198
                return false;
199
            }
200
        }
201
202
        @chmod($newFile, 0764);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

202
        /** @scrutinizer ignore-unhandled */ @chmod($newFile, 0764);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
203
        Release::query()->where('guid', $guid)->update(['videostatus' => 1]);
204
205
        return true;
206
    }
207
208
    /**
209
     * Extract media info from a video file.
210
     */
211
    public function getMediaInfo(string $fileLocation, int $releaseId): bool
212
    {
213
        if (! $this->config->processMediaInfo || ! File::isFile($fileLocation)) {
214
            return false;
215
        }
216
217
        try {
218
            $xmlArray = $this->mediaInfo()->getInfo($fileLocation, true);
219
            \App\Models\MediaInfo::addData($releaseId, $xmlArray);
220
            $this->releaseExtra->addFromXml($releaseId, $xmlArray);
221
            return true;
222
        } catch (\Throwable $e) {
223
            Log::debug($e->getMessage());
224
            return false;
225
        }
226
    }
227
228
    /**
229
     * Process a JPG sample image.
230
     */
231
    public function getJPGSample(string $fileLocation, string $guid): bool
232
    {
233
        $saved = $this->releaseImage->saveImage(
234
            $guid.'_thumb',
235
            $fileLocation,
236
            $this->releaseImage->jpgSavePath,
237
            650,
238
            650
239
        );
240
241
        if ($saved === 1) {
242
            Release::query()->where('guid', $guid)->update(['jpgstatus' => 1]);
243
            return true;
244
        }
245
246
        return false;
247
    }
248
249
    /**
250
     * Process audio file for media info and sample.
251
     *
252
     * @return array{audioInfo: bool, audioSample: bool}
253
     */
254
    public function getAudioInfo(
255
        string $fileLocation,
256
        string $fileExtension,
257
        ReleaseProcessingContext $context,
258
        string $tmpPath
259
    ): array {
260
        $result = ['audioInfo' => false, 'audioSample' => false];
261
262
        if (! $this->config->processAudioSample) {
263
            $result['audioSample'] = true;
264
        }
265
        if (! $this->config->processAudioInfo) {
266
            $result['audioInfo'] = true;
267
        }
268
269
        $rQuery = Release::query()
270
            ->where('proc_pp', '=', 0)
271
            ->where('id', $context->release->id)
272
            ->select(['searchname', 'fromname', 'categories_id', 'groups_id'])
273
            ->first();
274
275
        $musicParent = (string) Category::MUSIC_ROOT;
276
        if ($rQuery === null || ! preg_match(
277
            sprintf(
278
                '/%d\d{3}|%d|%d|%d/',
279
                $musicParent[0],
280
                Category::OTHER_MISC,
281
                Category::MOVIE_OTHER,
282
                Category::TV_OTHER
283
            ),
284
            $rQuery->categories_id
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...
285
        )) {
286
            return $result;
287
        }
288
289
        if (! File::isFile($fileLocation)) {
290
            return $result;
291
        }
292
293
        // Get media info
294
        if (! $result['audioInfo']) {
295
            try {
296
                $xmlArray = $this->mediaInfo()->getInfo($fileLocation, false);
297
                if ($xmlArray !== null) {
298
                    foreach ($xmlArray->getAudios() as $track) {
299
                        if ($track->get('album') !== null && $track->get('performer') !== null) {
300
                            if ((int) $context->release->predb_id === 0 && $this->config->renameMusicMediaInfo) {
0 ignored issues
show
Bug introduced by
The property predb_id does not exist on App\Models\Release. Did you mean predb?
Loading history...
301
                                $ext = strtoupper($fileExtension);
302
303
                                $newName = $track->get('performer')->getFullName().' - '.$track->get('album')->getFullName();
304
                                if (! empty($track->get('recorded_date'))
305
                                    && preg_match('/(?:19|20)\d\d/', $track->get('recorded_date')->getFullname, $Year)
306
                                ) {
307
                                    $newName .= ' ('.$Year[0].') '.$ext;
308
                                } else {
309
                                    $newName .= ' '.$ext;
310
                                }
311
312
                                $newCat = match ($ext) {
313
                                    'MP3' => Category::MUSIC_MP3,
314
                                    'FLAC' => Category::MUSIC_LOSSLESS,
315
                                    default => $this->categorize->determineCategory($rQuery->groups_id, $newName, $rQuery->fromname),
0 ignored issues
show
Bug introduced by
The property fromname 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...
Bug introduced by
The property groups_id does not exist on App\Models\Release. Did you mean group?
Loading history...
316
                                };
317
318
                                $newTitle = escapeString(substr($newName, 0, 255));
319
                                Release::whereId($context->release->id)->update([
320
                                    'searchname' => $newTitle,
321
                                    'categories_id' => is_array($newCat) ? $newCat['categories_id'] : $newCat,
322
                                    'iscategorized' => 1,
323
                                    'isrenamed' => 1,
324
                                    'proc_pp' => 1,
325
                                ]);
326
327
                                if ($this->config->elasticsearchEnabled) {
328
                                    $this->elasticsearch()->updateRelease($context->release->id);
329
                                } else {
330
                                    $this->manticore()->updateRelease($context->release->id);
331
                                }
332
333
                                if ($this->config->echoCLI) {
334
                                    NameFixer::echoChangedReleaseName([
335
                                        'new_name' => $newTitle,
336
                                        'old_name' => $rQuery->searchname,
0 ignored issues
show
Bug introduced by
The property searchname 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...
337
                                        'new_category' => $newCat,
338
                                        'old_category' => $rQuery->categories_id,
339
                                        'group' => $rQuery->groups_id,
340
                                        'releases_id' => $context->release->id,
341
                                        'method' => 'MediaExtractionService->getAudioInfo',
342
                                    ]);
343
                                }
344
                            }
345
346
                            $this->releaseExtra->addFromXml($context->release->id, $xmlArray);
347
                            $result['audioInfo'] = true;
348
                            $context->foundAudioInfo = true;
349
                            break;
350
                        }
351
                    }
352
                }
353
            } catch (\Throwable $e) {
354
                Log::debug($e->getMessage());
355
            }
356
        }
357
358
        // Create audio sample
359
        if (! $result['audioSample']) {
360
            $audioFileName = $context->release->guid.'.ogg';
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...
361
362
            try {
363
                if ($this->ffprobe()->isValid($fileLocation)) {
364
                    $audioSample = $this->ffmpeg()->open($fileLocation);
365
                    $format = new Vorbis();
366
                    $audioSample->clip(TimeCode::fromSeconds(30), TimeCode::fromSeconds(30));
367
                    $audioSample->save($format, $tmpPath.$audioFileName);
368
                }
369
            } catch (\Throwable $e) {
370
                if ($this->config->debugMode) {
371
                    Log::error($e->getTraceAsString());
372
                }
373
            }
374
375
            if (File::isFile($tmpPath.$audioFileName)) {
376
                $renamed = File::move($tmpPath.$audioFileName, $this->config->audioSavePath.$audioFileName);
377
                if (! $renamed) {
378
                    $copied = File::copy($tmpPath.$audioFileName, $this->config->audioSavePath.$audioFileName);
379
                    File::delete($tmpPath.$audioFileName);
380
                    if (! $copied) {
381
                        return $result;
382
                    }
383
                }
384
385
                @chmod($this->config->audioSavePath.$audioFileName, 0764);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

385
                /** @scrutinizer ignore-unhandled */ @chmod($this->config->audioSavePath.$audioFileName, 0764);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
386
                Release::query()->where('id', $context->release->id)->update(['audiostatus' => 1]);
387
                $result['audioSample'] = true;
388
                $context->foundAudioSample = true;
389
            }
390
        }
391
392
        return $result;
393
    }
394
395
    /**
396
     * Process a video file for sample, video clip, and media info.
397
     */
398
    public function processVideoFile(
399
        string $fileLocation,
400
        ReleaseProcessingContext $context,
401
        string $tmpPath
402
    ): array {
403
        $result = [
404
            'sample' => false,
405
            'video' => false,
406
            'mediaInfo' => false,
407
        ];
408
409
        if (! $context->foundSample) {
410
            $result['sample'] = $this->getSample($fileLocation, $tmpPath, $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...
411
            if ($result['sample']) {
412
                $context->foundSample = true;
413
            }
414
        }
415
416
        // Only get video if sampleMessageIDs count is less than 2
417
        if (! $context->foundVideo && count($context->sampleMessageIDs) < 2) {
418
            $result['video'] = $this->getVideo($fileLocation, $tmpPath, $context->release->guid);
419
            if ($result['video']) {
420
                $context->foundVideo = true;
421
            }
422
        }
423
424
        if (! $context->foundMediaInfo) {
425
            $result['mediaInfo'] = $this->getMediaInfo($fileLocation, $context->release->id);
426
            if ($result['mediaInfo']) {
427
                $context->foundMediaInfo = true;
428
            }
429
        }
430
431
        return $result;
432
    }
433
434
    /**
435
     * Check if data appears to be a JPEG image.
436
     */
437
    public function isJpegData(string $filePath): bool
438
    {
439
        if (! File::isFile($filePath)) {
440
            return false;
441
        }
442
        return exif_imagetype($filePath) === IMAGETYPE_JPEG;
443
    }
444
445
    private function ffmpeg(): FFMpeg
446
    {
447
        if ($this->ffmpeg === null) {
448
            $timeout = $this->config->timeoutSeconds > 0 ? $this->config->timeoutSeconds : 60;
449
            $this->ffmpeg = FFMpeg::create(['timeout' => $timeout]);
450
        }
451
        return $this->ffmpeg;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->ffmpeg could return the type null which is incompatible with the type-hinted return FFMpeg\FFMpeg. Consider adding an additional type-check to rule them out.
Loading history...
452
    }
453
454
    private function ffprobe(): FFProbe
455
    {
456
        if ($this->ffprobe === null) {
457
            $this->ffprobe = FFProbe::create();
458
        }
459
        return $this->ffprobe;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->ffprobe could return the type null which is incompatible with the type-hinted return FFMpeg\FFProbe. Consider adding an additional type-check to rule them out.
Loading history...
460
    }
461
462
    private function mediaInfo(): MediaInfo
463
    {
464
        if ($this->mediaInfo === null) {
465
            $this->mediaInfo = new MediaInfo();
466
            $this->mediaInfo->setConfig('use_oldxml_mediainfo_output_format', true);
0 ignored issues
show
Bug introduced by
The method setConfig() does not exist on null. ( Ignorable by Annotation )

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

466
            $this->mediaInfo->/** @scrutinizer ignore-call */ 
467
                              setConfig('use_oldxml_mediainfo_output_format', true);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
467
            if ($this->config->mediaInfoPath) {
468
                $this->mediaInfo->setConfig('command', $this->config->mediaInfoPath);
469
            }
470
        }
471
        return $this->mediaInfo;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->mediaInfo could return the type null which is incompatible with the type-hinted return Mhor\MediaInfo\MediaInfo. Consider adding an additional type-check to rule them out.
Loading history...
472
    }
473
474
    private function manticore(): ManticoreSearch
475
    {
476
        if ($this->manticore === null) {
477
            $this->manticore = new ManticoreSearch();
478
        }
479
        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...
480
    }
481
482
    private function elasticsearch(): ElasticSearchSiteSearch
483
    {
484
        if ($this->elasticsearch === null) {
485
            $this->elasticsearch = new ElasticSearchSiteSearch();
486
        }
487
        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...
488
    }
489
}
490
491