Passed
Push — master ( 81b9b0...8c7e20 )
by Sébastien
02:17
created

VideoInfoReader   A

Complexity

Total Complexity 15

Size/Duplication

Total Lines 121
Duplicated Lines 0 %

Test Coverage

Coverage 93.44%

Importance

Changes 0
Metric Value
wmc 15
eloc 65
dl 0
loc 121
c 0
b 0
f 0
ccs 57
cts 61
cp 0.9344
rs 10

4 Methods

Rating   Name   Duplication   Size   Complexity  
A getCacheKey() 0 5 1
C getInfo() 0 57 12
A getSymfonyProcess() 0 17 1
A __construct() 0 5 1
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @see       https://github.com/soluble-io/soluble-mediatools for the canonical repository
7
 *
8
 * @copyright Copyright (c) 2018-2019 Sébastien Vanvelthem. (https://github.com/belgattitude)
9
 * @license   https://github.com/soluble-io/soluble-mediatools/blob/master/LICENSE.md MIT
10
 */
11
12
namespace Soluble\MediaTools\Video;
13
14
use Psr\Log\LoggerInterface;
15
use Psr\Log\LogLevel;
16
use Psr\Log\NullLogger;
17
use Psr\SimpleCache\CacheInterface;
18
use Soluble\MediaTools\Common\Assert\PathAssertionsTrait;
19
use Soluble\MediaTools\Common\Cache\NullCache;
20
use Soluble\MediaTools\Common\Exception\FileEmptyException;
21
use Soluble\MediaTools\Common\Exception\FileNotFoundException;
22
use Soluble\MediaTools\Common\Exception\FileNotReadableException;
23
use Soluble\MediaTools\Common\Exception\JsonParseException;
24
use Soluble\MediaTools\Common\Process\ProcessFactory;
25
use Soluble\MediaTools\Common\Process\ProcessParamsInterface;
26
use Soluble\MediaTools\Video\Config\FFProbeConfigInterface;
27
use Soluble\MediaTools\Video\Exception\InfoProcessReaderExceptionInterface;
28
use Soluble\MediaTools\Video\Exception\InfoReaderExceptionInterface;
29
use Soluble\MediaTools\Video\Exception\InvalidFFProbeJsonException;
30
use Soluble\MediaTools\Video\Exception\MissingFFProbeBinaryException;
31
use Soluble\MediaTools\Video\Exception\MissingInputFileException;
32
use Soluble\MediaTools\Video\Exception\ProcessFailedException;
33
use Soluble\MediaTools\Video\Exception\RuntimeReaderException;
34
use Symfony\Component\Process\Exception as SPException;
35
use Symfony\Component\Process\Process;
36
37
class VideoInfoReader implements VideoInfoReaderInterface
38
{
39
    use PathAssertionsTrait;
40
41
    /** @var FFProbeConfigInterface */
42
    private $ffprobeConfig;
43
44
    /** @var LoggerInterface */
45
    private $logger;
46
47
    /**
48
     * @var CacheInterface
49
     */
50
    private $cache;
51
52 30
    public function __construct(FFProbeConfigInterface $ffProbeConfig, ?LoggerInterface $logger = null, ?CacheInterface $cache = null)
53
    {
54 30
        $this->ffprobeConfig = $ffProbeConfig;
55 30
        $this->logger        = $logger ?? new NullLogger();
56 30
        $this->cache         = $cache ?? new NullCache();
57 30
    }
58
59
    /**
60
     * Return ready-to-run symfony process object that you can use
61
     * to `run()` or `start()` programmatically. Useful if you want to make
62
     * things your way...
63
     *
64
     * @see https://symfony.com/doc/current/components/process.html
65
     */
66 12
    public function getSymfonyProcess(string $inputFile, ?ProcessParamsInterface $processParams = null): Process
67
    {
68
        $ffprobeCmd = [
69 12
            $this->ffprobeConfig->getBinary(),
70 12
            '-v',
71 12
            'quiet',
72 12
            '-print_format',
73 12
            'json',
74 12
            '-show_format',
75 12
            '-show_streams',
76 12
            '-i',
77 12
            $inputFile,
78
        ];
79
80 12
        $pp = $processParams ?? $this->ffprobeConfig->getProcessParams();
81
82 12
        return (new ProcessFactory($ffprobeCmd, $pp))();
83
    }
84
85
    /**
86
     * @throws InfoReaderExceptionInterface
87
     * @throws InfoProcessReaderExceptionInterface
88
     * @throws ProcessFailedException
89
     * @throws InvalidFFProbeJsonException
90
     * @throws MissingInputFileException
91
     * @throws MissingFFProbeBinaryException
92
     * @throws RuntimeReaderException
93
     */
94 14
    public function getInfo(string $file, ?CacheInterface $cache = null): VideoInfo
95
    {
96 14
        $cache = $cache ?? $this->cache;
97
98
        try {
99
            try {
100 14
                $this->ensureFileReadable($file, true);
101
102 12
                $process = $this->getSymfonyProcess($file);
103
104 12
                $key = $this->getCacheKey($process, $file);
105
                try {
106 12
                    $output = $cache->get($key, null);
107 12
                    if ($output === null) {
108 12
                        throw new JsonParseException('Json not in cache');
109
                    }
110 2
                    $videoInfo = VideoInfo::createFromFFProbeJson($file, $output, $this->logger);
111 12
                } catch (JsonParseException $e) {
112
                    // cache failure or corrupted, let's fallback to running ffprobe
113 12
                    $cache->set($key, null);
114 12
                    $process->mustRun();
115 10
                    $output    = $process->getOutput();
116 10
                    $videoInfo = VideoInfo::createFromFFProbeJson($file, $output, $this->logger);
117 10
                    $cache->set($key, $output);
118
                }
119 4
            } catch (FileNotFoundException | FileNotReadableException | FileEmptyException $e) {
120 2
                throw new MissingInputFileException($e->getMessage());
121 2
            } catch (JsonParseException $e) {
122
                throw new InvalidFFProbeJsonException($e->getMessage());
123 2
            } catch (SPException\ProcessFailedException $e) {
124 2
                $process = $e->getProcess();
125 2
                if ($process->getExitCode() === 127 ||
126 2
                    mb_strpos(mb_strtolower($process->getExitCodeText()), 'command not found') !== false) {
127 1
                    throw new MissingFFProbeBinaryException($process, $e);
128
                }
129 1
                throw new ProcessFailedException($process, $e);
130
            } catch (SPException\ProcessTimedOutException | SPException\ProcessSignaledException $e) {
131
                throw new ProcessFailedException($e->getProcess(), $e);
132
            } catch (SPException\RuntimeException $e) {
133 10
                throw new RuntimeReaderException($e->getMessage());
134
            }
135 4
        } catch (\Throwable $e) {
136 4
            $exceptionNs = explode('\\', get_class($e));
137 4
            $this->logger->log(
138 4
                ($e instanceof MissingInputFileException) ? LogLevel::WARNING : LogLevel::ERROR,
139 4
                sprintf(
140 4
                    'Video info retrieval failed \'%s\' with \'%s\'. "%s(%s)"',
141 4
                    $exceptionNs[count($exceptionNs) - 1],
142 4
                    __METHOD__,
143 4
                    $e->getMessage(),
144 4
                    $file
145
                )
146
            );
147 4
            throw $e;
148
        }
149
150 10
        return $videoInfo;
151
    }
152
153 12
    private function getCacheKey(Process $process, string $file): string
154
    {
155 12
        $key = sha1(sprintf('%s | %s | %s | %s', $process->getCommandLine(), $file, (string) filesize($file), (string) filemtime($file)));
156
157 12
        return $key;
158
    }
159
}
160