Issues (868)

AdditionalProcessing/MediaExtractionService.php (19 issues)

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

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

149
                        /** @scrutinizer ignore-call */ 
150
                        $clip = $video->clip(
Loading history...
150
                            TimeCode::fromString($lowestLength),
151
                            TimeCode::fromSeconds($this->config->ffmpegDuration)
152
                        );
153
                        $format = new Ogg();
154
                        $format->setAudioCodec('libvorbis');
155
                        $clip->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT);
156
                        $clip->save($format, $fileName);
157
                    }
158
                } catch (\Throwable $e) {
159
                    if ($this->config->debugMode) {
160
                        Log::error($e->getTraceAsString());
161
                    }
162
                }
163
            }
164
        }
165
166
        // Fallback: use start of video
167
        if (! $newMethod) {
168
            try {
169
                if ($this->ffprobe()->isValid($fileLocation)) {
170
                    $video = $this->ffmpeg()->open($fileLocation);
171
                    $clip = $video->clip(
172
                        TimeCode::fromSeconds(0),
173
                        TimeCode::fromSeconds($this->config->ffmpegDuration)
174
                    );
175
                    $format = new Ogg();
176
                    $format->setAudioCodec('libvorbis');
177
                    $clip->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT);
178
                    $clip->save($format, $fileName);
179
                }
180
            } catch (\Throwable $e) {
181
                if ($this->config->debugMode) {
182
                    Log::error($e->getTraceAsString());
183
                }
184
            }
185
        }
186
187
        if (! File::isFile($fileName)) {
188
            return false;
189
        }
190
191
        $newFile = $this->releaseImage->vidSavePath.$guid.'.ogv';
192
193
        if (! @File::move($fileName, $newFile)) {
194
            $copied = @File::copy($fileName, $newFile);
195
            File::delete($fileName);
196
            if (! $copied) {
197
                return false;
198
            }
199
        }
200
201
        @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

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

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

465
            $this->mediaInfo->/** @scrutinizer ignore-call */ 
466
                              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...
466
            if ($this->config->mediaInfoPath) {
467
                $this->mediaInfo->setConfig('command', $this->config->mediaInfoPath);
468
            }
469
        }
470
        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...
471
    }
472
473
    private function manticore(): ManticoreSearchService
474
    {
475
        if ($this->manticore === null) {
476
            $this->manticore = app(ManticoreSearchService::class);
477
        }
478
        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 App\Services\Search\ManticoreSearchService. Consider adding an additional type-check to rule them out.
Loading history...
479
    }
480
481
    private function elasticsearch(): ElasticSearchService
482
    {
483
        if ($this->elasticsearch === null) {
484
            $this->elasticsearch = app(ElasticSearchService::class);
485
        }
486
        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 App\Services\Search\ElasticSearchService. Consider adding an additional type-check to rule them out.
Loading history...
487
    }
488
}
489
490