├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── phpstan-baseline.php ├── phpstan.dist.neon └── src ├── Entity ├── AbstractEntity.php ├── Category.php ├── Comment.php ├── Extractor.php ├── Format.php ├── Mso.php ├── SubListItem.php ├── Subtitles.php ├── Thumbnail.php ├── Video.php └── VideoCollection.php ├── Exception ├── ExecutableNotFoundException.php ├── FileException.php ├── InvalidArgumentException.php ├── MsoNotParsableException.php ├── NoDownloadPathProvidedException.php ├── NoUrlProvidedException.php └── YoutubeDlException.php ├── Metadata ├── DefaultMetadataReader.php └── MetadataReaderInterface.php ├── Options.php ├── Process ├── ArgvBuilder.php ├── DefaultProcessBuilder.php ├── ProcessBuilderInterface.php └── TableParser.php └── YoutubeDl.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.php] 13 | indent_size = 4 14 | 15 | [composer.json] 16 | indent_size = 4 17 | 18 | [*.{yml,yaml}] 19 | indent_size = 4 20 | 21 | [*.{neon,neon.dist}] 22 | indent_style = tab 23 | indent_size = 4 24 | 25 | [*.md] 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | /tests export-ignore 4 | /.php_cs.dist export-ignore 5 | /.scrutinizer.yml export-ignore 6 | /.styleci.yml export-ignore 7 | /.travis.yml 8 | /phpunit.xml.dist export-ignore 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "master" 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | fail-fast: true 15 | 16 | jobs: 17 | phpstan: 18 | name: PHPStan 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: "Checkout" 22 | uses: actions/checkout@v4 23 | 24 | - name: "Setup PHP" 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | coverage: none 28 | php-version: 8.4 29 | 30 | - name: "Install Dependencies with Composer" 31 | uses: ramsey/composer-install@v3 32 | 33 | - name: "Run PHPStan" 34 | run: | 35 | vendor/bin/simple-phpunit --version 36 | vendor/bin/phpstan analyse --error-format=github 37 | 38 | php-cs-fixer: 39 | name: PHP-CS-Fixer 40 | runs-on: ubuntu-latest 41 | env: 42 | PHP_CS_FIXER_IGNORE_ENV: 1 43 | steps: 44 | - name: "Checkout" 45 | uses: actions/checkout@v4 46 | 47 | - name: "Setup PHP" 48 | uses: shivammathur/setup-php@v2 49 | with: 50 | coverage: none 51 | php-version: 8.4 52 | tools: cs2pr 53 | 54 | - name: "Install Dependencies with Composer" 55 | uses: ramsey/composer-install@v3 56 | 57 | - name: "Run PHP-CS-Fixer" 58 | run: vendor/bin/php-cs-fixer fix --dry-run --format=checkstyle | cs2pr 59 | 60 | phpunit: 61 | name: "PHPUnit (PHP ${{ matrix.php-version }}) (Symfony ${{ matrix.symfony-version }})" 62 | runs-on: ubuntu-latest 63 | strategy: 64 | matrix: 65 | php-version: ['7.4', '8.1', '8.2', '8.3', '8.4'] 66 | symfony-version: ['5.4', '6.4', '7.0', '7.1', '7.2'] 67 | exclude: 68 | - php-version: '7.4' 69 | symfony-version: '6.4' 70 | - php-version: '7.4' 71 | symfony-version: '7.0' 72 | - php-version: '7.4' 73 | symfony-version: '7.1' 74 | - php-version: '7.4' 75 | symfony-version: '7.2' 76 | - php-version: '8.1' 77 | symfony-version: '7.0' 78 | - php-version: '8.1' 79 | symfony-version: '7.1' 80 | - php-version: '8.1' 81 | symfony-version: '7.2' 82 | 83 | steps: 84 | - name: "Checkout" 85 | uses: actions/checkout@v4 86 | 87 | - name: "Setup PHP" 88 | uses: shivammathur/setup-php@v2 89 | with: 90 | php-version: ${{ matrix.php-version }} 91 | tools: flex 92 | 93 | - name: "Install Dependencies with Composer" 94 | uses: ramsey/composer-install@v3 95 | env: 96 | SYMFONY_REQUIRE: ${{ matrix.symfony-version }} 97 | with: 98 | composer-options: "--optimize-autoloader" 99 | 100 | - name: "Run tests with PHPUnit" 101 | env: 102 | SYMFONY_DEPRECATIONS_HELPER: 'ignoreFile=./tests/baseline-ignore' 103 | run: vendor/bin/simple-phpunit 104 | 105 | codecov: 106 | name: "Code coverage" 107 | runs-on: ubuntu-latest 108 | steps: 109 | - name: "Checkout" 110 | uses: actions/checkout@v4 111 | 112 | - name: "Setup PHP" 113 | uses: shivammathur/setup-php@v2 114 | with: 115 | coverage: pcov 116 | php-version: 8.4 117 | 118 | - name: "Install Dependencies with Composer" 119 | uses: ramsey/composer-install@v3 120 | 121 | - name: "Run tests with PHPUnit and collect coverage" 122 | env: 123 | SYMFONY_DEPRECATIONS_HELPER: 'ignoreFile=./tests/baseline-ignore' 124 | run: php -dpcov.enabled=1 -dpcov.directory=. -dpcov.exclude="~vendor~" vendor/bin/simple-phpunit --colors=always --coverage-text -vvv 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /.php-cs-fixer.php 4 | /.php-cs-fixer.cache 5 | /.phpunit.result.cache 6 | /.phpstan 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | files() 5 | ->in([ 6 | __DIR__ . '/src', 7 | __DIR__ . '/tests', 8 | ]); 9 | 10 | return (new PhpCsFixer\Config()) 11 | ->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) 12 | ->setRiskyAllowed(true) 13 | ->setRules([ 14 | '@Symfony' => true, 15 | '@PHP70Migration' => true, 16 | '@PHP70Migration:risky' => true, 17 | '@PHP71Migration:risky' => true, 18 | '@PHP73Migration' => true, 19 | 'linebreak_after_opening_tag' => true, 20 | 'global_namespace_import' => [ 21 | 'import_classes' => true, 22 | 'import_constants' => true, 23 | 'import_functions' => true, 24 | ], 25 | 'ordered_imports' => [ 26 | 'imports_order' => ['class', 'const', 'function'], 27 | ], 28 | 'yoda_style' => false, 29 | 'static_lambda' => true, 30 | 'align_multiline_comment' => true, 31 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], 32 | 'no_superfluous_phpdoc_tags' => false, 33 | ]) 34 | ->setFinder($finder); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Tomas Norkūnas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Youtube-dl PHP 2 | A PHP wrapper for [youtube-dl](https://github.com/ytdl-org/youtube-dl) or [yt-dlp](https://github.com/yt-dlp/yt-dlp). 3 | 4 | [![Latest Stable Version](https://poser.pugx.org/norkunas/youtube-dl-php/v/stable.svg)](https://packagist.org/packages/norkunas/youtube-dl-php) 5 | [![Latest Unstable Version](https://poser.pugx.org/norkunas/youtube-dl-php/v/unstable.svg)](https://packagist.org/packages/norkunas/youtube-dl-php) 6 | [![Total Downloads](https://poser.pugx.org/norkunas/youtube-dl-php/downloads)](https://packagist.org/packages/norkunas/youtube-dl-php) 7 | ![CI Status](https://github.com/norkunas/youtube-dl-php/workflows/CI/badge.svg?branch=master) 8 | [![License](https://poser.pugx.org/norkunas/youtube-dl-php/license.svg)](https://packagist.org/packages/norkunas/youtube-dl-php) 9 | 10 | ## Install 11 | First step is to download the [youtube-dl](https://ytdl-org.github.io/youtube-dl/download.html) or [yt-dlp](https://github.com/yt-dlp/yt-dlp#installation). 12 | 13 | Second step is to install the wrapper using [Composer](http://getcomposer.org/): 14 | ``` 15 | composer require norkunas/youtube-dl-php:dev-master 16 | ``` 17 | 18 | ## Download video 19 | ```php 20 | download( 32 | Options::create() 33 | ->downloadPath('/path/to/downloads') 34 | ->url('https://www.youtube.com/watch?v=oDAw7vW7H0c') 35 | ); 36 | 37 | foreach ($collection->getVideos() as $video) { 38 | if ($video->getError() !== null) { 39 | echo "Error downloading video: {$video->getError()}."; 40 | } else { 41 | echo $video->getTitle(); // Will return Phonebloks 42 | // $video->getFile(); // \SplFileInfo instance of downloaded file 43 | } 44 | } 45 | 46 | ``` 47 | 48 | ## Download only audio (requires ffmpeg or avconv and ffprobe or avprobe) 49 | ```php 50 | download( 61 | Options::create() 62 | ->downloadPath('/path/to/downloads') 63 | ->extractAudio(true) 64 | ->audioFormat('mp3') 65 | ->audioQuality('0') // best 66 | ->output('%(title)s.%(ext)s') 67 | ->url('https://www.youtube.com/watch?v=oDAw7vW7H0c') 68 | ); 69 | 70 | foreach ($collection->getVideos() as $video) { 71 | if ($video->getError() !== null) { 72 | echo "Error downloading video: {$video->getError()}."; 73 | } else { 74 | $video->getFile(); // audio file 75 | } 76 | } 77 | ``` 78 | 79 | ## Download progress 80 | ```php 81 | onProgress(static function (?string $progressTarget, string $percentage, string $size, string $speed, string $eta, ?string $totalTime): void { 91 | echo "Download file: $progressTarget; Percentage: $percentage; Size: $size"; 92 | if ($speed) { 93 | echo "; Speed: $speed"; 94 | } 95 | if ($eta) { 96 | echo "; ETA: $eta"; 97 | } 98 | if ($totalTime !== null) { 99 | echo "; Downloaded in: $totalTime"; 100 | } 101 | }); 102 | ``` 103 | 104 | ## Custom Process Instantiation 105 | 106 | ```php 107 | setTimeout(60); 123 | 124 | return $process; 125 | } 126 | } 127 | ``` 128 | 129 | ```php 130 | =7.4.0", 15 | "ext-json": "*", 16 | "symfony/filesystem": "^5.1|^6.0|^7.0", 17 | "symfony/polyfill-php80": "^1.28", 18 | "symfony/process": "^5.1|^6.0|^7.0" 19 | }, 20 | "require-dev": { 21 | "mikey179/vfsstream": "^1.6.11", 22 | "php-cs-fixer/shim": "^3.60", 23 | "phpstan/phpstan": "^1.11.8", 24 | "phpstan/phpstan-phpunit": "^1.4.0", 25 | "phpstan/phpstan-strict-rules": "^1.6.0", 26 | "symfony/phpunit-bridge": "^6.4.10" 27 | }, 28 | "autoload": { 29 | "psr-4": { "YoutubeDl\\": "src/" } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "YoutubeDl\\Tests\\": "tests/" 34 | } 35 | }, 36 | "config": { 37 | "sort-packages": true 38 | }, 39 | "extra": { 40 | "branch-alias": { 41 | "dev-master": "2.0-dev" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /phpstan-baseline.php: -------------------------------------------------------------------------------- 1 | '#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#', 6 | 'count' => 1, 7 | 'path' => __DIR__ . '/src/Entity/Video.php', 8 | ]; 9 | $ignoreErrors[] = [ 10 | 'message' => '#^YoutubeDl\\\\Tests\\\\StaticProcess\\:\\:__construct\\(\\) does not call parent constructor from Symfony\\\\Component\\\\Process\\\\Process\\.$#', 11 | 'count' => 1, 12 | 'path' => __DIR__ . '/tests/StaticProcess.php', 13 | ]; 14 | 15 | return ['parameters' => ['ignoreErrors' => $ignoreErrors]]; 16 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-phpunit/extension.neon 3 | - vendor/phpstan/phpstan-strict-rules/rules.neon 4 | - phpstan-baseline.php 5 | 6 | parameters: 7 | bootstrapFiles: 8 | - vendor/bin/.phpunit/phpunit/vendor/autoload.php 9 | paths: 10 | - src/ 11 | - tests/ 12 | tmpDir: %currentWorkingDirectory%/.phpstan 13 | level: 8 14 | inferPrivatePropertyTypeFromConstructor: true 15 | checkMissingIterableValueType: true 16 | checkGenericClassInNonGenericObjectType: true 17 | checkUninitializedProperties: true 18 | checkBenevolentUnionTypes: true 19 | reportAlwaysTrueInLastCondition: true 20 | -------------------------------------------------------------------------------- /src/Entity/AbstractEntity.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected static array $objectMap = []; 23 | 24 | /** 25 | * @var array 26 | */ 27 | protected array $elements = []; 28 | 29 | /** 30 | * @param array $elements 31 | */ 32 | public function __construct(array $elements = []) 33 | { 34 | $this->elements = $this->convert($elements); 35 | } 36 | 37 | /** 38 | * @param mixed $default 39 | * 40 | * @return mixed 41 | */ 42 | public function get(string $key, $default = null) 43 | { 44 | return $this->elements[$key] ?? $default; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function toArray(): array 51 | { 52 | $toArray = static function ($data) use (&$toArray): array { 53 | foreach ($data as $k => $v) { 54 | if (is_array($v)) { 55 | $data[$k] = $toArray($v); 56 | } elseif ($v instanceof AbstractEntity) { 57 | $data[$k] = $v->toArray(); 58 | } 59 | } 60 | 61 | return $data; 62 | }; 63 | 64 | return $toArray($this->elements); 65 | } 66 | 67 | public function count(): int 68 | { 69 | return count($this->elements); 70 | } 71 | 72 | public function toJson(int $options = JSON_THROW_ON_ERROR): string 73 | { 74 | $json = json_encode($this->toArray(), $options); 75 | 76 | if ($json === false) { 77 | throw new JsonException(json_last_error_msg()); 78 | } 79 | 80 | return $json; 81 | } 82 | 83 | public function __toString(): string 84 | { 85 | return $this->toJson(); 86 | } 87 | 88 | /** 89 | * @param array $data 90 | * 91 | * @return array 92 | */ 93 | protected function convert(array $data): array 94 | { 95 | foreach ($data as $key => $item) { 96 | if (!isset(static::$objectMap[$key])) { 97 | continue; 98 | } 99 | 100 | foreach ($item as $k2 => $v) { 101 | $data[$key][$k2] = new static::$objectMap[$key]($v); 102 | } 103 | } 104 | 105 | return $data; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Entity/Category.php: -------------------------------------------------------------------------------- 1 | $category]); 17 | } 18 | 19 | public function getTitle(): ?string 20 | { 21 | return $this->get('title'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Entity/Comment.php: -------------------------------------------------------------------------------- 1 | get('author'); 12 | } 13 | 14 | public function getAuthorId(): ?string 15 | { 16 | return $this->get('author_id'); 17 | } 18 | 19 | /** 20 | * @return mixed 21 | */ 22 | public function getId() 23 | { 24 | return $this->get('id'); 25 | } 26 | 27 | public function getHtml(): ?string 28 | { 29 | return $this->get('html'); 30 | } 31 | 32 | public function getText(): ?string 33 | { 34 | return $this->get('text'); 35 | } 36 | 37 | public function getTimestamp(): ?int 38 | { 39 | return $this->get('timestamp'); 40 | } 41 | 42 | /** 43 | * @return mixed 44 | */ 45 | public function getParent() 46 | { 47 | return $this->get('parent'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Entity/Extractor.php: -------------------------------------------------------------------------------- 1 | get('title'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Entity/Format.php: -------------------------------------------------------------------------------- 1 | get('format'); 12 | } 13 | 14 | public function getUrl(): ?string 15 | { 16 | return $this->get('url'); 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public function getHttpHeaders(): array 23 | { 24 | return $this->get('http_headers', []); 25 | } 26 | 27 | public function getAcodec(): ?string 28 | { 29 | return $this->get('acodec'); 30 | } 31 | 32 | public function getVcodec(): ?string 33 | { 34 | return $this->get('vcodec'); 35 | } 36 | 37 | public function getFormatNote(): ?string 38 | { 39 | return $this->get('format_note'); 40 | } 41 | 42 | public function getAbr(): ?int 43 | { 44 | return $this->get('abr'); 45 | } 46 | 47 | public function getPlayerUrl(): ?string 48 | { 49 | return $this->get('player_url'); 50 | } 51 | 52 | public function getExt(): ?string 53 | { 54 | return $this->get('ext'); 55 | } 56 | 57 | public function getPreference(): ?int 58 | { 59 | return $this->get('preference'); 60 | } 61 | 62 | public function getFormatId(): ?string 63 | { 64 | return $this->get('format_id'); 65 | } 66 | 67 | public function getContainer(): ?string 68 | { 69 | return $this->get('container'); 70 | } 71 | 72 | public function getWidth(): ?int 73 | { 74 | return $this->get('width'); 75 | } 76 | 77 | public function getHeight(): ?int 78 | { 79 | return $this->get('height'); 80 | } 81 | 82 | public function getAsr(): ?int 83 | { 84 | return $this->get('asr'); 85 | } 86 | 87 | public function getTbr(): ?float 88 | { 89 | return $this->get('tbr'); 90 | } 91 | 92 | public function getFps(): ?float 93 | { 94 | return $this->get('fps'); 95 | } 96 | 97 | public function getFilesize(): ?int 98 | { 99 | return $this->get('filesize'); 100 | } 101 | 102 | public function getResolution(): ?string 103 | { 104 | return $this->get('resolution'); 105 | } 106 | 107 | public function getVbr(): ?int 108 | { 109 | return $this->get('vbr'); 110 | } 111 | 112 | public function getProtocol(): ?string 113 | { 114 | return $this->get('protocol'); 115 | } 116 | 117 | public function getLanguagePreference(): ?int 118 | { 119 | return $this->get('language_preference'); 120 | } 121 | 122 | public function getQuality(): ?int 123 | { 124 | return $this->get('quality'); 125 | } 126 | 127 | public function getSourcePreference(): ?int 128 | { 129 | return $this->get('source_preference'); 130 | } 131 | 132 | public function getStretchedRatio(): ?float 133 | { 134 | return $this->get('stretched_ratio'); 135 | } 136 | 137 | public function getNoResume(): bool 138 | { 139 | return $this->get('no_resume'); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Entity/Mso.php: -------------------------------------------------------------------------------- 1 | get('code'); 12 | } 13 | 14 | public function getName(): string 15 | { 16 | return $this->get('name'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Entity/SubListItem.php: -------------------------------------------------------------------------------- 1 | get('language'); 12 | } 13 | 14 | /** 15 | * @return list 16 | */ 17 | public function getFormats(): array 18 | { 19 | return $this->get('formats'); 20 | } 21 | 22 | public function isAutoCaption(): bool 23 | { 24 | return $this->get('auto_caption', false); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Entity/Subtitles.php: -------------------------------------------------------------------------------- 1 | get('url'); 12 | } 13 | 14 | public function getExt(): ?string 15 | { 16 | return $this->get('ext'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Entity/Thumbnail.php: -------------------------------------------------------------------------------- 1 | get('id'); 12 | } 13 | 14 | public function getUrl(): ?string 15 | { 16 | return $this->get('url'); 17 | } 18 | 19 | public function getPreference(): ?int 20 | { 21 | return $this->get('preference'); 22 | } 23 | 24 | public function getWidth(): ?int 25 | { 26 | return $this->get('width'); 27 | } 28 | 29 | public function getHeight(): ?int 30 | { 31 | return $this->get('height'); 32 | } 33 | 34 | public function getResolution(): ?string 35 | { 36 | return $this->get('resolution'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Entity/Video.php: -------------------------------------------------------------------------------- 1 | Category::class, 21 | 'comments' => Comment::class, 22 | 'formats' => Format::class, 23 | 'requested_formats' => Format::class, 24 | 'requested_subtitles' => Subtitles::class, 25 | 'subtitles' => Subtitles::class, 26 | 'automatic_captions' => Subtitles::class, 27 | 'thumbnails' => Thumbnail::class, 28 | ]; 29 | 30 | public function getError(): ?string 31 | { 32 | return $this->get('error'); 33 | } 34 | 35 | public function getId(): ?string 36 | { 37 | return $this->get('id'); 38 | } 39 | 40 | public function getTitle(): ?string 41 | { 42 | return $this->get('title'); 43 | } 44 | 45 | public function getUrl(): ?string 46 | { 47 | return $this->get('url'); 48 | } 49 | 50 | public function getExt(): ?string 51 | { 52 | return $this->get('ext'); 53 | } 54 | 55 | public function getAltTitle(): ?string 56 | { 57 | return $this->get('alt_title'); 58 | } 59 | 60 | public function getDisplayId(): ?string 61 | { 62 | return $this->get('display_id'); 63 | } 64 | 65 | public function getUploader(): ?string 66 | { 67 | return $this->get('uploader'); 68 | } 69 | 70 | public function getUploaderUrl(): ?string 71 | { 72 | return $this->get('uploader_url'); 73 | } 74 | 75 | public function getLicense(): ?string 76 | { 77 | return $this->get('license'); 78 | } 79 | 80 | public function getCreator(): ?string 81 | { 82 | return $this->get('creator'); 83 | } 84 | 85 | public function getReleaseDate(): ?DateTimeInterface 86 | { 87 | return $this->get('release_date'); 88 | } 89 | 90 | public function getTimestamp(): ?int 91 | { 92 | return $this->get('timestamp'); 93 | } 94 | 95 | public function getUploadDate(): ?DateTimeInterface 96 | { 97 | return $this->get('upload_date'); 98 | } 99 | 100 | public function getUploaderId(): ?string 101 | { 102 | return $this->get('uploader_id'); 103 | } 104 | 105 | public function getChannel(): ?string 106 | { 107 | return $this->get('channel'); 108 | } 109 | 110 | public function getChannelId(): ?string 111 | { 112 | return $this->get('channel_id'); 113 | } 114 | 115 | public function getChannelUrl(): ?string 116 | { 117 | return $this->get('channel_url'); 118 | } 119 | 120 | public function getChannelFollowerCount(): ?int 121 | { 122 | return $this->get('channel_follower_count'); 123 | } 124 | 125 | public function getLocation(): ?string 126 | { 127 | return $this->get('location'); 128 | } 129 | 130 | public function getDuration(): ?float 131 | { 132 | return $this->get('duration'); 133 | } 134 | 135 | public function getViewCount(): ?int 136 | { 137 | return $this->get('view_count'); 138 | } 139 | 140 | public function getLikeCount(): ?int 141 | { 142 | return $this->get('like_count'); 143 | } 144 | 145 | public function getDislikeCount(): ?int 146 | { 147 | return $this->get('dislike_count'); 148 | } 149 | 150 | public function getRepostCount(): ?int 151 | { 152 | return $this->get('repost_count'); 153 | } 154 | 155 | public function getAverageRating(): ?float 156 | { 157 | return $this->get('average_rating'); 158 | } 159 | 160 | public function getCommentCount(): ?int 161 | { 162 | return $this->get('comment_count'); 163 | } 164 | 165 | public function getAgeLimit(): ?int 166 | { 167 | return $this->get('age_limit'); 168 | } 169 | 170 | public function getIsLive(): bool 171 | { 172 | return $this->get('is_live', false); 173 | } 174 | 175 | public function getStartTime(): ?int 176 | { 177 | return $this->get('start_time'); 178 | } 179 | 180 | public function getEndTime(): ?int 181 | { 182 | return $this->get('end_time'); 183 | } 184 | 185 | public function getFormat(): ?string 186 | { 187 | return $this->get('format'); 188 | } 189 | 190 | public function getFormatId(): ?string 191 | { 192 | return $this->get('format_id'); 193 | } 194 | 195 | public function getFormatNote(): ?string 196 | { 197 | return $this->get('format_note'); 198 | } 199 | 200 | public function getWidth(): ?int 201 | { 202 | return $this->get('width'); 203 | } 204 | 205 | public function getHeight(): ?int 206 | { 207 | return $this->get('height'); 208 | } 209 | 210 | public function getResolution(): ?string 211 | { 212 | return $this->get('resolution'); 213 | } 214 | 215 | public function getTbr(): ?float 216 | { 217 | return $this->get('tbr'); 218 | } 219 | 220 | public function getAbr(): ?int 221 | { 222 | return $this->get('abr'); 223 | } 224 | 225 | public function getAcodec(): ?string 226 | { 227 | return $this->get('acodec'); 228 | } 229 | 230 | public function getAsr(): ?int 231 | { 232 | return $this->get('asr'); 233 | } 234 | 235 | public function getVbr(): ?string 236 | { 237 | return $this->get('vbr'); 238 | } 239 | 240 | public function getFps(): ?float 241 | { 242 | return $this->get('fps'); 243 | } 244 | 245 | public function getVcodec(): ?string 246 | { 247 | return $this->get('vcodec'); 248 | } 249 | 250 | public function getContainer(): ?string 251 | { 252 | return $this->get('container'); 253 | } 254 | 255 | public function getFilesize(): ?int 256 | { 257 | return $this->get('filesize'); 258 | } 259 | 260 | public function getFilesizeApprox(): ?int 261 | { 262 | return $this->get('filesize_approx'); 263 | } 264 | 265 | public function getProtocol(): ?string 266 | { 267 | return $this->get('protocol'); 268 | } 269 | 270 | public function getExtractor(): ?string 271 | { 272 | return $this->get('extractor'); 273 | } 274 | 275 | public function getExtractorKey(): ?string 276 | { 277 | return $this->get('extractor_key'); 278 | } 279 | 280 | public function getEpoch(): ?int 281 | { 282 | return $this->get('epoch'); 283 | } 284 | 285 | public function getAutoNumber(): ?int 286 | { 287 | return $this->get('autonumber'); 288 | } 289 | 290 | public function getPlaylist(): ?string 291 | { 292 | return $this->get('playlist'); 293 | } 294 | 295 | public function getPlaylistIndex(): ?int 296 | { 297 | return $this->get('playlist_index'); 298 | } 299 | 300 | public function getPlaylistId(): ?string 301 | { 302 | return $this->get('playlist_id'); 303 | } 304 | 305 | public function getPlaylistTitle(): ?string 306 | { 307 | return $this->get('playlist_title'); 308 | } 309 | 310 | public function getPlaylistUploader(): ?string 311 | { 312 | return $this->get('playlist_uploader'); 313 | } 314 | 315 | public function getPlaylistUploaderId(): ?string 316 | { 317 | return $this->get('playlist_uploader_id'); 318 | } 319 | 320 | public function getChapter(): ?string 321 | { 322 | return $this->get('chapter'); 323 | } 324 | 325 | public function getChapterNumber(): ?int 326 | { 327 | return $this->get('chapter_number'); 328 | } 329 | 330 | public function getChapterId(): ?string 331 | { 332 | return $this->get('chapter_id'); 333 | } 334 | 335 | public function getSeries(): ?string 336 | { 337 | return $this->get('series'); 338 | } 339 | 340 | public function getSeason(): ?string 341 | { 342 | return $this->get('season'); 343 | } 344 | 345 | public function getSeasonNumber(): ?int 346 | { 347 | return $this->get('season_number'); 348 | } 349 | 350 | public function getSeasonId(): ?string 351 | { 352 | return $this->get('season_id'); 353 | } 354 | 355 | public function getEpisode(): ?string 356 | { 357 | return $this->get('episode'); 358 | } 359 | 360 | public function getEpisodeNumber(): ?int 361 | { 362 | return $this->get('episode_number'); 363 | } 364 | 365 | public function getEpisodeId(): ?string 366 | { 367 | return $this->get('episode_id'); 368 | } 369 | 370 | public function getTrack(): ?string 371 | { 372 | return $this->get('track'); 373 | } 374 | 375 | public function getTrackNumber(): ?int 376 | { 377 | return $this->get('track_number'); 378 | } 379 | 380 | public function getTrackId(): ?string 381 | { 382 | return $this->get('track_id'); 383 | } 384 | 385 | public function getArtist(): ?string 386 | { 387 | return $this->get('artist'); 388 | } 389 | 390 | public function getGenre(): ?string 391 | { 392 | return $this->get('genre'); 393 | } 394 | 395 | public function getAlbum(): ?string 396 | { 397 | return $this->get('album'); 398 | } 399 | 400 | public function getAlbumType(): ?string 401 | { 402 | return $this->get('album_type'); 403 | } 404 | 405 | public function getAlbumArtist(): ?string 406 | { 407 | return $this->get('album_artist'); 408 | } 409 | 410 | public function getDiscNumber(): ?int 411 | { 412 | return $this->get('disc_number'); 413 | } 414 | 415 | public function getReleaseYear(): ?string 416 | { 417 | return $this->get('release_year'); 418 | } 419 | 420 | /** 421 | * @return array 422 | */ 423 | public function getHttpHeaders(): array 424 | { 425 | return $this->get('http_headers', []); 426 | } 427 | 428 | public function getFilename(): ?string 429 | { 430 | return $this->get('_filename'); 431 | } 432 | 433 | /** 434 | * @return list 435 | */ 436 | public function getSubtitles(): array 437 | { 438 | return $this->get('subtitles', []); 439 | } 440 | 441 | /** 442 | * @return list 443 | */ 444 | public function getRequestedSubtitles(): array 445 | { 446 | return $this->get('requested_subtitles', []); 447 | } 448 | 449 | /** 450 | * @return list 451 | */ 452 | public function getAutomaticCaptions(): array 453 | { 454 | return $this->get('automatic_captions', []); 455 | } 456 | 457 | public function getWebpageUrlBasename(): ?string 458 | { 459 | return $this->get('webpage_url_basename'); 460 | } 461 | 462 | public function getDescription(): ?string 463 | { 464 | return $this->get('description'); 465 | } 466 | 467 | public function getStretchedRatio(): ?float 468 | { 469 | return $this->get('stretched_ratio'); 470 | } 471 | 472 | /** 473 | * @return list 474 | */ 475 | public function getCategories(): array 476 | { 477 | return $this->get('categories', []); 478 | } 479 | 480 | /** 481 | * @return list 482 | */ 483 | public function getThumbnails(): array 484 | { 485 | return $this->get('thumbnails', []); 486 | } 487 | 488 | public function getAnnotations(): ?SimpleXMLElement 489 | { 490 | return $this->get('annotations'); 491 | } 492 | 493 | public function getWebpageUrl(): ?string 494 | { 495 | return $this->get('webpage_url'); 496 | } 497 | 498 | /** 499 | * @return list 500 | */ 501 | public function getFormats(): array 502 | { 503 | return $this->get('formats', []); 504 | } 505 | 506 | /** 507 | * @return list 508 | */ 509 | public function getRequestedFormats(): array 510 | { 511 | return $this->get('requested_formats', []); 512 | } 513 | 514 | public function getNEntries(): ?int 515 | { 516 | return $this->get('n_entries'); 517 | } 518 | 519 | public function getPreference(): ?int 520 | { 521 | return $this->get('preference'); 522 | } 523 | 524 | public function getFile(): SplFileInfo 525 | { 526 | return $this->get('file'); 527 | } 528 | 529 | public function getMetadataFile(): SplFileInfo 530 | { 531 | return $this->get('metadataFile'); 532 | } 533 | 534 | /** 535 | * @return list 536 | */ 537 | public function getComments(): array 538 | { 539 | return $this->get('comments', []); 540 | } 541 | 542 | /** 543 | * @return list 544 | */ 545 | public function getTags(): array 546 | { 547 | return $this->get('tags', []); 548 | } 549 | 550 | public function toJson(int $options = JSON_THROW_ON_ERROR): string 551 | { 552 | $data = $this->toArray(); 553 | unset($data['file']); 554 | unset($data['metadataFile']); 555 | 556 | $json = json_encode($data, $options); 557 | 558 | if ($json === false) { 559 | throw new JsonException(json_last_error_msg()); 560 | } 561 | 562 | return $json; 563 | } 564 | 565 | protected function convert(array $data): array 566 | { 567 | $data = parent::convert($data); 568 | 569 | if (($data['release_date'] ?? null) !== null) { 570 | $data['release_date'] = DateTimeImmutable::createFromFormat('!Ymd', $data['release_date']); 571 | } 572 | 573 | if (($data['upload_date'] ?? null) !== null) { 574 | $data['upload_date'] = DateTimeImmutable::createFromFormat('!Ymd', $data['upload_date']); 575 | } 576 | 577 | if (!empty($data['annotations'])) { 578 | $data['annotations'] = $this->convertAnnotations($data['annotations']); 579 | } 580 | 581 | return $data; 582 | } 583 | 584 | private function convertAnnotations(string $data): ?SimpleXMLElement 585 | { 586 | try { 587 | libxml_use_internal_errors(true); 588 | 589 | $obj = new SimpleXMLElement($data); 590 | libxml_clear_errors(); 591 | 592 | return $obj; 593 | } catch (Exception $e) { 594 | // If for some reason annotations can't be mapped then just ignore this 595 | } 596 | 597 | return null; 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/Entity/VideoCollection.php: -------------------------------------------------------------------------------- 1 | elements; 20 | } 21 | 22 | protected function convert(array $data): array 23 | { 24 | return $data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Exception/ExecutableNotFoundException.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function read(string $file): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 94 | */ 95 | private array $playlistItems = []; 96 | private ?string $matchTitle = null; 97 | private ?string $rejectTitle = null; 98 | private ?int $maxDownloads = null; 99 | private ?string $minFileSize = null; 100 | private ?string $maxFileSize = null; 101 | private ?string $date = null; 102 | private ?string $dateBefore = null; 103 | private ?string $dateAfter = null; 104 | private ?int $minViews = null; 105 | private ?int $maxViews = null; 106 | private ?string $matchFilter = null; 107 | private bool $noPlaylist = false; 108 | private bool $yesPlaylist = false; 109 | private ?int $ageLimit = null; 110 | 111 | // Download Options 112 | private ?string $limitRate = null; 113 | private ?string $retries = null; 114 | private ?string $fragmentRetries = null; 115 | private bool $skipUnavailableFragments = false; 116 | private bool $keepFragments = false; 117 | private ?string $bufferSize = null; 118 | private bool $noResizeBuffer = false; 119 | private ?string $httpChunkSize = null; 120 | private bool $playlistReverse = false; 121 | private bool $playlistRandom = false; 122 | private ?string $xattrSetFilesize = null; 123 | private bool $hlsPreferNative = false; 124 | private bool $hlsPreferFFmpeg = false; 125 | private bool $hlsUseMpegts = false; 126 | private ?string $externalDownloader = null; 127 | private ?string $externalDownloaderArgs = null; 128 | private ?string $downloadSections = null; 129 | private ?int $concurrentFragments = null; 130 | 131 | // Filesystem Options 132 | private ?string $batchFile = null; 133 | private bool $id = false; 134 | private string $output = '%(title)s-%(id)s.%(ext)s'; 135 | private ?int $autoNumberStart = null; 136 | private bool $restrictFilenames = false; 137 | private bool $windowsFilenames = false; 138 | private bool $noOverwrites = false; 139 | private bool $continue = false; 140 | private bool $noContinue = false; 141 | private bool $noPart = false; 142 | private bool $noMtime = false; 143 | private bool $writeDescription = false; 144 | private bool $writeAnnotations = false; 145 | private ?string $loadInfoJson = null; 146 | private ?string $cookies = null; 147 | private ?string $cacheDir = null; 148 | private bool $noCacheDir = false; 149 | private bool $rmCacheDir = false; 150 | 151 | // Thumbnail Images Options 152 | private bool $writeThumbnail = false; 153 | private bool $writeAllThumbnails = false; 154 | private ?string $convertThumbnail = null; 155 | 156 | // Verbosity / Simulation Options 157 | private bool $skipDownload = false; 158 | private bool $verbose = false; 159 | private bool $writePages = false; 160 | private bool $printTraffic = false; 161 | private bool $callHome = false; 162 | private bool $noCallHome = false; 163 | 164 | // Workaround Options 165 | private ?string $encoding = null; 166 | private bool $noCheckCertificate = false; 167 | private bool $preferInsecure = false; 168 | private ?string $userAgent = null; 169 | private ?string $referer = null; 170 | /** 171 | * @var array 172 | */ 173 | private array $headers = []; 174 | private ?int $sleepInterval = null; 175 | private ?int $maxSleepInterval = null; 176 | 177 | // Video Format Options 178 | private ?string $format = null; 179 | /** 180 | * @var list 181 | */ 182 | private array $formatSort = []; 183 | private ?bool $formatSortForce = null; 184 | private ?bool $videoMultistreams = null; 185 | private ?bool $audioMultistreams = null; 186 | private ?bool $preferFreeFormats = null; 187 | private ?bool $checkFormats = null; 188 | private ?bool $checkAllFormats = null; 189 | private bool $youtubeSkipDashManifest = false; 190 | private ?string $mergeOutputFormat = null; 191 | 192 | // Subtitle Options 193 | private ?bool $writeSub = false; 194 | private ?bool $writeAutoSub = false; 195 | private ?bool $allSubs = false; 196 | private ?string $subFormat = null; 197 | /** 198 | * @var list 199 | */ 200 | private array $subLang = []; 201 | 202 | // Authentication Options 203 | private ?string $username = null; 204 | private ?string $password = null; 205 | private ?string $twoFactor = null; 206 | private bool $netrc = false; 207 | private ?string $videoPassword = null; 208 | 209 | // Adobe Pass Options 210 | private ?string $apMso = null; 211 | private ?string $apUsername = null; 212 | private ?string $apPassword = null; 213 | 214 | // Post-processing Options 215 | private bool $extractAudio = false; 216 | private ?string $audioFormat = null; 217 | private ?string $audioQuality = null; 218 | private ?string $remuxVideo = null; 219 | private ?string $recodeVideo = null; 220 | private ?string $postProcessorArgs = null; 221 | private bool $keepVideo = false; 222 | private bool $noPostOverwrites = false; 223 | private bool $embedSubs = false; 224 | private bool $embedThumbnail = false; 225 | private bool $addMetadata = false; 226 | private ?string $metadataFromTitle = null; 227 | private bool $xattrs = false; 228 | private bool $preferAvconv = false; 229 | private bool $preferFFmpeg = false; 230 | private ?string $ffmpegLocation = null; 231 | private ?string $exec = null; 232 | private ?string $convertSubsFormat = null; 233 | private bool $forceKeyframesAtCuts = false; 234 | 235 | // Extractor Options 236 | /** 237 | * @var int|'infinite'|null 238 | */ 239 | private $extractorRetries; 240 | private bool $allowDynamicMpd = false; 241 | private bool $hlsSplitDiscontinuity = false; 242 | /** 243 | * @var array 244 | */ 245 | private array $extractorArgs = []; 246 | 247 | /** 248 | * @var list 249 | */ 250 | private array $url = []; 251 | 252 | private function __construct() 253 | { 254 | } 255 | 256 | /** 257 | * Configure where to store downloads. 258 | * Arguments from `output` template also are available. 259 | */ 260 | public function downloadPath(string $downloadPath): self 261 | { 262 | $new = clone $this; 263 | $new->downloadPath = rtrim($downloadPath, '\/'); 264 | 265 | return $new; 266 | } 267 | 268 | public function getDownloadPath(): ?string 269 | { 270 | return $this->downloadPath; 271 | } 272 | 273 | public function cleanupMetadata(bool $cleanup): self 274 | { 275 | $new = clone $this; 276 | $new->cleanupMetadata = $cleanup; 277 | 278 | return $new; 279 | } 280 | 281 | public function getCleanupMetadata(): bool 282 | { 283 | return $this->cleanupMetadata; 284 | } 285 | 286 | /** 287 | * Use the specified HTTP/HTTPS/SOCKS proxy. To enable SOCKS proxy, specify 288 | * a proper scheme. For example socks5://127.0.0.1:1080/. Pass in an empty 289 | * string (--proxy "") for direct connection. 290 | */ 291 | public function proxy(?string $proxy): self 292 | { 293 | $new = clone $this; 294 | $new->proxy = $proxy; 295 | 296 | return $new; 297 | } 298 | 299 | /** 300 | * Time to wait before giving up, in seconds. 301 | */ 302 | public function socketTimeout(?int $socketTimeout): self 303 | { 304 | $new = clone $this; 305 | $new->socketTimeout = $socketTimeout; 306 | 307 | return $new; 308 | } 309 | 310 | /** 311 | * Client-side IP address to bind to. 312 | */ 313 | public function sourceAddress(?string $sourceAddress): self 314 | { 315 | $new = clone $this; 316 | $new->sourceAddress = $sourceAddress; 317 | 318 | return $new; 319 | } 320 | 321 | /** 322 | * Make all connections via IPv4. 323 | */ 324 | public function forceIpV4(): self 325 | { 326 | $new = clone $this; 327 | $new->forceIpV6 = false; 328 | $new->forceIpV4 = true; 329 | 330 | return $new; 331 | } 332 | 333 | /** 334 | * Make all connections via IPv6. 335 | */ 336 | public function forceIpV6(): self 337 | { 338 | $new = clone $this; 339 | $new->forceIpV4 = false; 340 | $new->forceIpV6 = true; 341 | 342 | return $new; 343 | } 344 | 345 | /** 346 | * Use this proxy to verify the IP address for some geo-restricted sites. 347 | * The default proxy specified by --proxy (or none, if the option is not 348 | * present) is used for the actual downloading. 349 | */ 350 | public function geoVerificationProxy(?string $geoVerificationProxy): self 351 | { 352 | $new = clone $this; 353 | $new->geoVerificationProxy = $geoVerificationProxy; 354 | 355 | return $new; 356 | } 357 | 358 | /** 359 | * Bypass geographic restriction via faking X-Forwarded-For HTTP header. 360 | */ 361 | public function geoByPass(): self 362 | { 363 | $new = clone $this; 364 | $new->geoBypass = true; 365 | 366 | return $new; 367 | } 368 | 369 | /** 370 | * Do not bypass geographic restriction via faking X-Forwarded-For HTTP 371 | * header. 372 | */ 373 | public function noGeoBypass(): self 374 | { 375 | $new = clone $this; 376 | $new->noGeoBypass = true; 377 | 378 | return $new; 379 | } 380 | 381 | /** 382 | * Force bypass geographic restriction with explicitly provided two-letter 383 | * ISO 3166-2 country code. 384 | */ 385 | public function geoBypassCountry(?string $code): self 386 | { 387 | $new = clone $this; 388 | $new->geoBypassCountry = $code; 389 | 390 | return $new; 391 | } 392 | 393 | /** 394 | * Force bypass geographic restriction with explicitly provided IP block in 395 | * CIDR notation. 396 | */ 397 | public function geoBypassIpBlock(?string $ipBlock): self 398 | { 399 | $new = clone $this; 400 | $new->geoBypassIpBlock = $ipBlock; 401 | 402 | return $new; 403 | } 404 | 405 | /** 406 | * Playlist video to start at (default is 1). 407 | */ 408 | public function playlistStart(?int $playlistStart): self 409 | { 410 | $new = clone $this; 411 | $new->playlistStart = $playlistStart; 412 | 413 | return $new; 414 | } 415 | 416 | /** 417 | * Playlist video to end at (default is last). 418 | */ 419 | public function playlistEnd(?int $playlistEnd): self 420 | { 421 | $new = clone $this; 422 | $new->playlistEnd = $playlistEnd; 423 | 424 | return $new; 425 | } 426 | 427 | /** 428 | * Playlist video items to download. Specify indices of the videos in the 429 | * playlist like: [1, 2, 5, 8]. If you want to download videos indexed 1, 2, 430 | * 5, 8 in the playlist. You can specify range: ['1-3', '7', '10-13'], it 431 | * will download the videos at index 1, 2, 3, 7, 10, 11, 12 and 13. 432 | * 433 | * @param list $playlistItems 434 | */ 435 | public function playlistItems(array $playlistItems): self 436 | { 437 | $new = clone $this; 438 | $new->playlistItems = $playlistItems; 439 | 440 | return $new; 441 | } 442 | 443 | /** 444 | * Download only matching titles (regex or caseless sub-string). 445 | */ 446 | public function matchTitle(?string $title): self 447 | { 448 | $new = clone $this; 449 | $new->matchTitle = $title; 450 | 451 | return $new; 452 | } 453 | 454 | /** 455 | * Skip download for matching titles (regex or caseless sub-string). 456 | */ 457 | public function rejectTitle(?string $title): self 458 | { 459 | $new = clone $this; 460 | $new->rejectTitle = $title; 461 | 462 | return $new; 463 | } 464 | 465 | /** 466 | * Abort after downloading NUMBER files. 467 | */ 468 | public function maxDownloads(?int $maxDownloads): self 469 | { 470 | $new = clone $this; 471 | $new->maxDownloads = $maxDownloads; 472 | 473 | return $new; 474 | } 475 | 476 | /** 477 | * Do not download any videos smaller than `$size` (e.g. 50k or 44.6m). 478 | */ 479 | public function minFileSize(?string $size): self 480 | { 481 | $new = clone $this; 482 | $new->minFileSize = $size; 483 | 484 | return $new; 485 | } 486 | 487 | /** 488 | * Do not download any videos larger than `$size` (e.g. 50k or 44.6m). 489 | */ 490 | public function maxFileSize(?string $size): self 491 | { 492 | $new = clone $this; 493 | $new->maxFileSize = $size; 494 | 495 | return $new; 496 | } 497 | 498 | /** 499 | * Download only videos uploaded in this date. 500 | */ 501 | public function date(?DateTimeInterface $date): self 502 | { 503 | $new = clone $this; 504 | $new->date = $date !== null ? $date->format('Ymd') : null; 505 | 506 | return $new; 507 | } 508 | 509 | /** 510 | * Download only videos uploaded on or before this date (i.e. inclusive). 511 | */ 512 | public function dateBefore(?DateTimeInterface $before): self 513 | { 514 | $new = clone $this; 515 | $new->dateBefore = $before !== null ? $before->format('Ymd') : null; 516 | 517 | return $new; 518 | } 519 | 520 | /** 521 | * Download only videos uploaded on or after this date (i.e. inclusive). 522 | */ 523 | public function dateAfter(?DateTimeInterface $after): self 524 | { 525 | $new = clone $this; 526 | $new->dateAfter = $after !== null ? $after->format('Ymd') : $after; 527 | 528 | return $new; 529 | } 530 | 531 | /** 532 | * Do not download any videos with less than `$count` views. 533 | */ 534 | public function minViews(?int $count): self 535 | { 536 | $new = clone $this; 537 | $new->minViews = $count; 538 | 539 | return $new; 540 | } 541 | 542 | /** 543 | * Do not download any videos with more than `$count` views. 544 | */ 545 | public function maxViews(?int $count): self 546 | { 547 | $new = clone $this; 548 | $new->maxViews = $count; 549 | 550 | return $new; 551 | } 552 | 553 | /** 554 | * Generic video filter. Specify any key (see the "OUTPUT TEMPLATE" for a 555 | * list of available keys) to match if the key is present, !key to check if 556 | * the key is not present, key > NUMBER (like "comment_count > 12", also 557 | * works with >=, <, <=, !=, =) to compare against a number, key = 'LITERAL' 558 | * (like "uploader = 'Mike Smith'", also works with !=) to match against a 559 | * string literal and & to require multiple matches. Values which are not 560 | * known are excluded unless you put a question mark (?) after the operator. 561 | * For example, to only match videos that have been liked more than 100 562 | * times and disliked less than 50 times (or the dislike functionality is 563 | * not available at the given service), but who also have a description, use 564 | * --match-filter "like_count > 100 & dislike_count matchFilter = $filter; 572 | 573 | return $new; 574 | } 575 | 576 | /** 577 | * Download only the video, if the URL refers to a video and a playlist. 578 | */ 579 | public function noPlaylist(): self 580 | { 581 | $new = clone $this; 582 | $new->yesPlaylist = false; 583 | $new->noPlaylist = true; 584 | 585 | return $new; 586 | } 587 | 588 | /** 589 | * Download the playlist, if the URL refers to a video and a playlist. 590 | */ 591 | public function yesPlaylist(): self 592 | { 593 | $new = clone $this; 594 | $new->noPlaylist = false; 595 | $new->yesPlaylist = true; 596 | 597 | return $new; 598 | } 599 | 600 | /** 601 | * Download only videos suitable for the given age. 602 | */ 603 | public function ageLimit(?int $ageLimit): self 604 | { 605 | $new = clone $this; 606 | $new->ageLimit = $ageLimit; 607 | 608 | return $new; 609 | } 610 | 611 | /** 612 | * Maximum download rate in bytes per second (e.g. 50K or 4.2M). 613 | */ 614 | public function limitRate(?string $limitRate): self 615 | { 616 | $new = clone $this; 617 | $new->limitRate = $limitRate; 618 | 619 | return $new; 620 | } 621 | 622 | /** 623 | * Number of retries (default is 10), or "infinite". 624 | */ 625 | public function retries(?string $retries): self 626 | { 627 | $new = clone $this; 628 | $new->retries = $retries; 629 | 630 | return $new; 631 | } 632 | 633 | /** 634 | * Number of retries for a fragment (default is 10), or "infinite" 635 | * (DASH, hlsnative and ISM). 636 | */ 637 | public function fragmentRetries(?string $fragmentRetries): self 638 | { 639 | $new = clone $this; 640 | $new->fragmentRetries = $fragmentRetries; 641 | 642 | return $new; 643 | } 644 | 645 | /** 646 | * Skip unavailable fragments (DASH, hlsnative and ISM). 647 | */ 648 | public function skipUnavailableFragments(bool $skipUnavailableFragments): self 649 | { 650 | $new = clone $this; 651 | $new->skipUnavailableFragments = $skipUnavailableFragments; 652 | 653 | return $new; 654 | } 655 | 656 | /** 657 | * Keep downloaded fragments on disk after downloading is finished; 658 | * fragments are erased by default. 659 | */ 660 | public function keepFragments(bool $keepFragments): self 661 | { 662 | $new = clone $this; 663 | $new->keepFragments = $keepFragments; 664 | 665 | return $new; 666 | } 667 | 668 | /** 669 | * Size of download buffer (e.g. 1024 or 16K) (default is 1024). 670 | */ 671 | public function bufferSize(?string $bufferSize): self 672 | { 673 | $new = clone $this; 674 | $new->bufferSize = $bufferSize; 675 | 676 | return $new; 677 | } 678 | 679 | /** 680 | * Do not automatically adjust the buffer size. By default, the buffer size 681 | * is automatically resized from an initial value of SIZE. 682 | */ 683 | public function noResizeBuffer(bool $noResizeBuffer): self 684 | { 685 | $new = clone $this; 686 | $new->noResizeBuffer = $noResizeBuffer; 687 | 688 | return $new; 689 | } 690 | 691 | /** 692 | * Size of a chunk for chunk-based HTTP downloading (e.g. 10485760 or 10M) 693 | * (default is disabled). May be useful for bypassing bandwidth throttling 694 | * imposed by a webserver (experimental). 695 | */ 696 | public function httpChunkSize(?string $httpChunkSize): self 697 | { 698 | $new = clone $this; 699 | $new->httpChunkSize = $httpChunkSize; 700 | 701 | return $new; 702 | } 703 | 704 | /** 705 | * Download playlist videos in reverse order. 706 | */ 707 | public function playlistReverse(bool $playlistReverse): self 708 | { 709 | $new = clone $this; 710 | $new->playlistReverse = $playlistReverse; 711 | if ($playlistReverse) { 712 | $new->playlistRandom = false; 713 | } 714 | 715 | return $new; 716 | } 717 | 718 | /** 719 | * Download playlist videos in random order. 720 | */ 721 | public function playlistRandom(bool $playlistRandom): self 722 | { 723 | $new = clone $this; 724 | $new->playlistRandom = $playlistRandom; 725 | if ($playlistRandom) { 726 | $new->playlistReverse = false; 727 | } 728 | 729 | return $new; 730 | } 731 | 732 | /** 733 | * Set file xattribute ytdl.filesize with expected file size. 734 | */ 735 | public function xattrSetFilesize(?string $xattrSetFilesize): self 736 | { 737 | $new = clone $this; 738 | $new->xattrSetFilesize = $xattrSetFilesize; 739 | 740 | return $new; 741 | } 742 | 743 | /** 744 | * Use the native HLS downloader instead of ffmpeg. 745 | */ 746 | public function hlsPreferNative(bool $hlsPreferNative): self 747 | { 748 | $new = clone $this; 749 | $new->hlsPreferNative = $hlsPreferNative; 750 | 751 | return $new; 752 | } 753 | 754 | /** 755 | * Use ffmpeg instead of the native HLS downloader. 756 | */ 757 | public function hlsPreferFFmpeg(bool $hlsPreferFFmpeg): self 758 | { 759 | $new = clone $this; 760 | $new->hlsPreferFFmpeg = $hlsPreferFFmpeg; 761 | 762 | return $new; 763 | } 764 | 765 | /** 766 | * Use the mpegts container for HLS videos, allowing to play the video while 767 | * downloading (some players may not be able to play it). 768 | */ 769 | public function hlsUseMpegts(bool $hlsUseMpegts): self 770 | { 771 | $new = clone $this; 772 | $new->hlsUseMpegts = $hlsUseMpegts; 773 | 774 | return $new; 775 | } 776 | 777 | /** 778 | * Use the specified external downloader. 779 | * Currently supports: aria2c, avconv, axel, curl, ffmpeg, httpie, wget. 780 | */ 781 | public function externalDownloader(?string $externalDownloader): self 782 | { 783 | $new = clone $this; 784 | $new->externalDownloader = $externalDownloader; 785 | 786 | return $new; 787 | } 788 | 789 | /** 790 | * Give these arguments to the external downloader. 791 | */ 792 | public function externalDownloaderArgs(?string $externalDownloaderArgs): self 793 | { 794 | $new = clone $this; 795 | $new->externalDownloaderArgs = $externalDownloaderArgs; 796 | 797 | return $new; 798 | } 799 | 800 | /** 801 | * File containing URLs to download ('-' for stdin), one URL per line. Lines 802 | * starting with '#', ';' or ']' are considered as comments and ignored. 803 | */ 804 | public function batchFile(?string $batchFile): self 805 | { 806 | $new = clone $this; 807 | $new->batchFile = $batchFile; 808 | 809 | return $new; 810 | } 811 | 812 | /** 813 | * Use only video ID in file name. 814 | */ 815 | public function id(bool $id): self 816 | { 817 | $new = clone $this; 818 | $new->id = $id; 819 | 820 | return $new; 821 | } 822 | 823 | /** 824 | * Output filename template. 825 | * 826 | * @see https://github.com/ytdl-org/youtube-dl#output-template 827 | */ 828 | public function output(string $output): self 829 | { 830 | if (str_contains($output, '/') || str_contains($output, '\\')) { 831 | throw new InvalidArgumentException('Providing download path via `output` option is prohibited. Set the download path when creating Options object or calling `downloadPath` method.'); 832 | } 833 | 834 | $new = clone $this; 835 | $new->output = $output; 836 | 837 | return $new; 838 | } 839 | 840 | /** 841 | * Specify the start value for %(autonumber)s (default is 1). 842 | */ 843 | public function autoNumberStart(?int $autoNumberStart): self 844 | { 845 | $new = clone $this; 846 | $new->autoNumberStart = $autoNumberStart; 847 | 848 | return $new; 849 | } 850 | 851 | /** 852 | * Restrict filenames to only ASCII characters, and avoid "&" and spaces in 853 | * filenames. 854 | */ 855 | public function restrictFileNames(bool $restrictFilenames): self 856 | { 857 | $new = clone $this; 858 | $new->restrictFilenames = $restrictFilenames; 859 | 860 | return $new; 861 | } 862 | 863 | /** 864 | * Force filenames to be Windows-compatible. 865 | */ 866 | public function windowsFilenames(bool $windowsFilenames): self 867 | { 868 | $new = clone $this; 869 | $new->windowsFilenames = $windowsFilenames; 870 | 871 | return $new; 872 | } 873 | 874 | /** 875 | * Do not overwrite files. 876 | */ 877 | public function noOverwrites(bool $noOverwrites): self 878 | { 879 | $new = clone $this; 880 | $new->noOverwrites = $noOverwrites; 881 | 882 | return $new; 883 | } 884 | 885 | /** 886 | * Force resume of partially downloaded files. By default, youtube-dl will 887 | * resume downloads if possible. 888 | */ 889 | public function continue(bool $continue): self 890 | { 891 | $new = clone $this; 892 | $new->continue = $continue; 893 | if ($continue) { 894 | $new->noContinue = false; 895 | } 896 | 897 | return $new; 898 | } 899 | 900 | /** 901 | * Do not resume partially downloaded files (restart from beginning). 902 | */ 903 | public function noContinue(bool $noContinue): self 904 | { 905 | $new = clone $this; 906 | $new->noContinue = $noContinue; 907 | if ($noContinue) { 908 | $new->continue = false; 909 | } 910 | 911 | return $new; 912 | } 913 | 914 | /** 915 | * Do not use .part files - write directly into output file. 916 | */ 917 | public function noPart(bool $noPart): self 918 | { 919 | $new = clone $this; 920 | $new->noPart = $noPart; 921 | 922 | return $new; 923 | } 924 | 925 | /** 926 | * Do not use the Last-modified header to set the file modification time. 927 | */ 928 | public function noMtime(bool $noMtime): self 929 | { 930 | $new = clone $this; 931 | $new->noMtime = $noMtime; 932 | 933 | return $new; 934 | } 935 | 936 | /** 937 | * Write video description to a .description file. 938 | */ 939 | public function writeDescription(bool $writeDescription): self 940 | { 941 | $new = clone $this; 942 | $new->writeDescription = $writeDescription; 943 | 944 | return $new; 945 | } 946 | 947 | /** 948 | * Write video annotations to a .annotations.xml file. 949 | */ 950 | public function writeAnnotations(bool $writeAnnotations): self 951 | { 952 | $new = clone $this; 953 | $new->writeAnnotations = $writeAnnotations; 954 | 955 | return $new; 956 | } 957 | 958 | /** 959 | * JSON file containing the video information (created with the 960 | * "--write-info-json" option). 961 | */ 962 | public function loadInfoJson(?string $loadInfoJson): self 963 | { 964 | $new = clone $this; 965 | $new->loadInfoJson = $loadInfoJson; 966 | 967 | return $new; 968 | } 969 | 970 | /** 971 | * File to read cookies from and dump cookie jar in. 972 | */ 973 | public function cookies(?string $cookies): self 974 | { 975 | $new = clone $this; 976 | $new->cookies = $cookies; 977 | 978 | return $new; 979 | } 980 | 981 | /** 982 | * Location in the filesystem where youtube-dl can store some downloaded 983 | * information permanently. By default $XDG_CACHE_HOME/youtube-dl or 984 | * ~/.cache/youtube-dl . At the moment, only YouTube player files (for 985 | * videos with obfuscated signatures) are cached, but that may change. 986 | */ 987 | public function cacheDir(?string $cacheDir): self 988 | { 989 | $new = clone $this; 990 | $new->cacheDir = $cacheDir; 991 | 992 | return $new; 993 | } 994 | 995 | /** 996 | * Disable filesystem caching. 997 | */ 998 | public function noCacheDir(bool $noCacheDir): self 999 | { 1000 | $new = clone $this; 1001 | $new->noCacheDir = $noCacheDir; 1002 | 1003 | return $new; 1004 | } 1005 | 1006 | /** 1007 | * Delete all filesystem cache files. 1008 | */ 1009 | public function rmCacheDir(bool $rmCacheDir): self 1010 | { 1011 | $new = clone $this; 1012 | $new->rmCacheDir = $rmCacheDir; 1013 | 1014 | return $new; 1015 | } 1016 | 1017 | /** 1018 | * Write thumbnail image to disk. 1019 | */ 1020 | public function writeThumbnail(bool $writeThumbnail): self 1021 | { 1022 | $new = clone $this; 1023 | $new->writeThumbnail = $writeThumbnail; 1024 | 1025 | return $new; 1026 | } 1027 | 1028 | /** 1029 | * Convert thumbnail to another format. 1030 | * 1031 | * @param 'jpg'|'png'|'webp'|null $format 1032 | */ 1033 | public function convertThumbnail(?string $format): self 1034 | { 1035 | $new = clone $this; 1036 | $new->convertThumbnail = $format; 1037 | 1038 | return $new; 1039 | } 1040 | 1041 | /** 1042 | * Write all thumbnail image formats to disk. 1043 | */ 1044 | public function writeAllThumbnails(bool $writeAllThumbnails): self 1045 | { 1046 | $new = clone $this; 1047 | $new->writeAllThumbnails = $writeAllThumbnails; 1048 | 1049 | return $new; 1050 | } 1051 | 1052 | /** 1053 | * Do not download the video. 1054 | */ 1055 | public function skipDownload(bool $skipDownload): self 1056 | { 1057 | $new = clone $this; 1058 | $new->skipDownload = $skipDownload; 1059 | 1060 | return $new; 1061 | } 1062 | 1063 | public function getSkipDownload(): bool 1064 | { 1065 | return $this->skipDownload; 1066 | } 1067 | 1068 | /** 1069 | * Print various debugging information. 1070 | */ 1071 | public function verbose(bool $verbose): self 1072 | { 1073 | $new = clone $this; 1074 | $new->verbose = $verbose; 1075 | 1076 | return $new; 1077 | } 1078 | 1079 | /** 1080 | * Print downloaded pages encoded using base64 to debug problems (very 1081 | * verbose). 1082 | */ 1083 | public function writePages(bool $writePages): self 1084 | { 1085 | $new = clone $this; 1086 | $new->writePages = $writePages; 1087 | 1088 | return $new; 1089 | } 1090 | 1091 | /** 1092 | * Display sent and read HTTP traffic. 1093 | */ 1094 | public function printTraffic(bool $printTraffic): self 1095 | { 1096 | $new = clone $this; 1097 | $new->printTraffic = $printTraffic; 1098 | 1099 | return $new; 1100 | } 1101 | 1102 | /** 1103 | * Contact the youtube-dl server for debugging. 1104 | */ 1105 | public function callHome(bool $callHome): self 1106 | { 1107 | $new = clone $this; 1108 | $new->callHome = $callHome; 1109 | if ($callHome) { 1110 | $new->noCallHome = false; 1111 | } 1112 | 1113 | return $new; 1114 | } 1115 | 1116 | /** 1117 | * Do NOT contact the youtube-dl server for debugging. 1118 | */ 1119 | public function noCallHome(bool $noCallHome): self 1120 | { 1121 | $new = clone $this; 1122 | $new->noCallHome = $noCallHome; 1123 | if ($noCallHome) { 1124 | $new->callHome = false; 1125 | } 1126 | 1127 | return $new; 1128 | } 1129 | 1130 | /** 1131 | * Force the specified encoding (experimental). 1132 | */ 1133 | public function encoding(?string $encoding): self 1134 | { 1135 | $new = clone $this; 1136 | $new->encoding = $encoding; 1137 | 1138 | return $new; 1139 | } 1140 | 1141 | /** 1142 | * Suppress HTTPS certificate validation. 1143 | */ 1144 | public function noCheckCertificate(bool $noCheckCertificate): self 1145 | { 1146 | $new = clone $this; 1147 | $new->noCheckCertificate = $noCheckCertificate; 1148 | 1149 | return $new; 1150 | } 1151 | 1152 | /** 1153 | * Use an unencrypted connection to retrieve information about the video. 1154 | * (Currently supported only for YouTube). 1155 | */ 1156 | public function preferInsecure(bool $preferInsecure): self 1157 | { 1158 | $new = clone $this; 1159 | $new->preferInsecure = $preferInsecure; 1160 | 1161 | return $new; 1162 | } 1163 | 1164 | /** 1165 | * Specify a custom user agent. 1166 | */ 1167 | public function userAgent(?string $userAgent): self 1168 | { 1169 | $new = clone $this; 1170 | $new->userAgent = $userAgent; 1171 | 1172 | return $new; 1173 | } 1174 | 1175 | /** 1176 | * Specify a custom referer, use if the video access is restricted to one 1177 | * domain. 1178 | */ 1179 | public function referer(?string $referer): self 1180 | { 1181 | $new = clone $this; 1182 | $new->referer = $referer; 1183 | 1184 | return $new; 1185 | } 1186 | 1187 | /** 1188 | * @param non-empty-string $header 1189 | */ 1190 | public function header(string $header, string $value): self 1191 | { 1192 | $new = clone $this; 1193 | $new->headers[$header] = $value; 1194 | 1195 | return $new; 1196 | } 1197 | 1198 | /** 1199 | * @param array $headers 1200 | */ 1201 | public function headers(array $headers): self 1202 | { 1203 | $new = clone $this; 1204 | $new->headers = $headers; 1205 | 1206 | return $new; 1207 | } 1208 | 1209 | /** 1210 | * Number of seconds to sleep before each download when used alone or a 1211 | * lower bound of a range for randomized sleep before each download (minimum 1212 | * possible number of seconds to sleep) when used along with 1213 | * `maxSleepInterval`. 1214 | */ 1215 | public function sleepInterval(?int $sleepInterval): self 1216 | { 1217 | $new = clone $this; 1218 | $new->sleepInterval = $sleepInterval; 1219 | 1220 | return $new; 1221 | } 1222 | 1223 | /** 1224 | * Upper bound of a range for randomized sleep before each download (maximum 1225 | * possible number of seconds to sleep). Must only be used along with 1226 | * `sleepInterval`. 1227 | */ 1228 | public function maxSleepInterval(?int $maxSleepInterval): self 1229 | { 1230 | $new = clone $this; 1231 | $new->maxSleepInterval = $maxSleepInterval; 1232 | 1233 | return $new; 1234 | } 1235 | 1236 | public function format(?string $format): self 1237 | { 1238 | $new = clone $this; 1239 | $new->format = $format; 1240 | 1241 | return $new; 1242 | } 1243 | 1244 | /** 1245 | * Sort the formats by the fields given. 1246 | * 1247 | * @see https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#sorting-formats 1248 | * 1249 | * @param list $formatSort 1250 | */ 1251 | public function formatSort(array $formatSort): self 1252 | { 1253 | $new = clone $this; 1254 | $new->formatSort = $formatSort; 1255 | 1256 | return $new; 1257 | } 1258 | 1259 | /** 1260 | * Force user specified sort order to have precedence over all fields. 1261 | * 1262 | * @see https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#sorting-formats 1263 | */ 1264 | public function formatSortForce(?bool $formatSortForce): self 1265 | { 1266 | $new = clone $this; 1267 | $new->formatSortForce = $formatSortForce; 1268 | 1269 | return $new; 1270 | } 1271 | 1272 | /** 1273 | * Allow multiple video streams to be merged into a single file. 1274 | */ 1275 | public function videoMultistreams(?bool $videoMultistreams): self 1276 | { 1277 | $new = clone $this; 1278 | $new->videoMultistreams = $videoMultistreams; 1279 | 1280 | return $new; 1281 | } 1282 | 1283 | /** 1284 | * Allow multiple audio streams to be merged into a single file. 1285 | */ 1286 | public function audioMultistreams(?bool $audioMultistreams): self 1287 | { 1288 | $new = clone $this; 1289 | $new->audioMultistreams = $audioMultistreams; 1290 | 1291 | return $new; 1292 | } 1293 | 1294 | /** 1295 | * Prefer video formats with free containers over non-free ones of same 1296 | * quality. Use with `Options::formatSort('ext')` option to strictly prefer 1297 | * free containers irrespective of quality. 1298 | */ 1299 | public function preferFreeFormats(?bool $preferFreeFormats): self 1300 | { 1301 | $new = clone $this; 1302 | $new->preferFreeFormats = $preferFreeFormats; 1303 | 1304 | return $new; 1305 | } 1306 | 1307 | /** 1308 | * Make sure formats are selected only from those that are actually 1309 | * downloadable. 1310 | */ 1311 | public function checkFormats(?bool $checkFormats): self 1312 | { 1313 | $new = clone $this; 1314 | $new->checkFormats = $checkFormats; 1315 | 1316 | return $new; 1317 | } 1318 | 1319 | /** 1320 | * Check all formats for whether they are actually downloadable. 1321 | */ 1322 | public function checkAllFormats(?bool $checkAllFormats): self 1323 | { 1324 | $new = clone $this; 1325 | $new->checkAllFormats = $checkAllFormats; 1326 | 1327 | return $new; 1328 | } 1329 | 1330 | /** 1331 | * Do not download the DASH manifests and related data on YouTube videos. 1332 | */ 1333 | public function youtubeSkipDashManifest(bool $youtubeSkipDashManifest): self 1334 | { 1335 | $new = clone $this; 1336 | $new->youtubeSkipDashManifest = $youtubeSkipDashManifest; 1337 | 1338 | return $new; 1339 | } 1340 | 1341 | /** 1342 | * If a merge is required (e.g. bestvideo+bestaudio), output to given 1343 | * container format. One of mkv, mp4, ogg, webm, flv. 1344 | * Ignored if no merge is required. 1345 | * 1346 | * @phpstasn-param self::MERGE_OUTPUT_FORMAT_*|null $mergeOutputFormat 1347 | */ 1348 | public function mergeOutputFormat(?string $mergeOutputFormat): self 1349 | { 1350 | if ($mergeOutputFormat !== null && !in_array($mergeOutputFormat, static::MERGE_OUTPUT_FORMATS, true)) { 1351 | throw new InvalidArgumentException(sprintf('Option `mergeOutputFormat` expected one of: %s. Got: %s.', implode(', ', array_map(static fn ($v) => '"'.$v.'"', static::MERGE_OUTPUT_FORMATS)), '"'.$mergeOutputFormat.'"')); 1352 | } 1353 | 1354 | $new = clone $this; 1355 | $new->mergeOutputFormat = $mergeOutputFormat; 1356 | 1357 | return $new; 1358 | } 1359 | 1360 | /** 1361 | * Write subtitle file. 1362 | */ 1363 | public function writeSub(bool $writeSub): self 1364 | { 1365 | $new = clone $this; 1366 | $new->writeSub = $writeSub; 1367 | 1368 | return $new; 1369 | } 1370 | 1371 | /** 1372 | * Write automatically generated subtitle file (YouTube only). 1373 | */ 1374 | public function writeAutoSub(bool $writeAutoSub): self 1375 | { 1376 | $new = clone $this; 1377 | $new->writeAutoSub = $writeAutoSub; 1378 | 1379 | return $new; 1380 | } 1381 | 1382 | /** 1383 | * Download all the available subtitles of the video. 1384 | */ 1385 | public function allSubs(bool $allSubs): self 1386 | { 1387 | $new = clone $this; 1388 | $new->allSubs = $allSubs; 1389 | 1390 | return $new; 1391 | } 1392 | 1393 | /** 1394 | * Subtitle format, accepts formats preference, for example: "srt" or 1395 | * "ass/srt/best". 1396 | */ 1397 | public function subFormat(?string $subFormat): self 1398 | { 1399 | $new = clone $this; 1400 | $new->subFormat = $subFormat; 1401 | 1402 | return $new; 1403 | } 1404 | 1405 | /** 1406 | * Languages of the subtitles to download (optional). 1407 | * Use `YoutubeDl::listSubs($url)` to get available language tags. 1408 | * 1409 | * @param list $subLang 1410 | */ 1411 | public function subLang(array $subLang): self 1412 | { 1413 | $new = clone $this; 1414 | $new->subLang = $subLang; 1415 | 1416 | return $new; 1417 | } 1418 | 1419 | /** 1420 | * Login with this account ID and password. 1421 | */ 1422 | public function authenticate(?string $username, ?string $password): self 1423 | { 1424 | $new = clone $this; 1425 | $new->username = $username; 1426 | $new->password = $password; 1427 | if (($username === null && $password !== null) || ($username !== null && $password === null)) { 1428 | // Without a password `youtube-dl` would enter ineractive mode. 1429 | throw new InvalidArgumentException('Authentication username and password must be provided when configuring account details.'); 1430 | } 1431 | 1432 | return $new; 1433 | } 1434 | 1435 | /** 1436 | * Two-factor authentication code. 1437 | */ 1438 | public function twoFactor(?string $twoFactor): self 1439 | { 1440 | $new = clone $this; 1441 | $new->twoFactor = $twoFactor; 1442 | 1443 | return $new; 1444 | } 1445 | 1446 | /** 1447 | * Use .netrc authentication data. 1448 | */ 1449 | public function netrc(bool $netrc): self 1450 | { 1451 | $new = clone $this; 1452 | $new->netrc = $netrc; 1453 | 1454 | return $new; 1455 | } 1456 | 1457 | /** 1458 | * Video password (vimeo, smotri, youku). 1459 | */ 1460 | public function videoPassword(?string $videoPassword): self 1461 | { 1462 | $new = clone $this; 1463 | $new->videoPassword = $videoPassword; 1464 | 1465 | return $new; 1466 | } 1467 | 1468 | /** 1469 | * Adobe Pass multiple-system operator (TV provider) identifier. 1470 | * Use `YoutubeDl::getMultipleSystemOperatorsList()` to get the list of 1471 | * all available MSOs. 1472 | */ 1473 | public function apMso(?string $apMso): self 1474 | { 1475 | $new = clone $this; 1476 | $new->apMso = $apMso; 1477 | 1478 | return $new; 1479 | } 1480 | 1481 | /** 1482 | * Multiple-system operator account username and password. 1483 | */ 1484 | public function apLogin(?string $apUsername, ?string $apPassword): self 1485 | { 1486 | $new = clone $this; 1487 | $new->apUsername = $apUsername; 1488 | $new->apPassword = $apPassword; 1489 | 1490 | if ($apUsername !== null && $apPassword === null) { 1491 | // Without a password `youtube-dl` would enter ineractive mode. 1492 | throw new InvalidArgumentException('MSO password must be provided when configuring account details.'); 1493 | } 1494 | 1495 | return $new; 1496 | } 1497 | 1498 | public function extractAudio(bool $extractAudio): self 1499 | { 1500 | $new = clone $this; 1501 | $new->extractAudio = $extractAudio; 1502 | 1503 | return $new; 1504 | } 1505 | 1506 | public function getExtractAudio(): bool 1507 | { 1508 | return $this->extractAudio; 1509 | } 1510 | 1511 | /** 1512 | * @phpstan-param self::AUDIO_FORMAT_*|null $audioFormat 1513 | */ 1514 | public function audioFormat(?string $audioFormat): self 1515 | { 1516 | if ($audioFormat !== null && !in_array($audioFormat, static::AUDIO_FORMATS, true)) { 1517 | throw new InvalidArgumentException(sprintf('Option `audioFormat` expected one of: %s. Got: %s.', implode(', ', array_map(static fn ($v) => '"'.$v.'"', static::AUDIO_FORMATS)), '"'.$audioFormat.'"')); 1518 | } 1519 | 1520 | $new = clone $this; 1521 | $new->audioFormat = $audioFormat; 1522 | 1523 | return $new; 1524 | } 1525 | 1526 | public function audioQuality(?string $audioQuality): self 1527 | { 1528 | $new = clone $this; 1529 | $new->audioQuality = $audioQuality; 1530 | 1531 | return $new; 1532 | } 1533 | 1534 | /** 1535 | * Download only chapters that match the regular expression. 1536 | */ 1537 | public function downloadSections(?string $downloadSections): self 1538 | { 1539 | $new = clone $this; 1540 | $new->downloadSections = $downloadSections; 1541 | 1542 | return $new; 1543 | } 1544 | 1545 | /** 1546 | * Number of fragments of a dash/hlsnative 1547 | * video that should be downloaded concurrently (default is 1). 1548 | */ 1549 | public function concurrentFragments(?int $concurrentFragments): self 1550 | { 1551 | $new = clone $this; 1552 | $new->concurrentFragments = $concurrentFragments; 1553 | 1554 | return $new; 1555 | } 1556 | 1557 | /** 1558 | * Force keyframes at cuts when downloading/splitting/removing sections. 1559 | * This is slow due to needing a re-encode, but the resulting video 1560 | * may have fewer artifacts around the cuts. 1561 | */ 1562 | public function forceKeyframesAtCuts(bool $forceKeyframesAtCuts): self 1563 | { 1564 | $new = clone $this; 1565 | $new->forceKeyframesAtCuts = $forceKeyframesAtCuts; 1566 | 1567 | return $new; 1568 | } 1569 | 1570 | /** 1571 | * Remux the video into another container if necessary (currently supported: 1572 | * avi, flv, gif, mkv, mov, mp4, webm, aac, aiff, alac, flac, m4a, mka, mp3, ogg, 1573 | * opus, vorbis, wav). If target container does not support the video/audio codec, 1574 | * remuxing will fail. You can specify multiple rules; e.g. "aac>m4a/mov>mp4/mkv" 1575 | * will remux aac to m4a, mov to mp4 and anything else to mkv. 1576 | */ 1577 | public function remuxVideo(?string $remuxVideo): self 1578 | { 1579 | $new = clone $this; 1580 | $new->remuxVideo = $remuxVideo; 1581 | 1582 | return $new; 1583 | } 1584 | 1585 | public function recodeVideo(?string $recodeVideo): self 1586 | { 1587 | if ($recodeVideo !== null && !in_array($recodeVideo, static::RECODE_VIDEO_FORMATS, true)) { 1588 | throw new InvalidArgumentException(sprintf('Option `recodeVideo` expected one of: %s. Got: %s.', implode(', ', array_map(static fn ($v) => '"'.$v.'"', static::RECODE_VIDEO_FORMATS)), '"'.$recodeVideo.'"')); 1589 | } 1590 | 1591 | $new = clone $this; 1592 | $new->recodeVideo = $recodeVideo; 1593 | 1594 | return $new; 1595 | } 1596 | 1597 | public function postProcessorArgs(?string $postProcessorArgs): self 1598 | { 1599 | $new = clone $this; 1600 | $new->postProcessorArgs = $postProcessorArgs; 1601 | 1602 | return $new; 1603 | } 1604 | 1605 | public function keepVideo(bool $keepVideo): self 1606 | { 1607 | $new = clone $this; 1608 | $new->keepVideo = $keepVideo; 1609 | 1610 | return $new; 1611 | } 1612 | 1613 | public function noPostOverwrites(bool $noPostOverwrites): self 1614 | { 1615 | $new = clone $this; 1616 | $new->noPostOverwrites = $noPostOverwrites; 1617 | 1618 | return $new; 1619 | } 1620 | 1621 | public function embedSubs(bool $embedSubs): self 1622 | { 1623 | $new = clone $this; 1624 | $new->embedSubs = $embedSubs; 1625 | 1626 | return $new; 1627 | } 1628 | 1629 | public function embedThumbnail(bool $embedThumbnail): self 1630 | { 1631 | $new = clone $this; 1632 | $new->embedThumbnail = $embedThumbnail; 1633 | 1634 | return $new; 1635 | } 1636 | 1637 | public function addMetadata(bool $addMetadata): self 1638 | { 1639 | $new = clone $this; 1640 | $new->addMetadata = $addMetadata; 1641 | 1642 | return $new; 1643 | } 1644 | 1645 | public function metadataFromTitle(string $metadataFromTitle): self 1646 | { 1647 | $new = clone $this; 1648 | $new->metadataFromTitle = $metadataFromTitle; 1649 | 1650 | return $new; 1651 | } 1652 | 1653 | public function xattrs(bool $xattrs): self 1654 | { 1655 | $new = clone $this; 1656 | $new->xattrs = $xattrs; 1657 | 1658 | return $new; 1659 | } 1660 | 1661 | public function preferAvconv(bool $preferAvconv): self 1662 | { 1663 | $new = clone $this; 1664 | $new->preferAvconv = $preferAvconv; 1665 | 1666 | return $new; 1667 | } 1668 | 1669 | public function preferFFmpeg(bool $preferFFmpeg): self 1670 | { 1671 | $new = clone $this; 1672 | $new->preferFFmpeg = $preferFFmpeg; 1673 | 1674 | return $new; 1675 | } 1676 | 1677 | public function ffmpegLocation(?string $ffmpegLocation): self 1678 | { 1679 | $new = clone $this; 1680 | $new->ffmpegLocation = $ffmpegLocation; 1681 | 1682 | return $new; 1683 | } 1684 | 1685 | public function exec(?string $exec): self 1686 | { 1687 | $new = clone $this; 1688 | $new->exec = $exec; 1689 | 1690 | return $new; 1691 | } 1692 | 1693 | /** 1694 | * @phpstan-param self::SUBTITLE_FORMAT_*|null $subsFormat 1695 | */ 1696 | public function convertSubsFormat(?string $subsFormat): self 1697 | { 1698 | if ($subsFormat !== null && !in_array($subsFormat, static::SUBTITLE_FORMATS, true)) { 1699 | throw new InvalidArgumentException(sprintf('Option `convertSubsFormat` expected one of: %s. Got: %s.', implode(', ', array_map(static fn ($v) => '"'.$v.'"', static::SUBTITLE_FORMATS)), '"'.$subsFormat.'"')); 1700 | } 1701 | 1702 | $new = clone $this; 1703 | $new->convertSubsFormat = $subsFormat; 1704 | 1705 | return $new; 1706 | } 1707 | 1708 | /** 1709 | * @param int|'infinite'|null $retries 1710 | */ 1711 | public function extractorRetries($retries): self 1712 | { 1713 | $new = clone $this; 1714 | $new->extractorRetries = $retries; 1715 | 1716 | return $new; 1717 | } 1718 | 1719 | /** 1720 | * Process dynamic DASH manifests. 1721 | */ 1722 | public function allowDynamicMpd(bool $allowDynamicMpd): self 1723 | { 1724 | $new = clone $this; 1725 | $new->allowDynamicMpd = $allowDynamicMpd; 1726 | 1727 | return $new; 1728 | } 1729 | 1730 | /** 1731 | * Split HLS playlists to different formats at discontinuities such as ad breaks. 1732 | */ 1733 | public function hlsSplitDiscontinuity(bool $hlsSplitDiscontinuity): self 1734 | { 1735 | $new = clone $this; 1736 | $new->hlsSplitDiscontinuity = $hlsSplitDiscontinuity; 1737 | 1738 | return $new; 1739 | } 1740 | 1741 | /** 1742 | * Pass args for a single extractor. 1743 | * 1744 | * @see https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#extractor-arguments 1745 | * 1746 | * @param non-empty-string $extractor 1747 | */ 1748 | public function extractorArgs(string $extractor, string $args): self 1749 | { 1750 | $new = clone $this; 1751 | $new->extractorArgs[$extractor] = $args; 1752 | 1753 | return $new; 1754 | } 1755 | 1756 | /** 1757 | * Pass args for all extractors. 1758 | * 1759 | * @see https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#extractor-arguments 1760 | * 1761 | * @param array $extractorArgs 1762 | */ 1763 | public function extractorsArgs(array $extractorArgs): self 1764 | { 1765 | $new = clone $this; 1766 | $new->extractorArgs = $extractorArgs; 1767 | 1768 | return $new; 1769 | } 1770 | 1771 | /** 1772 | * @param non-empty-string $url 1773 | * @param non-empty-string ...$urls 1774 | */ 1775 | public function url(string $url, string ...$urls): self 1776 | { 1777 | $new = clone $this; 1778 | $new->url = array_values([$url, ...$urls]); 1779 | 1780 | return $new; 1781 | } 1782 | 1783 | /** 1784 | * @return list 1785 | */ 1786 | public function getUrl(): array 1787 | { 1788 | return $this->url; 1789 | } 1790 | 1791 | /** 1792 | * @return array 1793 | */ 1794 | public function toArray(): array 1795 | { 1796 | return [ 1797 | // Network Options 1798 | 'proxy' => $this->proxy, 1799 | 'socket-timeout' => $this->socketTimeout, 1800 | 'source-address' => $this->sourceAddress, 1801 | 'force-ipv4' => $this->forceIpV4, 1802 | 'force-ipv6' => $this->forceIpV6, 1803 | // Geo Restriction 1804 | 'geo-verification-proxy' => $this->geoVerificationProxy, 1805 | 'geo-bypass' => $this->geoBypass, 1806 | 'no-geo-bypass' => $this->noGeoBypass, 1807 | 'geo-bypass-country' => $this->geoBypassCountry, 1808 | 'geo-bypass-ip-block' => $this->geoBypassIpBlock, 1809 | // Video Selection 1810 | 'playlist-start' => $this->playlistStart, 1811 | 'playlist-end' => $this->playlistEnd, 1812 | 'playlist-items' => $this->playlistItems, 1813 | 'match-title' => $this->matchTitle, 1814 | 'reject-title' => $this->rejectTitle, 1815 | 'max-downloads' => $this->maxDownloads, 1816 | 'min-filesize' => $this->minFileSize, 1817 | 'max-filesize' => $this->maxFileSize, 1818 | 'date' => $this->date, 1819 | 'datebefore' => $this->dateBefore, 1820 | 'dateafter' => $this->dateAfter, 1821 | 'min-views' => $this->minViews, 1822 | 'max-views' => $this->maxViews, 1823 | 'match-filter' => $this->matchFilter, 1824 | 'no-playlist' => $this->noPlaylist, 1825 | 'yes-playlist' => $this->yesPlaylist, 1826 | 'age-limit' => $this->ageLimit, 1827 | // Download Options 1828 | 'limit-rate' => $this->limitRate, 1829 | 'retries' => $this->retries, 1830 | 'fragment-retries' => $this->fragmentRetries, 1831 | 'skip-unavailable-fragments' => $this->skipUnavailableFragments, 1832 | 'keep-fragments' => $this->keepFragments, 1833 | 'buffer-size' => $this->bufferSize, 1834 | 'no-resize-buffer' => $this->noResizeBuffer, 1835 | 'http-chunk-size' => $this->httpChunkSize, 1836 | 'playlist-reverse' => $this->playlistReverse, 1837 | 'playlist-random' => $this->playlistRandom, 1838 | 'xattr-set-filesize' => $this->xattrSetFilesize, 1839 | 'hls-prefer-native' => $this->hlsPreferNative, 1840 | 'hls-prefer-ffmpeg' => $this->hlsPreferFFmpeg, 1841 | 'hls-use-mpegts' => $this->hlsUseMpegts, 1842 | 'external-downloader' => $this->externalDownloader, 1843 | 'external-downloader-args' => $this->externalDownloaderArgs, 1844 | 'download-sections' => $this->downloadSections, 1845 | 'concurrent-fragments' => $this->concurrentFragments, 1846 | // Filesystem Options 1847 | 'batch-file' => $this->batchFile, 1848 | 'id' => $this->id, 1849 | 'output' => $this->downloadPath.'/'.$this->output, 1850 | 'autonumber-start' => $this->autoNumberStart, 1851 | 'restrict-filenames' => $this->restrictFilenames, 1852 | 'windows-filenames' => $this->windowsFilenames, 1853 | 'no-overwrites' => $this->noOverwrites, 1854 | 'continue' => $this->continue, 1855 | 'no-continue' => $this->noContinue, 1856 | 'no-part' => $this->noPart, 1857 | 'no-mtime' => $this->noMtime, 1858 | 'write-description' => $this->writeDescription, 1859 | 'write-annotations' => $this->writeAnnotations, 1860 | 'load-info-json' => $this->loadInfoJson, 1861 | 'cookies' => $this->cookies, 1862 | 'cache-dir' => $this->cacheDir, 1863 | 'no-cache-dir' => $this->noCacheDir, 1864 | 'rm-cache-dir' => $this->rmCacheDir, 1865 | // Thumbnail Images Options 1866 | 'write-thumbnail' => $this->writeThumbnail, 1867 | 'write-all-thumbnails' => $this->writeAllThumbnails, 1868 | 'convert-thumbnail' => $this->convertThumbnail, 1869 | 1870 | // Verbosity / Simulation Options 1871 | 'skip-download' => $this->skipDownload, 1872 | 'verbose' => $this->verbose, 1873 | 'write-pages' => $this->writePages, 1874 | 'print-traffic' => $this->printTraffic, 1875 | 'call-home' => $this->callHome, 1876 | 'no-call-home' => $this->noCallHome, 1877 | // Workaround Options 1878 | 'encoding' => $this->encoding, 1879 | 'no-check-certificate' => $this->noCheckCertificate, 1880 | 'prefer-insecure' => $this->preferInsecure, 1881 | 'user-agent' => $this->userAgent, 1882 | 'referer' => $this->referer, 1883 | 'add-header' => $this->headers, 1884 | 'sleep-interval' => $this->sleepInterval, 1885 | 'max-sleep-interval' => $this->maxSleepInterval, 1886 | // Video Format Options 1887 | 'format' => $this->format, 1888 | 'format-sort' => $this->formatSort, 1889 | 'format-sort-force' => $this->formatSortForce, 1890 | 'video-multistreams' => $this->videoMultistreams, 1891 | 'audio-multistreams' => $this->audioMultistreams, 1892 | 'prefer-free-formats' => $this->preferFreeFormats, 1893 | 'check-formats' => $this->checkFormats, 1894 | 'check-all-formats' => $this->checkAllFormats, 1895 | 'youtube-skip-dash-manifest' => $this->youtubeSkipDashManifest, 1896 | 'merge-output-format' => $this->mergeOutputFormat, 1897 | // Subtitle Options 1898 | 'write-sub' => $this->writeSub, 1899 | 'write-auto-sub' => $this->writeAutoSub, 1900 | 'all-subs' => $this->allSubs, 1901 | 'sub-format' => $this->subFormat, 1902 | 'sub-lang' => $this->subLang, 1903 | // Authentication Options 1904 | 'username' => $this->username, 1905 | 'password' => $this->password, 1906 | 'twofactor' => $this->twoFactor, 1907 | 'netrc' => $this->netrc, 1908 | 'video-password' => $this->videoPassword, 1909 | // Adobe Pass Options 1910 | 'ap-mso' => $this->apMso, 1911 | 'ap-username' => $this->apUsername, 1912 | 'ap-password' => $this->apPassword, 1913 | // Post-processing Options 1914 | 'extract-audio' => $this->extractAudio, 1915 | 'audio-format' => $this->audioFormat, 1916 | 'audio-quality' => $this->audioQuality, 1917 | 'remux-video' => $this->remuxVideo, 1918 | 'recode-video' => $this->recodeVideo, 1919 | 'postprocessor-args' => $this->postProcessorArgs, 1920 | 'keep-video' => $this->keepVideo, 1921 | 'no-post-overwrites' => $this->noPostOverwrites, 1922 | 'embed-subs' => $this->embedSubs, 1923 | 'embed-thumbnail' => $this->embedThumbnail, 1924 | 'add-metadata' => $this->addMetadata, 1925 | 'metadata-from-title' => $this->metadataFromTitle, 1926 | 'xattrs' => $this->xattrs, 1927 | 'prefer-avconv' => $this->preferAvconv, 1928 | 'prefer-ffmpeg' => $this->preferFFmpeg, 1929 | 'ffmpeg-location' => $this->ffmpegLocation, 1930 | 'exec' => $this->exec, 1931 | 'convert-subs-format' => $this->convertSubsFormat, 1932 | 'force-keyframes-at-cuts' => $this->forceKeyframesAtCuts, 1933 | // Extractor Options 1934 | 'extractor-retries' => $this->extractorRetries, 1935 | 'allow-dynamic-mpd' => $this->allowDynamicMpd, 1936 | 'hls-split-discontinuity' => $this->hlsSplitDiscontinuity, 1937 | 'extractor-args' => $this->extractorArgs, 1938 | 1939 | 'url' => $this->url, 1940 | ]; 1941 | } 1942 | 1943 | public static function create(): self 1944 | { 1945 | return new self(); 1946 | } 1947 | } 1948 | -------------------------------------------------------------------------------- /src/Process/ArgvBuilder.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public static function build(Options $options): array 19 | { 20 | $cmd = []; 21 | 22 | foreach ($options->toArray() as $option => $value) { 23 | if (is_bool($value)) { 24 | if ($value) { 25 | $cmd[] = "--$option"; 26 | } 27 | } elseif (is_array($value)) { 28 | if ($option === 'url') { 29 | foreach ($value as $url) { 30 | $cmd[] = $url; 31 | } 32 | } elseif ($option === 'playlist-items' || $option === 'sub-lang' || $option === 'format-sort') { 33 | if (count($value) > 0) { 34 | $cmd[] = sprintf('--%s=%s', $option, implode(',', $value)); 35 | } 36 | } elseif ($option === 'add-header' || $option === 'extractor-args') { 37 | foreach ($value as $key => $v) { 38 | $cmd[] = sprintf('--%s=%s:%s', $option, $key, $v); 39 | } 40 | } else { 41 | foreach ($value as $v) { 42 | $cmd[] = "--$option=$v"; 43 | } 44 | } 45 | } elseif ($value !== null) { 46 | $cmd[] = "--$option=$value"; 47 | } 48 | } 49 | 50 | return $cmd; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Process/DefaultProcessBuilder.php: -------------------------------------------------------------------------------- 1 | executableFinder = $executableFinder ?? new ExecutableFinder(); 20 | } 21 | 22 | public function build(?string $binPath, ?string $pythonPath, array $arguments = []): Process 23 | { 24 | if ($binPath === null) { 25 | $binPath = $this->executableFinder->find('yt-dlp'); 26 | } 27 | 28 | if ($binPath === null) { 29 | $binPath = $this->executableFinder->find('youtube-dl'); 30 | } 31 | 32 | if ($binPath === null) { 33 | throw new ExecutableNotFoundException('"yt-dlp" or "youtube-dl" executable was not found. Did you forgot to configure it\'s binary path? ex.: $yt->setBinPath(\'/usr/bin/yt-dlp\') ?.'); 34 | } 35 | 36 | array_unshift($arguments, $binPath); 37 | 38 | if ($pythonPath !== null) { 39 | array_unshift($arguments, $pythonPath); 40 | } 41 | 42 | $process = new Process($arguments); 43 | $process->setTimeout(null); 44 | 45 | return $process; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Process/ProcessBuilderInterface.php: -------------------------------------------------------------------------------- 1 | $arguments 16 | * 17 | * @throws ExecutableNotFoundException if `youtube-dl` binary could not be located 18 | */ 19 | public function build(?string $binPath, ?string $pythonPath, array $arguments = []): Process; 20 | } 21 | -------------------------------------------------------------------------------- /src/Process/TableParser.php: -------------------------------------------------------------------------------- 1 | $rows 15 | * 16 | * @return list> 17 | */ 18 | public static function parse(string $header, array $rows): array 19 | { 20 | $columns = self::collectColumnsAndWidths($header); 21 | 22 | $data = []; 23 | 24 | foreach ($rows as $row) { 25 | $rowData = []; 26 | $line = $row; 27 | 28 | foreach ($columns as $c) { 29 | $column = $c['column']; 30 | $width = $c['width']; 31 | if ($width !== null) { 32 | $rowData[$column] = trim(substr($line, 0, $width)); 33 | $line = substr($line, $width); 34 | } else { 35 | $rowData[$column] = trim($line); 36 | } 37 | } 38 | 39 | $data[] = $rowData; 40 | } 41 | 42 | return $data; 43 | } 44 | 45 | /** 46 | * @return array 47 | */ 48 | private static function collectColumnsAndWidths(string $header): array 49 | { 50 | $split = str_split($header); 51 | 52 | $columns = []; 53 | $column = ''; 54 | $columnWidth = 0; 55 | 56 | foreach ($split as $i => $r) { 57 | if ($r !== ' ') { 58 | if (isset($split[$i - 1]) && $split[$i - 1] === ' ') { 59 | $columns[] = [ 60 | 'column' => $column, 61 | 'width' => $columnWidth, 62 | ]; 63 | $column = ''; 64 | $columnWidth = 0; 65 | } 66 | $column .= strtolower($r); 67 | ++$columnWidth; 68 | } else { 69 | ++$columnWidth; 70 | } 71 | } 72 | 73 | if ($column !== '') { 74 | $columns[] = [ 75 | 'column' => $column, 76 | 'width' => null, 77 | ]; 78 | } 79 | 80 | return $columns; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/YoutubeDl.php: -------------------------------------------------------------------------------- 1 | \d+(?:\.\d+)?%)\s+of\s+(?[~]?\d+(?:\.\d+)?(?:K|M|G)iB)(?:\s+at\s+(?(\d+(?:\.\d+)?(?:K|M|G)iB/s)|Unknown speed))?(?:\s+ETA\s+(?([\d:]{2,8}|Unknown ETA)))?(\s+in\s+(?[\d:]{2,8}))?#i'; 40 | 41 | private ProcessBuilderInterface $processBuilder; 42 | private MetadataReaderInterface $metadataReader; 43 | private Filesystem $filesystem; 44 | 45 | /** 46 | * @var non-empty-string|null 47 | */ 48 | private ?string $binPath = null; 49 | 50 | /** 51 | * @var non-empty-string|null 52 | */ 53 | private ?string $pythonPath = null; 54 | 55 | /** 56 | * @var callable 57 | */ 58 | private $progress; 59 | 60 | /** 61 | * @var callable 62 | */ 63 | private $debug; 64 | 65 | public function __construct(?ProcessBuilderInterface $processBuilder = null, ?MetadataReaderInterface $metadataReader = null, ?Filesystem $filesystem = null) 66 | { 67 | $this->processBuilder = $processBuilder ?? new DefaultProcessBuilder(); 68 | $this->metadataReader = $metadataReader ?? new DefaultMetadataReader(); 69 | $this->filesystem = $filesystem ?? new Filesystem(); 70 | $this->progress = static function (?string $progressTarget, string $percentage, string $size, ?string $speed, ?string $eta, ?string $totalTime): void {}; 71 | $this->debug = static function (string $type, string $buffer): void {}; 72 | } 73 | 74 | /** 75 | * @param non-empty-string|null $binPath 76 | */ 77 | public function setBinPath(?string $binPath): self 78 | { 79 | $this->binPath = $binPath; 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * @param non-empty-string|null $pythonPath 86 | */ 87 | public function setPythonPath(?string $pythonPath): self 88 | { 89 | $this->pythonPath = $pythonPath; 90 | 91 | return $this; 92 | } 93 | 94 | public function onProgress(callable $onProgress): self 95 | { 96 | $this->progress = $onProgress; 97 | 98 | return $this; 99 | } 100 | 101 | public function debug(callable $debug): self 102 | { 103 | $this->debug = $debug; 104 | 105 | return $this; 106 | } 107 | 108 | public function download(Options $options): VideoCollection 109 | { 110 | $urls = $options->getUrl(); 111 | 112 | if (count($urls) === 0) { 113 | throw new NoUrlProvidedException('Missing configured URL to download.'); 114 | } 115 | 116 | if ($options->getDownloadPath() === null) { 117 | throw new NoDownloadPathProvidedException('Missing configured downloadPath option.'); 118 | } 119 | 120 | $arguments = [ 121 | '--ignore-config', 122 | '--ignore-errors', 123 | '--write-info-json', 124 | ...ArgvBuilder::build($options), 125 | ]; 126 | 127 | $parsedData = []; 128 | $currentVideo = null; 129 | $progressTarget = null; 130 | 131 | $process = $this->processBuilder->build($this->binPath, $this->pythonPath, $arguments); 132 | $process->run(function (string $type, string $buffer) use (&$currentVideo, &$parsedData, &$progressTarget): void { 133 | ($this->debug)($type, $buffer); 134 | 135 | if (preg_match('/\[(?.+)]\s(?.+):\sDownloading (pc )?webpage/', $buffer, $match) === 1) { 136 | if ($currentVideo !== null) { 137 | $parsedData[] = $currentVideo; 138 | $currentVideo = null; 139 | } 140 | 141 | $currentVideo['extractor'] = $match['extractor']; 142 | $currentVideo['id'] = $match['id']; 143 | } 144 | 145 | if (preg_match('/\[download] File is (larger|smaller) than (min|max)-filesize/', $buffer, $match) === 1) { 146 | $currentVideo['error'] = trim(substr($buffer, 11)); 147 | } elseif (str_starts_with($buffer, 'ERROR:')) { 148 | $currentVideo['error'] = trim(substr($buffer, 6)); 149 | } 150 | 151 | if (preg_match('/Writing video( description)? metadata as JSON to:\s(?.+)/', $buffer, $match) === 1) { 152 | $currentVideo['metadataFile'] = $match['metadataFile']; 153 | } 154 | 155 | if (preg_match('/\[(ffmpeg|Merger)] Merging formats into "(?.+)"/', $buffer, $match) === 1) { 156 | $currentVideo['fileName'] = $match['file']; 157 | } elseif (preg_match('/\[(download|ffmpeg|ExtractAudio)] Destination: (?.+)/', $buffer, $match) === 1 || preg_match('/\[download] (?.+) has already been downloaded/', $buffer, $match) === 1) { 158 | $currentVideo['fileName'] = $match['file']; 159 | $progressTarget = basename($match['file']); 160 | } 161 | 162 | if (preg_match_all(static::PROGRESS_PATTERN, $buffer, $matches, PREG_SET_ORDER) !== false) { 163 | if (count($matches) > 0) { 164 | $progress = $this->progress; 165 | 166 | foreach ($matches as $progressMatch) { 167 | $progress($progressTarget, $progressMatch['percentage'], $progressMatch['size'], $progressMatch['speed'] ?? null, $progressMatch['eta'] ?? null, $progressMatch['totalTime'] ?? null); 168 | } 169 | } 170 | } 171 | }); 172 | 173 | if ($currentVideo !== null && !in_array($currentVideo, $parsedData, true)) { 174 | $parsedData[] = $currentVideo; 175 | } 176 | 177 | $videos = []; 178 | $metadataFiles = []; 179 | 180 | foreach ($parsedData as $parsedRow) { 181 | if (isset($parsedRow['error'])) { 182 | $videos[] = new Video([ 183 | 'error' => $parsedRow['error'], 184 | 'extractor' => $parsedRow['extractor'] ?? 'generic', 185 | ]); 186 | } elseif (isset($parsedRow['metadataFile'])) { 187 | $metadataFile = $parsedRow['metadataFile']; 188 | $metadataFiles[] = $metadataFile; 189 | $metadata = $this->metadataReader->read($metadataFile); 190 | if (isset($parsedRow['fileName'])) { 191 | $metadata['_filename'] = $parsedRow['fileName']; 192 | $metadata['file'] = new SplFileInfo($metadata['_filename']); 193 | } 194 | $metadata['metadataFile'] = new SplFileInfo($metadataFile); 195 | 196 | $videos[] = new Video($metadata); 197 | } 198 | } 199 | 200 | if ($options->getCleanupMetadata()) { 201 | $this->filesystem->remove($metadataFiles); 202 | } 203 | 204 | return new VideoCollection($videos); 205 | } 206 | 207 | /** 208 | * @param non-empty-string $url 209 | * 210 | * @return list 211 | */ 212 | public function listThumbnails(string $url): array 213 | { 214 | $process = $this->processBuilder->build($this->binPath, $this->pythonPath, ['--list-thumbnails', $url]); 215 | $process->mustRun(); 216 | 217 | $data = explode("\n", $process->getOutput()); 218 | $parsing = null; 219 | $header = ''; 220 | $rows = []; 221 | 222 | foreach ($data as $line) { 223 | if ($line === '') { 224 | continue; 225 | } 226 | 227 | if (str_starts_with($line, '[info] Thumbnails for')) { 228 | $parsing = 'table_header'; 229 | } elseif ($parsing === 'table_header') { 230 | $header = $line; 231 | $parsing = 'thumbnails'; 232 | } elseif ($parsing === 'thumbnails') { 233 | $rows[] = $line; 234 | } 235 | } 236 | 237 | return array_map(static fn (array $row) => new Thumbnail($row), TableParser::parse($header, $rows)); 238 | } 239 | 240 | /** 241 | * @return SubListItem[] 242 | */ 243 | public function listSubs(string $url): array 244 | { 245 | $process = $this->processBuilder->build($this->binPath, $this->pythonPath, ['--list-subs', $url]); 246 | $process->mustRun(); 247 | 248 | $data = explode("\n", $process->getOutput()); 249 | $parsing = null; 250 | $header = ''; 251 | $autoCaptionRows = []; 252 | $subtitleRows = []; 253 | 254 | foreach ($data as $line) { 255 | if ($line === '') { 256 | continue; 257 | } 258 | 259 | if (str_contains($line, 'Available automatic captions for')) { 260 | $parsing = 'auto_caption'; 261 | } elseif (str_contains($line, 'Available subtitles for')) { 262 | $parsing = 'subtitles'; 263 | } elseif (str_contains($line, 'has no automatic captions') || str_contains($line, 'has no subtitles')) { 264 | $parsing = null; 265 | } elseif (str_contains($line, 'Language')) { 266 | $header = $line; 267 | } elseif ($parsing !== null) { 268 | if ($parsing === 'auto_caption') { 269 | $autoCaptionRows[] = $line; 270 | } else { 271 | $subtitleRows[] = $line; 272 | } 273 | } 274 | } 275 | 276 | $list = []; 277 | foreach (TableParser::parse($header, $autoCaptionRows) as $row) { 278 | $list[] = new SubListItem([ 279 | 'language' => $row['language'], 280 | 'formats' => explode(', ', $row['formats']), 281 | 'auto_caption' => true, 282 | ]); 283 | } 284 | 285 | foreach (TableParser::parse($header, $subtitleRows) as $row) { 286 | $list[] = new SubListItem([ 287 | 'language' => $row['language'], 288 | 'formats' => explode(', ', $row['formats']), 289 | 'auto_caption' => false, 290 | ]); 291 | } 292 | 293 | return $list; 294 | } 295 | 296 | /** 297 | * @return Extractor[] 298 | */ 299 | public function getExtractorsList(): array 300 | { 301 | $process = $this->processBuilder->build($this->binPath, $this->pythonPath, ['--list-extractors']); 302 | $process->mustRun(); 303 | 304 | return array_map(static fn (string $extractor) => new Extractor(['title' => $extractor]), array_filter(explode("\n", $process->getOutput()))); 305 | } 306 | 307 | /** 308 | * @todo: use TableParser 309 | * 310 | * @return Mso[] 311 | */ 312 | public function getMultipleSystemOperatorsList(): array 313 | { 314 | $process = $this->processBuilder->build($this->binPath, $this->pythonPath, ['--ap-list-mso']); 315 | $process->mustRun(); 316 | 317 | $output = explode("\n", $process->getOutput()); 318 | 319 | if (count($output) <= 2) { 320 | return []; 321 | } 322 | 323 | unset($output[0]); // Remove "Supported TV Providers:" line 324 | // Calculate how much space "mso" takes on the line 325 | $msoWidth = strpos($output[1], 'mso name'); 326 | if ($msoWidth === false) { 327 | throw new MsoNotParsableException('Cannot properly parse Multiple System Operators list'); 328 | } 329 | unset($output[1]); 330 | 331 | $list = []; 332 | 333 | foreach ($output as $line) { 334 | if ($line === '') { 335 | continue; 336 | } 337 | 338 | $code = trim(substr($line, 0, $msoWidth)); 339 | $name = substr($line, $msoWidth); 340 | 341 | $list[] = new Mso(['code' => $code, 'name' => $name]); 342 | } 343 | 344 | return $list; 345 | } 346 | } 347 | --------------------------------------------------------------------------------