diff --git a/classes/Video.php b/classes/Video.php index 4dc64d9..0e179c9 100644 --- a/classes/Video.php +++ b/classes/Video.php @@ -24,6 +24,7 @@ use Symfony\Component\Process\Process; * @property-read array $entries List of videos (if the object contains information about a playlist) * @property-read array $rtmp_conn * @property-read string|null $_type Object type (usually "playlist" or null) + * @property-read stdClass $downloader_options */ class Video { @@ -62,6 +63,12 @@ class Video */ private $json; + /** + * URLs of the video files. + * @var array + */ + private $urls; + /** * VideoDownload constructor. * @@ -219,13 +226,16 @@ class Video * */ public function getUrl() { - $urls = explode("\n", $this->getProp('get-url')); + // Cache the URLs. + if (!isset($this->urls)) { + $this->urls = explode("\n", $this->getProp('get-url')); - if (empty($urls[0])) { - throw new EmptyUrlException(_('youtube-dl returned an empty URL.')); + if (empty($urls[0])) { + throw new EmptyUrlException(_('youtube-dl returned an empty URL.')); + } } - return $urls; + return $this->urls; } /** diff --git a/classes/YoutubeChunkStream.php b/classes/YoutubeChunkStream.php new file mode 100644 index 0000000..3e77674 --- /dev/null +++ b/classes/YoutubeChunkStream.php @@ -0,0 +1,196 @@ +response = $response; + } + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * + * @return string + */ + public function read($length) + { + $size = $this->response->getHeader('Content-Length')[0]; + if ($size - $this->tell() < $length) { + // Don't try to read further than the end of the stream. + $length = $size - $this->tell(); + } + + return $this->response->getBody()->read($length); + } + + /** + * Reads all data from the stream into a string, from the beginning to end. + */ + public function __toString() + { + return (string) $this->response->getBody(); + } + + /** + * Closes the stream and any underlying resources. + * + * @return mixed + */ + public function close() + { + return $this->response->getBody()->close(); + } + + /** + * Separates any underlying resources from the stream. + * + * @return resource|null + */ + public function detach() + { + return $this->response->getBody()->detach(); + } + + /** + * Get the size of the stream if known. + * + * @return int|null + */ + public function getSize() + { + return $this->response->getBody()->getSize(); + } + + /** + * Returns the current position of the file read/write pointer. + * + * @return int + */ + public function tell() + { + return $this->response->getBody()->tell(); + } + + /** + * Returns true if the stream is at the end of the stream. + * + * @return bool + */ + public function eof() + { + return $this->response->getBody()->eof(); + } + + /** + * Returns whether or not the stream is seekable. + * + * @return bool + */ + public function isSeekable() + { + return $this->response->getBody()->isSeekable(); + } + + /** + * Seek to a position in the stream. + * + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * + * @return mixed + */ + public function seek($offset, $whence = SEEK_SET) + { + return $this->response->getBody()->seek($offset, $whence); + } + + /** + * Seek to the beginning of the stream. + * + * @return mixed + */ + public function rewind() + { + return $this->response->getBody()->rewind(); + } + + /** + * Returns whether or not the stream is writable. + * + * @return bool + */ + public function isWritable() + { + return $this->response->getBody()->isWritable(); + } + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written + * + * @return mixed + */ + public function write($string) + { + return $this->response->getBody()->write($string); + } + + /** + * Returns whether or not the stream is readable. + * + * @return bool + */ + public function isReadable() + { + return $this->response->getBody()->isReadable(); + } + + /** + * Returns the remaining contents in a string + * + * @return string + */ + public function getContents() + { + return $this->response->getBody()->getContents(); + } + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * @param string $key Specific metadata to retrieve. + * + * @return array|mixed|null + */ + public function getMetadata($key = null) + { + return $this->response->getBody()->getMetadata($key); + } +} diff --git a/classes/YoutubeStream.php b/classes/YoutubeStream.php new file mode 100644 index 0000000..d6c599b --- /dev/null +++ b/classes/YoutubeStream.php @@ -0,0 +1,38 @@ +getHttpResponse(); + $fileSize = $stream->getHeader('Content-Length'); + $curSize = 0; + while ($curSize < $fileSize[0]) { + $newSize = $curSize + $video->downloader_options->http_chunk_size; + if ($newSize > $fileSize[0]) { + $newSize = $fileSize[0] - 1; + } + $response = $video->getHttpResponse(['Range' => 'bytes='.$curSize.'-'.$newSize]); + $this->addStream(new YoutubeChunkStream($response)); + $curSize = $newSize + 1; + } + } +} diff --git a/controllers/DownloadController.php b/controllers/DownloadController.php index ecd7496..08e5583 100644 --- a/controllers/DownloadController.php +++ b/controllers/DownloadController.php @@ -10,6 +10,7 @@ use Alltube\EmptyUrlException; use Alltube\PasswordException; use Alltube\PlaylistArchiveStream; use Alltube\Video; +use Alltube\YoutubeStream; use Exception; use Slim\Http\Request; use Slim\Http\Response; @@ -172,7 +173,13 @@ class DownloadController extends BaseController if ($stream->getStatusCode() == 206) { $response = $response->withStatus(206); } - $body = $stream->getBody(); + + if (isset($this->video->downloader_options->http_chunk_size)) { + // Workaround for Youtube throttling the download speed. + $body = new YoutubeStream($this->video); + } else { + $body = $stream->getBody(); + } } if ($request->isGet()) { $response = $response->withBody($body);