From d2cd370c824d27779ef9d533e84b08f79beb31a4 Mon Sep 17 00:00:00 2001 From: Pierre Rudloff Date: Sat, 20 Jun 2020 22:46:28 +0200 Subject: [PATCH] Move download logic to a separate Downloader class To make reusing the youtube-dl settings easier --- classes/Downloader.php | 474 +++++++++++++++++++++++++++++++++++++++++ classes/Video.php | 439 ++------------------------------------ composer.json | 3 +- composer.lock | 6 +- 4 files changed, 499 insertions(+), 423 deletions(-) create mode 100644 classes/Downloader.php diff --git a/classes/Downloader.php b/classes/Downloader.php new file mode 100644 index 0000000..0eec4cc --- /dev/null +++ b/classes/Downloader.php @@ -0,0 +1,474 @@ +youtubedl = $youtubedl; + $this->params = $params; + $this->python = $python; + $this->avconv = $avconv; + $this->phantomjsDir = $phantomjsDir; + $this->avconvVerbosity = $avconvVerbosity; + } + + /** + * @param string $webpageUrl URL of the page containing the video + * @param string $requestedFormat Requested video format + * @param string $password Password + * @return Video + */ + public function getVideo($webpageUrl, $requestedFormat = 'best/bestvideo', $password = null) + { + return new Video($this, $webpageUrl, $requestedFormat, $password); + } + + /** + * Return a youtube-dl process with the specified arguments. + * + * @param string[] $arguments Arguments + * + * @return Process + */ + private function getProcess(array $arguments) + { + return new Process( + array_merge( + [$this->python, $this->youtubedl], + $this->params, + $arguments + ) + ); + } + + /** + * Check if a command runs successfully. + * + * @param string[] $command Command and arguments + * + * @return bool False if the command returns an error, true otherwise + */ + private static function checkCommand(array $command) + { + $process = new Process($command); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * Get a process that runs avconv in order to convert a video. + * + * @param Video $video Video object + * @param int $audioBitrate Audio bitrate of the converted file + * @param string $filetype Filetype of the converted file + * @param bool $audioOnly True to return an audio-only file + * @param string $from Start the conversion at this time + * @param string $to End the conversion at this time + * + * @return Process Process + * @throws AlltubeLibraryException + * @throws AvconvException If avconv/ffmpeg is missing + * @throws InvalidTimeException + */ + private function getAvconvProcess( + Video $video, + $audioBitrate, + $filetype = 'mp3', + $audioOnly = true, + $from = null, + $to = null + ) { + if (!$this->checkCommand([$this->avconv, '-version'])) { + throw new AvconvException($this->avconv); + } + + $durationRegex = '/(\d+:)?(\d+:)?(\d+)/'; + + $afterArguments = []; + + if ($audioOnly) { + $afterArguments[] = '-vn'; + } + + if (!empty($from)) { + if (!preg_match($durationRegex, $from)) { + throw new InvalidTimeException($from); + } + $afterArguments[] = '-ss'; + $afterArguments[] = $from; + } + if (!empty($to)) { + if (!preg_match($durationRegex, $to)) { + throw new InvalidTimeException($to); + } + $afterArguments[] = '-to'; + $afterArguments[] = $to; + } + + $urls = $video->getUrl(); + + $arguments = array_merge( + [ + $this->avconv, + '-v', $this->avconvVerbosity, + ], + $video->getRtmpArguments(), + [ + '-i', $urls[0], + '-f', $filetype, + '-b:a', $audioBitrate . 'k', + ], + $afterArguments, + [ + 'pipe:1', + ] + ); + + //Vimeo needs a correct user-agent + $arguments[] = '-user_agent'; + $arguments[] = $video->getProp('dump-user-agent'); + + return new Process($arguments); + } + + + /** + * Call youtube-dl. + * + * @param string[] $arguments Arguments + * + * @return string Result + * @throws WrongPasswordException If the password is wrong + * @throws YoutubedlException If youtube-dl returns an error + * @throws PasswordException If the video is protected by a password and no password was specified + */ + public function callYoutubedl(array $arguments) + { + $process = $this->getProcess($arguments); + //This is needed by the openload extractor because it runs PhantomJS + $process->setEnv(['PATH' => $this->phantomjsDir]); + $process->run(); + if (!$process->isSuccessful()) { + $errorOutput = trim($process->getErrorOutput()); + $exitCode = intval($process->getExitCode()); + if ($errorOutput == 'ERROR: This video is protected by a password, use the --video-password option') { + throw new PasswordException($errorOutput, $exitCode); + } elseif (substr($errorOutput, 0, 21) == 'ERROR: Wrong password') { + throw new WrongPasswordException($errorOutput, $exitCode); + } else { + throw new YoutubedlException($errorOutput, $exitCode); + } + } else { + return trim($process->getOutput()); + } + } + + + /** + * Get video stream from an M3U playlist. + * + * @param Video $video Video object + * @return resource popen stream + * @throws AlltubeLibraryException + * @throws AvconvException If avconv/ffmpeg is missing + * @throws PopenStreamException If the popen stream was not created correctly + */ + public function getM3uStream(Video $video) + { + if (!$this->checkCommand([$this->avconv, '-version'])) { + throw new AvconvException($this->avconv); + } + + $urls = $video->getUrl(); + + $process = new Process( + [ + $this->avconv, + '-v', $this->avconvVerbosity, + '-i', $urls[0], + '-f', $video->ext, + '-c', 'copy', + '-bsf:a', 'aac_adtstoasc', + '-movflags', 'frag_keyframe+empty_moov', + 'pipe:1', + ] + ); + + $stream = popen($process->getCommandLine(), 'r'); + if (!is_resource($stream)) { + throw new PopenStreamException(); + } + + return $stream; + } + + + /** + * Get audio stream of converted video. + * + * @param Video $video Video object + * @param int $audioBitrate MP3 bitrate when converting (in kbit/s) + * @param string $from Start the conversion at this time + * @param string $to End the conversion at this time + * + * @return resource popen stream + * @throws AlltubeLibraryException + * @throws AvconvException + * @throws InvalidProtocolConversionException If you try to convert an M3U or Dash media + * @throws PlaylistConversionException If you try to convert a playlist + * @throws PopenStreamException If the stream is invalid + */ + public function getAudioStream(Video $video, $audioBitrate = 128, $from = null, $to = null) + { + if (isset($video->_type) && $video->_type == 'playlist') { + throw new PlaylistConversionException(); + } + + if (isset($video->protocol)) { + if (in_array($video->protocol, ['m3u8', 'm3u8_native', 'http_dash_segments'])) { + throw new InvalidProtocolConversionException($video->protocol); + } + } + + $avconvProc = $this->getAvconvProcess($video, $audioBitrate, 'mp3', true, $from, $to); + + $stream = popen($avconvProc->getCommandLine(), 'r'); + + if (!is_resource($stream)) { + throw new PopenStreamException(); + } + + return $stream; + } + + + /** + * Get an avconv stream to remux audio and video. + * + * @param Video $video Video object + * @return resource popen stream + * @throws AlltubeLibraryException + * @throws PopenStreamException If the popen stream was not created correctly + * @throws RemuxException If the video does not have two URLs + */ + public function getRemuxStream(Video $video) + { + $urls = $video->getUrl(); + + if (!isset($urls[0]) || !isset($urls[1])) { + throw new RemuxException('This video does not have two URLs.'); + } + + $process = new Process( + [ + $this->avconv, + '-v', $this->avconvVerbosity, + '-i', $urls[0], + '-i', $urls[1], + '-c', 'copy', + '-map', '0:v:0', + '-map', '1:a:0', + '-f', 'matroska', + 'pipe:1', + ] + ); + + $stream = popen($process->getCommandLine(), 'r'); + if (!is_resource($stream)) { + throw new PopenStreamException(); + } + + return $stream; + } + + + /** + * Get video stream from an RTMP video. + * + * @param Video $video Video object + * @return resource popen stream + * @throws AlltubeLibraryException + * @throws PopenStreamException If the popen stream was not created correctly + */ + public function getRtmpStream(Video $video) + { + $urls = $video->getUrl(); + + $process = new Process( + array_merge( + [ + $this->avconv, + '-v', $this->avconvVerbosity, + ], + $video->getRtmpArguments(), + [ + '-i', $urls[0], + '-f', $video->ext, + 'pipe:1', + ] + ) + ); + $stream = popen($process->getCommandLine(), 'r'); + if (!is_resource($stream)) { + throw new PopenStreamException(); + } + + return $stream; + } + + + /** + * Get the stream of a converted video. + * + * @param Video $video Video object + * @param int $audioBitrate Audio bitrate of the converted file + * @param string $filetype Filetype of the converted file + * + * @return resource popen stream + * @throws AlltubeLibraryException + * @throws AvconvException + * @throws InvalidProtocolConversionException If your try to convert and M3U8 video + * @throws PopenStreamException If the popen stream was not created correctly + */ + public function getConvertedStream(Video $video, $audioBitrate, $filetype) + { + if (in_array($video->protocol, ['m3u8', 'm3u8_native'])) { + throw new InvalidProtocolConversionException($video->protocol); + } + + $avconvProc = $this->getAvconvProcess($video, $audioBitrate, $filetype, false); + + $stream = popen($avconvProc->getCommandLine(), 'r'); + + if (!is_resource($stream)) { + throw new PopenStreamException(); + } + + return $stream; + } + + /** + * List all extractors. + * + * @return string[] Extractors + * + * @throws AlltubeLibraryException + */ + public function getExtractors() + { + return explode("\n", trim($this->callYoutubedl(['--list-extractors']))); + } + + + /** + * Get a HTTP response containing the video. + * + * @param Video $video Video object + * @param mixed[] $headers HTTP headers of the request + * + * @return ResponseInterface + * @throws AlltubeLibraryException + * @link https://github.com/guzzle/guzzle/issues/2640 + */ + public function getHttpResponse(Video $video, array $headers = []) + { + // IDN conversion breaks with Google hosts like https://r3---sn-25glene6.googlevideo.com/. + $client = new Client(['idn_conversion' => false]); + $urls = $video->getUrl(); + $stream_context_options = []; + + if (array_key_exists('Referer', (array)$video->http_headers)) { + $stream_context_options = [ + 'http' => [ + 'header' => 'Referer: ' . $video->http_headers->Referer + ] + ]; + } + + return $client->request( + 'GET', + $urls[0], + [ + 'stream' => true, + 'stream_context' => $stream_context_options, + 'headers' => array_merge((array)$video->http_headers, $headers) + ] + ); + } +} diff --git a/classes/Video.php b/classes/Video.php index 57b65f6..d4d9ea0 100644 --- a/classes/Video.php +++ b/classes/Video.php @@ -7,20 +7,8 @@ namespace Alltube\Library; use Alltube\Library\Exception\AlltubeLibraryException; -use Alltube\Library\Exception\AvconvException; use Alltube\Library\Exception\EmptyUrlException; -use Alltube\Library\Exception\InvalidProtocolConversionException; -use Alltube\Library\Exception\InvalidTimeException; -use Alltube\Library\Exception\PasswordException; -use Alltube\Library\Exception\PlaylistConversionException; -use Alltube\Library\Exception\PopenStreamException; -use Alltube\Library\Exception\RemuxException; -use Alltube\Library\Exception\WrongPasswordException; -use Alltube\Library\Exception\YoutubedlException; -use GuzzleHttp\Client; -use Psr\Http\Message\ResponseInterface; use stdClass; -use Symfony\Component\Process\Process; /** * Extract info about videos. @@ -41,49 +29,6 @@ use Symfony\Component\Process\Process; class Video { - /** - * youtube-dl binary path. - * - * @var string - */ - public $youtubedl = '/usr/bin/youtube-dl'; - - /** - * python binary path. - * - * @var string - */ - public $python = '/usr/bin/python3'; - - /** - * avconv or ffmpeg binary path. - * - * @var string - */ - public $avconv = '/usr/bin/ffmpeg'; - - /** - * avconv/ffmpeg logging level. - * Must be one of these: quiet, panic, fatal, error, warning, info, verbose, debug. - * - * @var string - */ - public $avconvVerbosity = 'error'; - - /** - * Path to the directory that contains the phantomjs binary. - * - * @var string - */ - public $phantomjsDir = '/usr/bin/'; - - /** - * youtube-dl parameters. - * - * @var string[] - */ - public $params = ['--no-warnings']; - /** * URL of the page containing the video. * @@ -119,85 +64,35 @@ class Video */ private $urls; + /** + * Downloader instance. + * + * @var Downloader + */ + private $downloader; + /** * VideoDownload constructor. * + * @param Downloader $downloader Downloader instance * @param string $webpageUrl URL of the page containing the video * @param string $requestedFormat Requested video format * (can be any format string accepted by youtube-dl, * including selectors like "[height<=720]") * @param string $password Password */ - public function __construct($webpageUrl, $requestedFormat = 'best/bestvideo', $password = null) - { + public function __construct( + Downloader $downloader, + $webpageUrl, + $requestedFormat, + $password = null + ) { + $this->downloader = $downloader; $this->webpageUrl = $webpageUrl; $this->requestedFormat = $requestedFormat; $this->password = $password; } - /** - * Return a youtube-dl process with the specified arguments. - * - * @param string[] $arguments Arguments - * - * @return Process - */ - private function getProcess(array $arguments) - { - return new Process( - array_merge( - [$this->python, $this->youtubedl], - $this->params, - $arguments - ) - ); - } - - /** - * List all extractors. - * - * @return string[] Extractors - * - * @throws AlltubeLibraryException - */ - public static function getExtractors() - { - $video = new self(''); - - return explode("\n", trim($video->callYoutubedl(['--list-extractors']))); - } - - /** - * Call youtube-dl. - * - * @param string[] $arguments Arguments - * - * @return string Result - * @throws WrongPasswordException If the password is wrong - * @throws YoutubedlException If youtube-dl returns an error - * @throws PasswordException If the video is protected by a password and no password was specified - */ - private function callYoutubedl(array $arguments) - { - $process = $this->getProcess($arguments); - //This is needed by the openload extractor because it runs PhantomJS - $process->setEnv(['PATH' => $this->phantomjsDir]); - $process->run(); - if (!$process->isSuccessful()) { - $errorOutput = trim($process->getErrorOutput()); - $exitCode = intval($process->getExitCode()); - if ($errorOutput == 'ERROR: This video is protected by a password, use the --video-password option') { - throw new PasswordException($errorOutput, $exitCode); - } elseif (substr($errorOutput, 0, 21) == 'ERROR: Wrong password') { - throw new WrongPasswordException($errorOutput, $exitCode); - } else { - throw new YoutubedlException($errorOutput, $exitCode); - } - } else { - return trim($process->getOutput()); - } - } - /** * Get a property from youtube-dl. * @@ -206,7 +101,7 @@ class Video * @return string * @throws AlltubeLibraryException */ - private function getProp($prop = 'dump-json') + public function getProp($prop = 'dump-json') { $arguments = ['--' . $prop]; @@ -222,7 +117,7 @@ class Video $arguments[] = $this->password; } - return $this->callYoutubedl($arguments); + return $this->downloader->callYoutubedl($arguments); } /** @@ -325,7 +220,7 @@ class Video * * @return string[] Arguments */ - private function getRtmpArguments() + public function getRtmpArguments() { $arguments = []; @@ -357,267 +252,6 @@ class Video return $arguments; } - /** - * Check if a command runs successfully. - * - * @param string[] $command Command and arguments - * - * @return bool False if the command returns an error, true otherwise - */ - public static function checkCommand(array $command) - { - $process = new Process($command); - $process->run(); - - return $process->isSuccessful(); - } - - /** - * Get a process that runs avconv in order to convert a video. - * - * @param int $audioBitrate Audio bitrate of the converted file - * @param string $filetype Filetype of the converted file - * @param bool $audioOnly True to return an audio-only file - * @param string $from Start the conversion at this time - * @param string $to End the conversion at this time - * - * @return Process Process - * @throws AvconvException If avconv/ffmpeg is missing - * @throws AlltubeLibraryException - */ - private function getAvconvProcess( - $audioBitrate, - $filetype = 'mp3', - $audioOnly = true, - $from = null, - $to = null - ) { - if (!$this->checkCommand([$this->avconv, '-version'])) { - throw new AvconvException($this->avconv); - } - - $durationRegex = '/(\d+:)?(\d+:)?(\d+)/'; - - $afterArguments = []; - - if ($audioOnly) { - $afterArguments[] = '-vn'; - } - - if (!empty($from)) { - if (!preg_match($durationRegex, $from)) { - throw new InvalidTimeException($from); - } - $afterArguments[] = '-ss'; - $afterArguments[] = $from; - } - if (!empty($to)) { - if (!preg_match($durationRegex, $to)) { - throw new InvalidTimeException($to); - } - $afterArguments[] = '-to'; - $afterArguments[] = $to; - } - - $urls = $this->getUrl(); - - $arguments = array_merge( - [ - $this->avconv, - '-v', $this->avconvVerbosity, - ], - $this->getRtmpArguments(), - [ - '-i', $urls[0], - '-f', $filetype, - '-b:a', $audioBitrate . 'k', - ], - $afterArguments, - [ - 'pipe:1', - ] - ); - - //Vimeo needs a correct user-agent - $arguments[] = '-user_agent'; - $arguments[] = $this->getProp('dump-user-agent'); - - return new Process($arguments); - } - - /** - * Get audio stream of converted video. - * - * @param int $audioBitrate MP3 bitrate when converting (in kbit/s) - * @param string $from Start the conversion at this time - * @param string $to End the conversion at this time - * - * @return resource popen stream - * @throws PlaylistConversionException If you try to convert a playlist - * @throws InvalidProtocolConversionException If you try to convert an M3U or Dash media - * @throws PopenStreamException If the stream is invalid - * @throws AlltubeLibraryException - */ - public function getAudioStream($audioBitrate = 128, $from = null, $to = null) - { - if (isset($this->_type) && $this->_type == 'playlist') { - throw new PlaylistConversionException(); - } - - if (isset($this->protocol)) { - if (in_array($this->protocol, ['m3u8', 'm3u8_native', 'http_dash_segments'])) { - throw new InvalidProtocolConversionException($this->protocol); - } - } - - $avconvProc = $this->getAvconvProcess($audioBitrate, 'mp3', true, $from, $to); - - $stream = popen($avconvProc->getCommandLine(), 'r'); - - if (!is_resource($stream)) { - throw new PopenStreamException(); - } - - return $stream; - } - - /** - * Get video stream from an M3U playlist. - * - * @return resource popen stream - * @throws PopenStreamException If the popen stream was not created correctly - * @throws AvconvException If avconv/ffmpeg is missing - * @throws AlltubeLibraryException - */ - public function getM3uStream() - { - if (!$this->checkCommand([$this->avconv, '-version'])) { - throw new AvconvException($this->avconv); - } - - $urls = $this->getUrl(); - - $process = new Process( - [ - $this->avconv, - '-v', $this->avconvVerbosity, - '-i', $urls[0], - '-f', $this->ext, - '-c', 'copy', - '-bsf:a', 'aac_adtstoasc', - '-movflags', 'frag_keyframe+empty_moov', - 'pipe:1', - ] - ); - - $stream = popen($process->getCommandLine(), 'r'); - if (!is_resource($stream)) { - throw new PopenStreamException(); - } - - return $stream; - } - - /** - * Get an avconv stream to remux audio and video. - * - * @return resource popen stream - * @throws PopenStreamException If the popen stream was not created correctly - * @throws RemuxException If the video does not have two URLs - * @throws AlltubeLibraryException - * - */ - public function getRemuxStream() - { - $urls = $this->getUrl(); - - if (!isset($urls[0]) || !isset($urls[1])) { - throw new RemuxException('This video does not have two URLs.'); - } - - $process = new Process( - [ - $this->avconv, - '-v', $this->avconvVerbosity, - '-i', $urls[0], - '-i', $urls[1], - '-c', 'copy', - '-map', '0:v:0', - '-map', '1:a:0', - '-f', 'matroska', - 'pipe:1', - ] - ); - - $stream = popen($process->getCommandLine(), 'r'); - if (!is_resource($stream)) { - throw new PopenStreamException(); - } - - return $stream; - } - - /** - * Get video stream from an RTMP video. - * - * @return resource popen stream - * @throws PopenStreamException If the popen stream was not created correctly - * - */ - public function getRtmpStream() - { - $urls = $this->getUrl(); - - $process = new Process( - array_merge( - [ - $this->avconv, - '-v', $this->avconvVerbosity, - ], - $this->getRtmpArguments(), - [ - '-i', $urls[0], - '-f', $this->ext, - 'pipe:1', - ] - ) - ); - $stream = popen($process->getCommandLine(), 'r'); - if (!is_resource($stream)) { - throw new PopenStreamException(); - } - - return $stream; - } - - /** - * Get the stream of a converted video. - * - * @param int $audioBitrate Audio bitrate of the converted file - * @param string $filetype Filetype of the converted file - * - * @return resource popen stream - * @throws PopenStreamException If the popen stream was not created correctly - * @throws InvalidProtocolConversionException If your try to convert and M3U8 video - * @throws AlltubeLibraryException - */ - public function getConvertedStream($audioBitrate, $filetype) - { - if (in_array($this->protocol, ['m3u8', 'm3u8_native'])) { - throw new InvalidProtocolConversionException($this->protocol); - } - - $avconvProc = $this->getAvconvProcess($audioBitrate, $filetype, false); - - $stream = popen($avconvProc->getCommandLine(), 'r'); - - if (!is_resource($stream)) { - throw new PopenStreamException(); - } - - return $stream; - } - /** * Get the same video but with another format. * @@ -627,41 +261,6 @@ class Video */ public function withFormat($format) { - return new self($this->webpageUrl, $format, $this->password); - } - - /** - * Get a HTTP response containing the video. - * - * @param mixed[] $headers HTTP headers of the request - * - * @return ResponseInterface - * @throws AlltubeLibraryException - * @link https://github.com/guzzle/guzzle/issues/2640 - */ - public function getHttpResponse(array $headers = []) - { - // IDN conversion breaks with Google hosts like https://r3---sn-25glene6.googlevideo.com/. - $client = new Client(['idn_conversion' => false]); - $urls = $this->getUrl(); - $stream_context_options = []; - - if (array_key_exists('Referer', (array)$this->http_headers)) { - $stream_context_options = [ - 'http' => [ - 'header' => 'Referer: ' . $this->http_headers->Referer - ] - ]; - } - - return $client->request( - 'GET', - $urls[0], - [ - 'stream' => true, - 'stream_context' => $stream_context_options, - 'headers' => array_merge((array)$this->http_headers, $headers) - ] - ); + return new self($this->downloader, $this->webpageUrl, $format, $this->password); } } diff --git a/composer.json b/composer.json index c52e799..8984422 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "homepage": "http://alltubedownload.net/", "require": { "guzzlehttp/guzzle": "^6.5", - "symfony/process": "^4.0|^5.0" + "symfony/process": "^4.0|^5.0", + "ext-json": "*" }, "require-dev": { "phpro/grumphp": "^0.18.0", diff --git a/composer.lock b/composer.lock index 90419bc..31f45ec 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "78f44c1972a792af40e0eed6d242308f", + "content-hash": "e084bdaf7ed377ff4963c65d85513eca", "packages": [ { "name": "guzzlehttp/guzzle", @@ -3047,7 +3047,9 @@ }, "prefer-stable": false, "prefer-lowest": false, - "platform": [], + "platform": { + "ext-json": "*" + }, "platform-dev": [], "platform-overrides": { "php": "7.3.11"