├── .github └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── README.md ├── RELEASES.md ├── dist-workspace.toml └── src ├── analyzer.rs ├── app.rs ├── assembling.rs ├── assembling ├── youtube.rs └── youtube │ ├── config.rs │ ├── yt_playlist.rs │ └── yt_video.rs ├── dispatcher.rs ├── error.rs ├── lib.rs ├── main.rs ├── parser.rs └── run.rs /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ubuntu-22.04, macos-latest, windows-latest] 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Rust 20 | uses: actions/setup-rust@v1 21 | with: 22 | rust-version: stable 23 | 24 | - name: Install dependencies (Linux/macOS only) 25 | if: runner.os != 'Windows' 26 | run: | 27 | if [[ "$RUNNER_OS" == "Linux" ]]; then 28 | sudo apt-get update 29 | sudo apt-get install -y build-essential 30 | elif [[ "$RUNNER_OS" == "macOS" ]]; then 31 | brew install build-essential 32 | fi 33 | 34 | - name: Install dependencies (Windows only) 35 | if: runner.os == 'Windows' 36 | run: | 37 | # PowerShell script for Windows 38 | Write-Host "Installing dependencies for Windows..." 39 | # Add any Windows-specific steps you need here. 40 | # Example: winget install 41 | 42 | - name: Build the project 43 | run: cargo build --release 44 | 45 | - name: Run tests (optional) 46 | run: cargo test 47 | 48 | - name: Create release artifact 49 | run: | 50 | dist build --release --target $RUNNER_OS --output-format=json 51 | echo "Artifacts created successfully" 52 | 53 | - name: Upload release artifacts 54 | uses: actions/upload-artifact@v2 55 | with: 56 | name: my-release-artifacts 57 | path: target/release/* 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | target/ 4 | debug/ 5 | 6 | # Clion configs 7 | .idea/ 8 | 9 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## 1.0.3 - 2025-04-21 4 | Added support for configuration files and built-in subtitles download -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.10" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.6" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.2" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 40 | dependencies = [ 41 | "windows-sys", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.7" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 49 | dependencies = [ 50 | "anstyle", 51 | "once_cell", 52 | "windows-sys", 53 | ] 54 | 55 | [[package]] 56 | name = "bitflags" 57 | version = "2.9.0" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 60 | 61 | [[package]] 62 | name = "blob-dl" 63 | version = "1.1.6" 64 | dependencies = [ 65 | "clap", 66 | "colored", 67 | "dialoguer", 68 | "directories", 69 | "execute", 70 | "serde", 71 | "serde_json", 72 | "spinoff", 73 | "url", 74 | "which", 75 | ] 76 | 77 | [[package]] 78 | name = "cfg-if" 79 | version = "1.0.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 82 | 83 | [[package]] 84 | name = "clap" 85 | version = "4.5.37" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" 88 | dependencies = [ 89 | "clap_builder", 90 | "clap_derive", 91 | ] 92 | 93 | [[package]] 94 | name = "clap_builder" 95 | version = "4.5.37" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" 98 | dependencies = [ 99 | "anstream", 100 | "anstyle", 101 | "clap_lex", 102 | "strsim", 103 | ] 104 | 105 | [[package]] 106 | name = "clap_derive" 107 | version = "4.5.32" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 110 | dependencies = [ 111 | "heck", 112 | "proc-macro2", 113 | "quote", 114 | "syn", 115 | ] 116 | 117 | [[package]] 118 | name = "clap_lex" 119 | version = "0.7.4" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 122 | 123 | [[package]] 124 | name = "colorchoice" 125 | version = "1.0.3" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 128 | 129 | [[package]] 130 | name = "colored" 131 | version = "2.2.0" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" 134 | dependencies = [ 135 | "lazy_static", 136 | "windows-sys", 137 | ] 138 | 139 | [[package]] 140 | name = "console" 141 | version = "0.15.11" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" 144 | dependencies = [ 145 | "encode_unicode", 146 | "libc", 147 | "once_cell", 148 | "unicode-width", 149 | "windows-sys", 150 | ] 151 | 152 | [[package]] 153 | name = "dialoguer" 154 | version = "0.10.4" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87" 157 | dependencies = [ 158 | "console", 159 | "shell-words", 160 | "tempfile", 161 | "zeroize", 162 | ] 163 | 164 | [[package]] 165 | name = "directories" 166 | version = "6.0.0" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" 169 | dependencies = [ 170 | "dirs-sys", 171 | ] 172 | 173 | [[package]] 174 | name = "dirs-sys" 175 | version = "0.5.0" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 178 | dependencies = [ 179 | "libc", 180 | "option-ext", 181 | "redox_users", 182 | "windows-sys", 183 | ] 184 | 185 | [[package]] 186 | name = "displaydoc" 187 | version = "0.2.5" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 190 | dependencies = [ 191 | "proc-macro2", 192 | "quote", 193 | "syn", 194 | ] 195 | 196 | [[package]] 197 | name = "either" 198 | version = "1.15.0" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 201 | 202 | [[package]] 203 | name = "encode_unicode" 204 | version = "1.0.0" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 207 | 208 | [[package]] 209 | name = "errno" 210 | version = "0.3.11" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" 213 | dependencies = [ 214 | "libc", 215 | "windows-sys", 216 | ] 217 | 218 | [[package]] 219 | name = "execute" 220 | version = "0.2.13" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "3a82608ee96ce76aeab659e9b8d3c2b787bffd223199af88c674923d861ada10" 223 | dependencies = [ 224 | "execute-command-macro", 225 | "execute-command-tokens", 226 | "generic-array", 227 | ] 228 | 229 | [[package]] 230 | name = "execute-command-macro" 231 | version = "0.1.9" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "90dec53d547564e911dc4ff3ecb726a64cf41a6fa01a2370ebc0d95175dd08bd" 234 | dependencies = [ 235 | "execute-command-macro-impl", 236 | ] 237 | 238 | [[package]] 239 | name = "execute-command-macro-impl" 240 | version = "0.1.10" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "ce8cd46a041ad005ab9c71263f9a0ff5b529eac0fe4cc9b4a20f4f0765d8cf4b" 243 | dependencies = [ 244 | "execute-command-tokens", 245 | "quote", 246 | "syn", 247 | ] 248 | 249 | [[package]] 250 | name = "execute-command-tokens" 251 | version = "0.1.7" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "69dc321eb6be977f44674620ca3aa21703cb20ffbe560e1ae97da08401ffbcad" 254 | 255 | [[package]] 256 | name = "fastrand" 257 | version = "2.3.0" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 260 | 261 | [[package]] 262 | name = "form_urlencoded" 263 | version = "1.2.1" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 266 | dependencies = [ 267 | "percent-encoding", 268 | ] 269 | 270 | [[package]] 271 | name = "generic-array" 272 | version = "1.2.0" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "e8c8444bc9d71b935156cc0ccab7f622180808af7867b1daae6547d773591703" 275 | dependencies = [ 276 | "typenum", 277 | ] 278 | 279 | [[package]] 280 | name = "getrandom" 281 | version = "0.2.15" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 284 | dependencies = [ 285 | "cfg-if", 286 | "libc", 287 | "wasi 0.11.0+wasi-snapshot-preview1", 288 | ] 289 | 290 | [[package]] 291 | name = "getrandom" 292 | version = "0.3.2" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" 295 | dependencies = [ 296 | "cfg-if", 297 | "libc", 298 | "r-efi", 299 | "wasi 0.14.2+wasi-0.2.4", 300 | ] 301 | 302 | [[package]] 303 | name = "heck" 304 | version = "0.5.0" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 307 | 308 | [[package]] 309 | name = "home" 310 | version = "0.5.11" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" 313 | dependencies = [ 314 | "windows-sys", 315 | ] 316 | 317 | [[package]] 318 | name = "icu_collections" 319 | version = "1.5.0" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 322 | dependencies = [ 323 | "displaydoc", 324 | "yoke", 325 | "zerofrom", 326 | "zerovec", 327 | ] 328 | 329 | [[package]] 330 | name = "icu_locid" 331 | version = "1.5.0" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 334 | dependencies = [ 335 | "displaydoc", 336 | "litemap", 337 | "tinystr", 338 | "writeable", 339 | "zerovec", 340 | ] 341 | 342 | [[package]] 343 | name = "icu_locid_transform" 344 | version = "1.5.0" 345 | source = "registry+https://github.com/rust-lang/crates.io-index" 346 | checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 347 | dependencies = [ 348 | "displaydoc", 349 | "icu_locid", 350 | "icu_locid_transform_data", 351 | "icu_provider", 352 | "tinystr", 353 | "zerovec", 354 | ] 355 | 356 | [[package]] 357 | name = "icu_locid_transform_data" 358 | version = "1.5.1" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" 361 | 362 | [[package]] 363 | name = "icu_normalizer" 364 | version = "1.5.0" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 367 | dependencies = [ 368 | "displaydoc", 369 | "icu_collections", 370 | "icu_normalizer_data", 371 | "icu_properties", 372 | "icu_provider", 373 | "smallvec", 374 | "utf16_iter", 375 | "utf8_iter", 376 | "write16", 377 | "zerovec", 378 | ] 379 | 380 | [[package]] 381 | name = "icu_normalizer_data" 382 | version = "1.5.1" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" 385 | 386 | [[package]] 387 | name = "icu_properties" 388 | version = "1.5.1" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 391 | dependencies = [ 392 | "displaydoc", 393 | "icu_collections", 394 | "icu_locid_transform", 395 | "icu_properties_data", 396 | "icu_provider", 397 | "tinystr", 398 | "zerovec", 399 | ] 400 | 401 | [[package]] 402 | name = "icu_properties_data" 403 | version = "1.5.1" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" 406 | 407 | [[package]] 408 | name = "icu_provider" 409 | version = "1.5.0" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 412 | dependencies = [ 413 | "displaydoc", 414 | "icu_locid", 415 | "icu_provider_macros", 416 | "stable_deref_trait", 417 | "tinystr", 418 | "writeable", 419 | "yoke", 420 | "zerofrom", 421 | "zerovec", 422 | ] 423 | 424 | [[package]] 425 | name = "icu_provider_macros" 426 | version = "1.5.0" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 429 | dependencies = [ 430 | "proc-macro2", 431 | "quote", 432 | "syn", 433 | ] 434 | 435 | [[package]] 436 | name = "idna" 437 | version = "1.0.3" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 440 | dependencies = [ 441 | "idna_adapter", 442 | "smallvec", 443 | "utf8_iter", 444 | ] 445 | 446 | [[package]] 447 | name = "idna_adapter" 448 | version = "1.2.0" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 451 | dependencies = [ 452 | "icu_normalizer", 453 | "icu_properties", 454 | ] 455 | 456 | [[package]] 457 | name = "is_terminal_polyfill" 458 | version = "1.70.1" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 461 | 462 | [[package]] 463 | name = "itoa" 464 | version = "1.0.15" 465 | source = "registry+https://github.com/rust-lang/crates.io-index" 466 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 467 | 468 | [[package]] 469 | name = "lazy_static" 470 | version = "1.5.0" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 473 | 474 | [[package]] 475 | name = "libc" 476 | version = "0.2.172" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 479 | 480 | [[package]] 481 | name = "libredox" 482 | version = "0.1.3" 483 | source = "registry+https://github.com/rust-lang/crates.io-index" 484 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 485 | dependencies = [ 486 | "bitflags", 487 | "libc", 488 | ] 489 | 490 | [[package]] 491 | name = "linux-raw-sys" 492 | version = "0.4.15" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 495 | 496 | [[package]] 497 | name = "linux-raw-sys" 498 | version = "0.9.4" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 501 | 502 | [[package]] 503 | name = "litemap" 504 | version = "0.7.5" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 507 | 508 | [[package]] 509 | name = "memchr" 510 | version = "2.7.4" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 513 | 514 | [[package]] 515 | name = "once_cell" 516 | version = "1.21.3" 517 | source = "registry+https://github.com/rust-lang/crates.io-index" 518 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 519 | 520 | [[package]] 521 | name = "option-ext" 522 | version = "0.2.0" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 525 | 526 | [[package]] 527 | name = "paste" 528 | version = "1.0.15" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 531 | 532 | [[package]] 533 | name = "percent-encoding" 534 | version = "2.3.1" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 537 | 538 | [[package]] 539 | name = "proc-macro2" 540 | version = "1.0.95" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 543 | dependencies = [ 544 | "unicode-ident", 545 | ] 546 | 547 | [[package]] 548 | name = "quote" 549 | version = "1.0.40" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 552 | dependencies = [ 553 | "proc-macro2", 554 | ] 555 | 556 | [[package]] 557 | name = "r-efi" 558 | version = "5.2.0" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 561 | 562 | [[package]] 563 | name = "redox_users" 564 | version = "0.5.0" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 567 | dependencies = [ 568 | "getrandom 0.2.15", 569 | "libredox", 570 | "thiserror", 571 | ] 572 | 573 | [[package]] 574 | name = "rustix" 575 | version = "0.38.44" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 578 | dependencies = [ 579 | "bitflags", 580 | "errno", 581 | "libc", 582 | "linux-raw-sys 0.4.15", 583 | "windows-sys", 584 | ] 585 | 586 | [[package]] 587 | name = "rustix" 588 | version = "1.0.5" 589 | source = "registry+https://github.com/rust-lang/crates.io-index" 590 | checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" 591 | dependencies = [ 592 | "bitflags", 593 | "errno", 594 | "libc", 595 | "linux-raw-sys 0.9.4", 596 | "windows-sys", 597 | ] 598 | 599 | [[package]] 600 | name = "ryu" 601 | version = "1.0.20" 602 | source = "registry+https://github.com/rust-lang/crates.io-index" 603 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 604 | 605 | [[package]] 606 | name = "serde" 607 | version = "1.0.219" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 610 | dependencies = [ 611 | "serde_derive", 612 | ] 613 | 614 | [[package]] 615 | name = "serde_derive" 616 | version = "1.0.219" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 619 | dependencies = [ 620 | "proc-macro2", 621 | "quote", 622 | "syn", 623 | ] 624 | 625 | [[package]] 626 | name = "serde_json" 627 | version = "1.0.140" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 630 | dependencies = [ 631 | "itoa", 632 | "memchr", 633 | "ryu", 634 | "serde", 635 | ] 636 | 637 | [[package]] 638 | name = "shell-words" 639 | version = "1.1.0" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" 642 | 643 | [[package]] 644 | name = "smallvec" 645 | version = "1.15.0" 646 | source = "registry+https://github.com/rust-lang/crates.io-index" 647 | checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 648 | 649 | [[package]] 650 | name = "spinoff" 651 | version = "0.8.0" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "20aa2ed67fbb202e7b716ff8bfc6571dd9301617767380197d701c31124e88f6" 654 | dependencies = [ 655 | "colored", 656 | "once_cell", 657 | "paste", 658 | ] 659 | 660 | [[package]] 661 | name = "stable_deref_trait" 662 | version = "1.2.0" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 665 | 666 | [[package]] 667 | name = "strsim" 668 | version = "0.11.1" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 671 | 672 | [[package]] 673 | name = "syn" 674 | version = "2.0.100" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 677 | dependencies = [ 678 | "proc-macro2", 679 | "quote", 680 | "unicode-ident", 681 | ] 682 | 683 | [[package]] 684 | name = "synstructure" 685 | version = "0.13.1" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 688 | dependencies = [ 689 | "proc-macro2", 690 | "quote", 691 | "syn", 692 | ] 693 | 694 | [[package]] 695 | name = "tempfile" 696 | version = "3.19.1" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" 699 | dependencies = [ 700 | "fastrand", 701 | "getrandom 0.3.2", 702 | "once_cell", 703 | "rustix 1.0.5", 704 | "windows-sys", 705 | ] 706 | 707 | [[package]] 708 | name = "thiserror" 709 | version = "2.0.12" 710 | source = "registry+https://github.com/rust-lang/crates.io-index" 711 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 712 | dependencies = [ 713 | "thiserror-impl", 714 | ] 715 | 716 | [[package]] 717 | name = "thiserror-impl" 718 | version = "2.0.12" 719 | source = "registry+https://github.com/rust-lang/crates.io-index" 720 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 721 | dependencies = [ 722 | "proc-macro2", 723 | "quote", 724 | "syn", 725 | ] 726 | 727 | [[package]] 728 | name = "tinystr" 729 | version = "0.7.6" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 732 | dependencies = [ 733 | "displaydoc", 734 | "zerovec", 735 | ] 736 | 737 | [[package]] 738 | name = "typenum" 739 | version = "1.18.0" 740 | source = "registry+https://github.com/rust-lang/crates.io-index" 741 | checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 742 | 743 | [[package]] 744 | name = "unicode-ident" 745 | version = "1.0.18" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 748 | 749 | [[package]] 750 | name = "unicode-width" 751 | version = "0.2.0" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 754 | 755 | [[package]] 756 | name = "url" 757 | version = "2.5.4" 758 | source = "registry+https://github.com/rust-lang/crates.io-index" 759 | checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 760 | dependencies = [ 761 | "form_urlencoded", 762 | "idna", 763 | "percent-encoding", 764 | ] 765 | 766 | [[package]] 767 | name = "utf16_iter" 768 | version = "1.0.5" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 771 | 772 | [[package]] 773 | name = "utf8_iter" 774 | version = "1.0.4" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 777 | 778 | [[package]] 779 | name = "utf8parse" 780 | version = "0.2.2" 781 | source = "registry+https://github.com/rust-lang/crates.io-index" 782 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 783 | 784 | [[package]] 785 | name = "wasi" 786 | version = "0.11.0+wasi-snapshot-preview1" 787 | source = "registry+https://github.com/rust-lang/crates.io-index" 788 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 789 | 790 | [[package]] 791 | name = "wasi" 792 | version = "0.14.2+wasi-0.2.4" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 795 | dependencies = [ 796 | "wit-bindgen-rt", 797 | ] 798 | 799 | [[package]] 800 | name = "which" 801 | version = "4.4.2" 802 | source = "registry+https://github.com/rust-lang/crates.io-index" 803 | checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" 804 | dependencies = [ 805 | "either", 806 | "home", 807 | "once_cell", 808 | "rustix 0.38.44", 809 | ] 810 | 811 | [[package]] 812 | name = "windows-sys" 813 | version = "0.59.0" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 816 | dependencies = [ 817 | "windows-targets", 818 | ] 819 | 820 | [[package]] 821 | name = "windows-targets" 822 | version = "0.52.6" 823 | source = "registry+https://github.com/rust-lang/crates.io-index" 824 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 825 | dependencies = [ 826 | "windows_aarch64_gnullvm", 827 | "windows_aarch64_msvc", 828 | "windows_i686_gnu", 829 | "windows_i686_gnullvm", 830 | "windows_i686_msvc", 831 | "windows_x86_64_gnu", 832 | "windows_x86_64_gnullvm", 833 | "windows_x86_64_msvc", 834 | ] 835 | 836 | [[package]] 837 | name = "windows_aarch64_gnullvm" 838 | version = "0.52.6" 839 | source = "registry+https://github.com/rust-lang/crates.io-index" 840 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 841 | 842 | [[package]] 843 | name = "windows_aarch64_msvc" 844 | version = "0.52.6" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 847 | 848 | [[package]] 849 | name = "windows_i686_gnu" 850 | version = "0.52.6" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 853 | 854 | [[package]] 855 | name = "windows_i686_gnullvm" 856 | version = "0.52.6" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 859 | 860 | [[package]] 861 | name = "windows_i686_msvc" 862 | version = "0.52.6" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 865 | 866 | [[package]] 867 | name = "windows_x86_64_gnu" 868 | version = "0.52.6" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 871 | 872 | [[package]] 873 | name = "windows_x86_64_gnullvm" 874 | version = "0.52.6" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 877 | 878 | [[package]] 879 | name = "windows_x86_64_msvc" 880 | version = "0.52.6" 881 | source = "registry+https://github.com/rust-lang/crates.io-index" 882 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 883 | 884 | [[package]] 885 | name = "wit-bindgen-rt" 886 | version = "0.39.0" 887 | source = "registry+https://github.com/rust-lang/crates.io-index" 888 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 889 | dependencies = [ 890 | "bitflags", 891 | ] 892 | 893 | [[package]] 894 | name = "write16" 895 | version = "1.0.0" 896 | source = "registry+https://github.com/rust-lang/crates.io-index" 897 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 898 | 899 | [[package]] 900 | name = "writeable" 901 | version = "0.5.5" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 904 | 905 | [[package]] 906 | name = "yoke" 907 | version = "0.7.5" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 910 | dependencies = [ 911 | "serde", 912 | "stable_deref_trait", 913 | "yoke-derive", 914 | "zerofrom", 915 | ] 916 | 917 | [[package]] 918 | name = "yoke-derive" 919 | version = "0.7.5" 920 | source = "registry+https://github.com/rust-lang/crates.io-index" 921 | checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 922 | dependencies = [ 923 | "proc-macro2", 924 | "quote", 925 | "syn", 926 | "synstructure", 927 | ] 928 | 929 | [[package]] 930 | name = "zerofrom" 931 | version = "0.1.6" 932 | source = "registry+https://github.com/rust-lang/crates.io-index" 933 | checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 934 | dependencies = [ 935 | "zerofrom-derive", 936 | ] 937 | 938 | [[package]] 939 | name = "zerofrom-derive" 940 | version = "0.1.6" 941 | source = "registry+https://github.com/rust-lang/crates.io-index" 942 | checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 943 | dependencies = [ 944 | "proc-macro2", 945 | "quote", 946 | "syn", 947 | "synstructure", 948 | ] 949 | 950 | [[package]] 951 | name = "zeroize" 952 | version = "1.8.1" 953 | source = "registry+https://github.com/rust-lang/crates.io-index" 954 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 955 | 956 | [[package]] 957 | name = "zerovec" 958 | version = "0.10.4" 959 | source = "registry+https://github.com/rust-lang/crates.io-index" 960 | checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 961 | dependencies = [ 962 | "yoke", 963 | "zerofrom", 964 | "zerovec-derive", 965 | ] 966 | 967 | [[package]] 968 | name = "zerovec-derive" 969 | version = "0.10.3" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 972 | dependencies = [ 973 | "proc-macro2", 974 | "quote", 975 | "syn", 976 | ] 977 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "blob-dl" 3 | version = "1.1.6" 4 | license = "MIT" 5 | description = "A cli tool to make downloading youtube content easy, based on yt-dlp" 6 | categories = ["command-line-utilities"] 7 | keywords = ["youtube", "youtube-dl", "yt-dlp", "video", "playlist"] 8 | repository = "https://github.com/MicheleCioccarelli/blob-dl" 9 | edition = "2021" 10 | 11 | [dependencies] 12 | clap = { version = "4.0.29", features = ["derive"] } 13 | colored = "2.0.0" 14 | dialoguer = "0.10.2" 15 | directories = "6.0.0" 16 | execute = "0.2.11" 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0.94" 19 | spinoff = "0.8.0" 20 | url = "2.3.1" 21 | which = "4.4.0" 22 | 23 | # The profile that 'cargo dist' will build with 24 | [profile.dist] 25 | inherits = "release" 26 | lto = "thin" 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | `blob-dl` Logo 4 | 5 |

6 | 7 |
8 | 9 | ![Crates.io](https://img.shields.io/crates/d/blob-dl?color=%2325BE5D) 10 | ![GitHub](https://img.shields.io/badge/license-MIT-blue) 11 | ![GitHub issues](https://img.shields.io/github/issues/MicheleCioccarelli/blob-dl) 12 | ![Crates.io](https://img.shields.io/crates/v/blob-dl) 13 | 14 |
15 | 16 |

blob-dl

17 | 18 | 19 | `blob-dl` is a command line tool used to download video and audio files from YouTube. It acts as an interface to [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and works by asking a series of questions that make it generate and then execute a `yt-dlp` command that fits your needs. 20 | 21 | The idea behind this program is to remove all the tedious work of researching which flags you need to pass to `yt-dlp` to make it do what you want. 22 | When you use blob-dl you only need to know the url of what you want to download, and it'll figure out the rest. 23 | 24 | - See the [Features](#Features) section for more details on what `blob-dl` can do 25 | 26 | [![asciicast](https://asciinema.org/a/jZUokSc5oDms6vICdNTic1vxh.svg)](https://asciinema.org/a/jZUokSc5oDms6vICdNTic1vxh) 27 | 28 | 29 | # Installation 30 | The most straightforward way to install `blob-dl` is to use [the binaries](https://github.com/MicheleCioccarelli/blob-dl/releases/) 31 | 32 | Alternatively, if you are a Rust programmer you can install `blob-dl` with `cargo` 33 | 34 | ``` 35 | $ cargo install blob-dl 36 | ``` 37 | ## Dependencies 38 | `blob-dl` depends on `yt-dlp`, you can install it by following the official [guide](https://github.com/yt-dlp/yt-dlp#installation). 39 | 40 | You should also install `yt-dlp`'s [recommended dependencies](https://github.com/yt-dlp/yt-dlp#dependencies) to access all of `blob-dl`'s features (namely `ffmpeg` and `ffprobe`). 41 | 42 | # Usage 43 | To use `blob-dl` you just have to pass it the url of the video or playlist that you want to download, the program will understand by itself what the link refers to and ask you questions accordingly. 44 | 45 | The first one is `What kind of file(s) do you want to download?` 46 | 47 | The answer you choose determines which download formats you can pick later on: For example, if you answer that you want to download audio-only files, then formats containing video will be hidden. In this readme, statements about downloading `video`s also apply to audio-only downloads 48 | 49 | The second question `Which quality or format do you want to apply to the video?` allows you to choose a specific format, quality, filesize, ... 50 | 51 | The available answers mean these things: 52 | 53 | - `Best possible quality` tells yt-dlp to automatically choose the `best` quality, for more information see `yt-dlp`'s [wiki](https://github.com/yt-dlp/yt-dlp#format-selection) 54 | 55 | - `Smallest file size` uses the format which results in the smallest file size 56 | 57 | - `Choose a format to recode the video to` is only available if ffmpeg is installed: After the video is downloaded, it can be converted to a file format of your choosing 58 | 59 | - `Choose a format to download the video in` doesn't require ffmpeg: it shows a list of formats directly available for download from YouTube without needing to convert anything, but the choice is rather limited 60 | 61 | 62 | `blob-dl` will also ask other questions, but they are self-explanatory 63 | 64 | # Features 65 | 66 | ### Format conversion 67 | `blob-dl` was designed to download large song playlists directly as audio files. Choosing between downloading audio files, normal video files or video-only files is very easy 68 | 69 | ### Playlist Download 70 | With `blob-dl` you can download whole playlists in one go, you can also choose a single file format to apply to all videos 71 | 72 | ### Error tracking 73 | 74 | While downloading, `blob-dl` keeps track of any errors thrown by yt-dlp and reports them at the end, the ones caused which can be resolved by re-trying the download can be easily re-downloaded 75 | 76 | ## Configuration files 77 | If you find yourself downloading videos using the same settings often and always answering the same questions has 78 | started to annoy you it's time to use a config file! 79 | 80 | It makes blob-dl already know what you want so it can avoid asking too many questions. 81 | 82 | ### How to create a config file 83 | Creating a new config file is a straight-forward process: just use the `-g` flag and blob-dl will generate a config file with your answers in its default location 84 | ``` 85 | $ blob-dl -g "youtube url" 86 | ``` 87 | Note that using `-g` multiple times will overwrite old config files: there will only be one at a time in the default location 88 | 89 | 90 | 91 | #### Default Config File Location 92 | 93 | | OS | Config Path | 94 | |:---------:|:-------------------------------------------------------------------------:| 95 | | **Linux** | `~/.config/blob-dl/config.json` or
`$XDG_CONFIG_HOME/blob-dl/config.json`| 96 | | **macOS** | `~/Library/Application Support/blob-dl/config.json` | 97 | | **Windows** | `%APPDATA%\blob-dl\config.json` | 98 | 99 | #### Notes: 100 | - On **Linux**, if `$XDG_CONFIG_HOME` is not set, it defaults to `~/.config`. 101 | - On **macOS**, `~/Library` is typically hidden. Use `Cmd + Shift + .` in Finder to reveal hidden folders. 102 | - On **Windows**, `%APPDATA%` usually resolves to `C:\Users\YourName\AppData\Roaming`. 103 | 104 | ### Usage 105 | 106 | Use `-c` or `--use-config` to use the config file present in blob-dl's default location 107 | ``` 108 | $ blob-dl -c "youtube url" 109 | ``` 110 | If you've moved your config file somewhere else you should use `-l "filepath"` 111 | ``` 112 | $ blob-dl -l "/Users/YourName/Desktop/config.json" "youtube url" 113 | ``` 114 | 115 | ### How to edit your config file 116 | A blob-dl config file looks something like this: 117 | 118 | 119 | filename: `config.json` 120 | ``` 121 | { 122 | "url": null, 123 | "output_path": "/Users/YourName/Desktop", 124 | "include_indexes": false, 125 | "chosen_format": "BestQuality", 126 | "media_selected": "FullVideo", 127 | "download_target": "YtPlaylist" 128 | } 129 | ``` 130 | Each of these fields can be set to null. If that is the case blob-dl will ask you a question related to what you've left out 131 | 132 | `url` is ignored by blob-dl, so you can leave this always null 133 | 134 | `output_path` is where the files you are downloading will end up, it should be path 135 | 136 | `include_indexes` is a boolean value: when you are downloading a playlist you can have a video's position in the playlist as a part of its filename (e.g. 1_firstvideo, 2_secondvideo, ... ) 137 | 138 | `chosen_format` is what format you want your files to be in. It has a few options: 139 | 140 | | Option | What it does | 141 | |:---------------------:|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| 142 | | **BestQuality** | blob-dl will download your video/audio in the highest quality available | 143 | | **SmallestSize** | blob-dl will download your video/audio using the smallest filesize available | 144 | | **ConvertTo(format)** | After downloading your video/audio blob-dl will use ffmpeg to convert it to a format of your choosing | 145 | | **UniqueFormat(id)** | This is not supposed to be edited by end users: each possible format has a numerical id. The problem is that it is unlikely for a specific format to be available for multiple videos, making it a bad fit for a config file | 146 | 147 | Syntax for using ConvertTo(format): 148 | ``` 149 | "chosen_format": { 150 | "ConvertTo": "mp4" 151 | }, 152 | ``` 153 | This feature supports all the formats that ffmpeg does: `mp4, mkv, mov, avi, flv, gif, webm, aac, aiff, alac, flac, m4a, mka, mp3, ogg, opus, vorbis, wav` 154 | 155 | `media_selection` refers to whether you want to download a normal video, audio only or video only. 156 | It expects a string and the available options are: `FullVideo` `AudioOnly` `VideoOnly` 157 | 158 | `download_target` is whether you are downloading a single video or a full playlist. 159 | It expects a string, the options are `YtPlaylist` (which should be used in most circumstances, even when downloading a normal video) and `YtVideo(index)` which is only needed when you are downloading a single video from a playlist, needing to specify its index in it. 160 | 161 | # Q&A 162 | ### Who is this for? 163 | This program is intended for anyone who wants to download things from YouTube without having to remember yt-dlp's syntax. `blob-dl` can do everything an average user needs but with less hassle 164 | 165 | `yt-dlp` power users with advanced needs probably won't find this program useful. 166 | 167 | ### Why did I make this? 168 | Have you ever had to download videos from YouTube? 169 | The process can be quite a pain because you will have to either spend your time closing pop-ups from a sketchy website or browsing through [`yt-dlp`](https://github.com/yt-dlp/yt-dlp)'s documentation. 170 | 171 | I was tired of spending hours downloading music videos and converting them to audio, so I wrote this program to make everything way easier 172 | 173 | ## Notes 174 | This logo was inspired by [@Primer](https://www.youtube.com/c/PrimerLearning)'s [blob plushie](https://store.dftba.com/collections/primer/products/primer-blob-plushie) 175 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Version 1.0.1 (2023-08-22) 2 | Made url parsing more robust and polished 3 | 4 | # Version 1.0.0 (2023-08-20) 5 | This is the first fully-featured version of blob-dl, tested with yt-dlp 2023.07.26 -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "powershell"] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 14 | # Which actions to run on pull requests 15 | pr-run-mode = "plan" 16 | # Whether to install an updater program 17 | install-updater = false 18 | # Path that installers should place binaries in 19 | install-path = "CARGO_HOME" 20 | -------------------------------------------------------------------------------- /src/analyzer.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | use dialoguer::console::Term; 3 | use dialoguer::{theme::ColorfulTheme, Select}; 4 | use serde::{Deserialize, Serialize}; 5 | use crate::error::{BlobdlError, BlobResult}; 6 | 7 | #[derive(Debug, PartialOrd, PartialEq, Clone, Deserialize, Serialize)] 8 | pub enum DownloadOption { 9 | /// If the url refers to a video in a playlist and the user only wants to download the single video, YtVideo's value is the video's index in the playlist 10 | YtVideo(usize), 11 | YtPlaylist, 12 | } 13 | 14 | /// Analyzes the url provided by the user and deduces whether it 15 | /// refers to a youtube video or playlist 16 | pub fn analyze_url(command_line_url: &str) -> BlobResult { 17 | if let Ok(url) = Url::parse(command_line_url) { 18 | if let Some(domain_name) = url.domain() { 19 | // All youtube-related urls have "youtu" in them 20 | if domain_name.contains("youtu") { 21 | inspect_yt_url(url) 22 | } else { 23 | // The url isn't from youtube 24 | Err(BlobdlError::UnsupportedWebsite) 25 | } 26 | } else { 27 | Err(BlobdlError::DomainNotFound) 28 | } 29 | } else { 30 | Err(BlobdlError::UrlParsingError) 31 | } 32 | } 33 | 34 | /// Given a youtube url determines whether it refers to a video/playlist 35 | fn inspect_yt_url(yt_url: Url) -> BlobResult { 36 | if let Some(query) = yt_url.query() { 37 | // Also urls can be part of a playlist but not have an index, just an id 38 | // example: https://www.youtube.com/watch?v=GNxZ_izoC8I&list=PLl-vhnGPY7cqQ0b_NXy1qyMVsA9LHiPmv 39 | // maybe support for this will be added in the future 40 | if query.contains("&index="){ 41 | // This video is part of a youtube playlist 42 | let term = Term::buffered_stderr(); 43 | 44 | // Ask the user whether they want to download the whole playlist or just the video 45 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 46 | .with_prompt("The url refers to a video in a playlist, which do you want to download?") 47 | .default(0) 48 | .items(&["Only the video", "The whole playlist"]) 49 | .interact_on(&term)?; 50 | 51 | return match user_selection { 52 | 0 => { 53 | // If only this video needs to be downloaded, calculate its index 54 | let index = if let Some(index_location) = query.find("&index=") { 55 | let slice = &query[index_location + "&index=".len() ..]; 56 | 57 | if let Some(second_ampersand_location) = slice.find('&') { 58 | // There are url parameters after &index=.. 59 | &slice[..second_ampersand_location] 60 | } else { 61 | slice 62 | } 63 | } else { 64 | return Err(BlobdlError::PlaylistUrlError); 65 | }; 66 | 67 | if let Ok(parsed) = index.parse() { 68 | Ok(DownloadOption::YtVideo(parsed)) 69 | } else { 70 | Err(BlobdlError::UrlIndexParsingError) 71 | } 72 | } 73 | 74 | _ => Ok(DownloadOption::YtPlaylist), 75 | }; 76 | } 77 | if yt_url.path().contains("playlist") || query.contains("list"){ 78 | return Ok(DownloadOption::YtPlaylist); 79 | } 80 | 81 | // This url is probably referring to a video or a short 82 | return Ok(DownloadOption::YtVideo(1)); 83 | } 84 | 85 | Err(BlobdlError::QueryCouldNotBeParsed) 86 | } -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | use crate::{parser, ui_prompts}; 3 | use crate::dispatcher::dispatch; 4 | use which::which; 5 | 6 | /// Handles most of the running logic behind blob-dl 7 | /// 8 | /// First it checks whether yt-dlp is installed 9 | /// 10 | /// Then it launches functions to parse command-line arguments and passes them to dispatcher() 11 | pub fn run() { 12 | // Processed command line arguments live here 13 | let config = parser::parse_config(); 14 | #[cfg(debug_assertions)] 15 | println!("##DEBUG## {:?}", config); 16 | 17 | // tested with yt-dlp 2025.03.31 18 | if which("yt-dlp").is_ok() { 19 | // check whether yt-dlp's version is compatible with this version of blob-dl 20 | match parser::is_ytdlp_compatible() { 21 | Ok(false) => { 22 | // This was annoying, might add this back in a future update 23 | print!("{}", "WARNING: ".bold().yellow()); 24 | println!("{}", ui_prompts::WRONG_YTDLP_VERSION); 25 | } 26 | Err(_) => { 27 | print!("{}", "WARNING: ".bold().yellow()); 28 | println!("{}", ui_prompts::COMMAND_NOT_SPAWNED); 29 | }, 30 | _ => {} 31 | } 32 | 33 | match config { 34 | Ok(config) => { 35 | // Ask for more input > Generate a command > Execute yt-dlp 36 | if let Err(err) = dispatch(&config) { 37 | // Tell the user about the error 38 | err.report(); 39 | } 40 | } 41 | Err(err) => { 42 | err.report(); 43 | } 44 | } 45 | } else { 46 | // ytdlp is not installed! 47 | eprintln!("{}", crate::ui_prompts::YTDLP_NOT_INSTALLED); 48 | } 49 | } -------------------------------------------------------------------------------- /src/assembling.rs: -------------------------------------------------------------------------------- 1 | pub mod youtube; 2 | 3 | use crate::analyzer; 4 | use crate::error::BlobResult; 5 | 6 | /// Asks the user for specific download preferences (output path, download format, ...) and builds 7 | /// a yt-dlp command according to them 8 | /// 9 | /// User config is the information present in a config file. It has user preferences on things like which file format they prefer 10 | /// knowing this blob-dl can avoid asking redundant questions 11 | /// 12 | /// Returns the command along with a DownloadConfig object, which contains all the user-specified preferences 13 | pub(crate) fn generate_command(url: &str, download_option: &analyzer::DownloadOption, user_config: youtube::config::DownloadConfig) -> BlobResult<(std::process::Command, youtube::config::DownloadConfig)> { 14 | // Get preferences from the user, various errors may occur 15 | let unchecked_config = match download_option { 16 | analyzer::DownloadOption::YtPlaylist => youtube::yt_playlist::assemble_data(url, user_config), 17 | 18 | analyzer::DownloadOption::YtVideo(id) => youtube::yt_video::assemble_data(url, *id, user_config) 19 | }; 20 | 21 | match unchecked_config { 22 | Ok(safe) => { 23 | // Everything went smoothly, now generate a yt-dlp command 24 | let (command, local_config) = safe.build_command()?; 25 | Ok((command, local_config)) 26 | } 27 | // Propagate the errors 28 | Err(err) => Err(err) 29 | } 30 | } -------------------------------------------------------------------------------- /src/assembling/youtube.rs: -------------------------------------------------------------------------------- 1 | pub mod yt_playlist; 2 | pub mod yt_video; 3 | pub mod config; 4 | 5 | use crate::error::{BlobdlError, BlobResult}; 6 | use dialoguer::console::Term; 7 | use dialoguer::{theme::ColorfulTheme, Select, Input}; 8 | use serde::{Deserialize, Serialize}; 9 | use serde_json; 10 | use std::{env, fmt}; 11 | use colored::Colorize; 12 | 13 | // Functions used both in yt_video.rs and yt_playlist.rs 14 | /// Asks the user whether they want to download video files or audio-only 15 | fn get_media_selection(term: &Term) -> Result { 16 | let download_formats = &[ 17 | "Normal Video", 18 | "Audio-only", 19 | "Video-only" 20 | ]; 21 | 22 | // Ask the user which format they want the downloaded files to be in 23 | let media_selection = Select::with_theme(&ColorfulTheme::default()) 24 | .with_prompt("What kind of file(s) do you want to download?") 25 | .default(0) 26 | .items(download_formats) 27 | .interact_on(term)?; 28 | 29 | match media_selection { 30 | 0 => Ok(MediaSelection::FullVideo), 31 | 1 => Ok(MediaSelection::AudioOnly), 32 | _ => Ok(MediaSelection::VideoOnly), 33 | } 34 | } 35 | 36 | /// Asks for an directory to store downloaded file(s) in 37 | /// 38 | /// The current directory can be selected or one can be typed in 39 | fn get_output_path(term: &Term) -> BlobResult { 40 | let output_path_options = &[ 41 | "Current directory", 42 | "Other [specify]", 43 | ]; 44 | 45 | let output_path = Select::with_theme(&ColorfulTheme::default()) 46 | .with_prompt("Where do you want the downloaded file(s) to be saved?") 47 | .default(0) 48 | .items(output_path_options) 49 | .interact_on(term)?; 50 | 51 | match output_path { 52 | // Return the current directory 53 | 0 => Ok(env::current_dir()? 54 | .as_path() 55 | .display() 56 | .to_string()), 57 | 58 | // Return a directory typed in by the user 59 | _ => Ok(Input::with_theme(&ColorfulTheme::default()) 60 | .with_prompt("Output path:") 61 | .interact_text()?), 62 | } 63 | } 64 | 65 | 66 | use spinoff; 67 | use std::process; 68 | // Running yt-dlp -j <...> 69 | use execute::Execute; 70 | use crate::error::BlobdlError::JsonGenerationError; 71 | 72 | /// Returns the output of : a JSON dump of all the available format information for a video 73 | fn get_ytdlp_formats(url: &str) -> Result { 74 | // Neat animation to entertain the user while the information is being downloaded 75 | let mut sp = spinoff::Spinner::new(spinoff::spinners::Dots10, "Fetching available formats...", spinoff::Color::Cyan); 76 | 77 | let mut command = process::Command::new("yt-dlp"); 78 | // Get a JSON dump of all the available formats related to this url 79 | command.arg("-J"); 80 | // Continue even if you get errors 81 | command.arg("-i"); 82 | command.arg(url); 83 | 84 | // Redirect the output to a variable instead of to the screen 85 | command.stdout(process::Stdio::piped()); 86 | // Don't show errors and warnings 87 | command.stderr(process::Stdio::piped()); 88 | let output = command.execute_output(); 89 | 90 | // Stop the ui spinner 91 | sp.success("Formats downloaded successfully".bold().to_string().as_str()); 92 | 93 | output 94 | } 95 | 96 | /// Ask the user what format they want the downloaded file to be recoded to (yt-dlp postprocessor) REQUIRES FFMPEG 97 | fn convert_to_format(term: &Term, media_selected: &MediaSelection) 98 | -> BlobResult 99 | { 100 | // Available formats for recoding 101 | let format_options = match *media_selected { 102 | // Only show audio-only formats 103 | MediaSelection::AudioOnly => vec!["mp3", "m4a", "wav", "aac", "alac", "flac", "opus", "vorbis"], 104 | // Only show formats which aren't audio-only 105 | MediaSelection::VideoOnly => vec!["mp4", "mkv", "mov", "avi", "flv", "gif", "webm", "aiff", "mka", "ogg"], 106 | // Show all the available formats 107 | MediaSelection::FullVideo => vec!["mp4", "mkv", "mov", "avi", "flv", "gif", "webm", "aac", "aiff", 108 | "alac", "flac", "m4a", "mka", "mp3", "ogg", "opus", "vorbis", "wav"], 109 | }; 110 | 111 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 112 | .with_prompt("Which container do you want the final file to be in?") 113 | .default(0) 114 | .items(&format_options) 115 | .interact_on(term)?; 116 | 117 | Ok(VideoQualityAndFormatPreferences::ConvertTo(format_options[user_selection].to_string())) 118 | } 119 | 120 | #[derive(Deserialize, Serialize, Debug)] 121 | struct Format { 122 | format_id: String, 123 | } 124 | 125 | /// Serializes the information about all the formats available for 1 video 126 | fn serialize_formats(json_dump: Option<&str>) -> BlobResult { 127 | if let Some(json) = json_dump { 128 | let result = serde_json::from_str(json); 129 | match result { 130 | Ok(cool) => Ok(cool), 131 | Err(err) => Err(BlobdlError::SerdeError(err)) 132 | } 133 | } else { 134 | Err(JsonGenerationError) 135 | } 136 | } 137 | 138 | /// Checks if format has conflicts with media_selected (like a video only format and an audio-only media_selection 139 | /// 140 | /// Returns true format and media_selected are compatible 141 | fn check_format(format: &VideoFormat, media_selected: &MediaSelection) -> bool { 142 | // Skip image and weird formats (examples of strange formats ids: 233, 234, sb2, sb1, sb0) 143 | if format.filesize.is_none() { 144 | return false; 145 | } 146 | // Skip audio-only files if the user wants full video 147 | if *media_selected == MediaSelection::FullVideo && format.resolution == "audio only" { 148 | return false; 149 | } 150 | // Skip video files if the user wants audio-only 151 | if *media_selected == MediaSelection::AudioOnly && format.resolution != "audio only" { 152 | return false; 153 | } 154 | if let Some(acodec) = &format.acodec { 155 | // Skip video-only files if the user doesn't want video-only 156 | if *media_selected == MediaSelection::FullVideo && acodec == "none" { 157 | return false; 158 | } 159 | //Skip normal video if the user wants video-only 160 | if *media_selected == MediaSelection::VideoOnly && acodec != "none" { 161 | return false; 162 | } 163 | } 164 | true 165 | } 166 | 167 | // Common enums and structs 168 | /// Whether the user wants to download video files or audio-only 169 | #[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)] 170 | pub(crate) enum MediaSelection { 171 | FullVideo, 172 | VideoOnly, 173 | AudioOnly, 174 | } 175 | 176 | /// All the information about a particular video format 177 | #[derive(Deserialize, Serialize, Debug, PartialOrd, PartialEq, Clone)] 178 | struct VideoFormat { 179 | format_id: String, 180 | // File extension 181 | ext: String, 182 | // Fps count, is null for audio-only formats 183 | fps: Option, 184 | // How many audio channels are available, is null for video-only formats. Unavailable on weird sb* formats 185 | audio_channels: Option, 186 | // Video resolution, is "audio only" for audio-only formats 187 | resolution: String, 188 | // Measured in MB. Unavailable on sb* formats 189 | filesize: Option, 190 | // Video codec, can be "none" 191 | vcodec: String, 192 | // Audio codec, can be "none" or straight up not exist (like in mp4 audio-only formats) 193 | acodec: Option, 194 | // Codec container 195 | container: Option, 196 | // Total average bitrate 197 | tbr: Option, 198 | // When filesize is null, this may be available 199 | filesize_approx: Option, 200 | } 201 | 202 | // A list of all the formats available for a single video 203 | #[derive(Deserialize, Serialize, Debug, Clone)] 204 | struct VideoSpecs { 205 | formats: Vec, 206 | } 207 | 208 | /// What quality and format the user wants a specific video to be downloaded in 209 | #[derive(Debug, Clone, Serialize, Deserialize)] 210 | pub(crate) enum VideoQualityAndFormatPreferences { 211 | // Code of the selected format 212 | UniqueFormat(String), 213 | // Recode the downloaded file to this format (post-processor) 214 | ConvertTo(String), 215 | BestQuality, 216 | SmallestSize, 217 | } 218 | 219 | impl fmt::Display for VideoFormat { 220 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 221 | let mut result; 222 | 223 | if let Some(tbr) = self.tbr { 224 | // Skip picture formats 225 | // Add container 226 | result = format!("{:<6} ", self.ext); 227 | 228 | if self.resolution != "audio only" { 229 | result = format!("{}| {:<13} ", result, self.resolution); 230 | } 231 | 232 | // This isn't a picture format so unwrap() is safe 233 | let filesize = self.filesize.unwrap_or(0); 234 | 235 | // filesize is converted from bytes to MB 236 | let filesize_section = format!("| filesize: {:<.2}MB", filesize as f32 * 0.000001); 237 | result = format!("{}{:<24}", result, filesize_section); 238 | 239 | // If available, add audio channels 240 | if let Some(ch) = self.audio_channels { 241 | result = format!("{}| {} audio ch ", result, ch); 242 | } 243 | 244 | result = format!("{}| tbr: {:<8.2} ", result, tbr); 245 | 246 | if self.vcodec != "none" { 247 | result = format!("{}| vcodec: {:<13} ", result, self.vcodec); 248 | } 249 | 250 | if let Some(acodec) = &self.acodec { 251 | if acodec != "none" { 252 | result = format!("{}| acodec: {:<13} ", result, acodec); 253 | } 254 | } 255 | 256 | #[cfg(debug_assertions)] 257 | return { 258 | result = format!("[[DEBUG code: {:<3}]] {} ", self.format_id, result); 259 | write!(f, "{}", result) 260 | }; 261 | 262 | #[cfg(not(debug_assertions))] 263 | write!(f, "{}", result) 264 | } else { 265 | write!(f, "I shouldn't show up because I am a picture format") 266 | } 267 | } 268 | } 269 | 270 | impl VideoSpecs { 271 | fn formats(&self) -> &Vec { 272 | &self.formats 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/assembling/youtube/config.rs: -------------------------------------------------------------------------------- 1 | use crate::assembling::youtube; 2 | use crate::analyzer; 3 | use std::process; 4 | use serde::{Deserialize, Serialize}; 5 | use crate::analyzer::DownloadOption; 6 | use crate::error::{BlobResult, BlobdlError}; 7 | 8 | /// Contains all the information needed to download a youtube video or playlist 9 | #[derive(Debug, Clone, Serialize, Deserialize)] 10 | pub(crate) struct DownloadConfig { 11 | pub(crate) url: Option, 12 | 13 | pub(crate) output_path: Option, 14 | /// Whether to include a file's index (in the playlist it is downloaded from) in its name 15 | pub(crate) include_indexes: Option, 16 | /// The quality and format the user wants the downloaded files to be in 17 | pub(crate) chosen_format: Option, 18 | /// Whether the downloaded files have to be audio-only/video-only/normal video 19 | pub(crate) media_selected: Option, 20 | /// Whether the link refers to a p laylist or a single video 21 | pub(crate) download_target: Option, 22 | } 23 | 24 | impl DownloadConfig { 25 | // Creates a DownloadConfig with all fields set to None 26 | pub(crate) fn empty() -> DownloadConfig { 27 | DownloadConfig { 28 | url: None, 29 | output_path: None, 30 | include_indexes: None, 31 | chosen_format: None, 32 | media_selected: None, 33 | download_target: None, 34 | } 35 | } 36 | 37 | pub(crate) fn new_playlist ( 38 | url: &str, 39 | output_path: String, 40 | include_indexes: bool, 41 | chosen_format: youtube::VideoQualityAndFormatPreferences, 42 | media_selected: youtube::MediaSelection, 43 | ) 44 | -> DownloadConfig 45 | { 46 | DownloadConfig { 47 | url: Some(url.to_string()), 48 | output_path: Some(output_path), 49 | include_indexes: Some(include_indexes), 50 | chosen_format: Some(chosen_format), 51 | media_selected: Some(media_selected), 52 | download_target: Some(analyzer::DownloadOption::YtPlaylist) } 53 | } 54 | 55 | pub(crate) fn new_video ( 56 | url: &str, 57 | chosen_format: youtube::VideoQualityAndFormatPreferences, 58 | output_path: String, 59 | media_selected: youtube::MediaSelection, 60 | playlist_index: usize, 61 | ) 62 | -> DownloadConfig 63 | { 64 | DownloadConfig { 65 | url: Some(url.to_string()), 66 | chosen_format: Some(chosen_format), 67 | output_path: Some(output_path), 68 | media_selected: Some(media_selected), 69 | include_indexes: Some(false), 70 | download_target: Some(analyzer::DownloadOption::YtVideo(playlist_index)) } 71 | } 72 | } 73 | 74 | // Command generation 75 | // IMPORTANT WARNING: All of these functions expect every member of DownloadConfig to not be None, or else they will return errors 76 | // The idea is to provide them before getting to this stage. 77 | impl DownloadConfig { 78 | /// Builds a command according to the current configuration, which is also returned 79 | /// 80 | /// This function is meant for the main video-downloading task 81 | pub(crate) fn build_command(&self) -> BlobResult<(process::Command, DownloadConfig)> { 82 | if let Some(download_target) = &self.download_target { 83 | Ok(( 84 | match download_target { 85 | analyzer::DownloadOption::YtVideo(_) => self.build_yt_video_command()?, 86 | analyzer::DownloadOption::YtPlaylist => self.build_yt_playlist_command()?, 87 | }, 88 | self.clone() 89 | )) 90 | } else { 91 | Err(BlobdlError::DownloadTargetNotProvided) 92 | } 93 | } 94 | 95 | fn build_yt_playlist_command(&self) -> BlobResult{ 96 | let mut command = process::Command::new("yt-dlp"); 97 | 98 | // Continue even when errors are encountered 99 | command.arg("-i"); 100 | 101 | // If the url refers to a video in a playlist, download the whole playlist 102 | command.arg("--yes-playlist"); 103 | 104 | // Setup output directory and naming scheme 105 | self.choose_output_path(&mut command)?; 106 | 107 | // Makes the id live long enough to be used as an arg for command. 108 | // If it was fetched from the next match arm the temporary &str would not outlive command 109 | let id = match &self.chosen_format { 110 | Some(youtube::VideoQualityAndFormatPreferences::UniqueFormat(id)) => id.to_string(), 111 | _ => String::new(), 112 | }; 113 | 114 | // Quality and format selection 115 | self.choose_format(&mut command, id.as_str())?; 116 | 117 | if let Some(url) = self.url.clone() { 118 | 119 | // Add the playlist's url 120 | command.arg(url); 121 | 122 | Ok(command) 123 | } else { 124 | Err(BlobdlError::UrlNotProvided) 125 | } 126 | } 127 | 128 | fn build_yt_video_command(&self) -> BlobResult { 129 | let mut command = process::Command::new("yt-dlp"); 130 | 131 | self.choose_output_path(&mut command)?; 132 | 133 | if let Some(chosen_format) = &self.chosen_format { 134 | 135 | // Makes the id live long enough to be used as an arg for command. 136 | // If it was fetched from the next match arm the temporary &str would not outlive command 137 | let id = match chosen_format { 138 | youtube::VideoQualityAndFormatPreferences::UniqueFormat(id) => id.to_string(), 139 | _ => String::new(), 140 | }; 141 | 142 | self.choose_format(&mut command, &id)?; 143 | 144 | command.arg("--no-playlist"); 145 | 146 | if let Some(DownloadOption::YtVideo(index)) = &self.download_target { 147 | command.arg("--playlist-items"); 148 | command.arg(format!("{}", index)); 149 | } 150 | 151 | // If they are available also download subtitles 152 | command.arg("--embed-subs"); 153 | 154 | if let Some(url) = self.url.clone() { 155 | command.arg(url); 156 | } else { 157 | return Err(BlobdlError::UrlNotProvided); 158 | } 159 | 160 | Ok(command) 161 | } else { 162 | Err(BlobdlError::FormatPreferenceNotProvided) 163 | } 164 | } 165 | 166 | /// Downloads a new video while keeping the current preferences. 167 | /// 168 | /// This function is meant to be used to re-download videos which failed because of issues like bad internet 169 | pub fn build_command_for_video(&self, video_id: &str) -> BlobResult { 170 | let mut command = process::Command::new("yt-dlp"); 171 | 172 | self.choose_output_path(&mut command)?; 173 | 174 | if let Some(chosen_format) = &self.chosen_format { 175 | 176 | // Makes the id live long enough to be used as an arg for command. 177 | // If it was fetched from the next match arm the temporary &str would not outlive command 178 | let id = match chosen_format { 179 | youtube::VideoQualityAndFormatPreferences::UniqueFormat(id) => id.to_string(), 180 | _ => String::new(), 181 | }; 182 | 183 | self.choose_format(&mut command, id.as_str())?; 184 | 185 | command.arg("--no-playlist"); 186 | 187 | command.arg(video_id); 188 | 189 | Ok(command) 190 | } else { 191 | Err(BlobdlError::FormatPreferenceNotProvided) 192 | } 193 | } 194 | 195 | // funzione un po' schifosa 196 | fn choose_output_path(&self, command: &mut process::Command) -> BlobResult<()> { 197 | if let Some(output_path) = &self.output_path { 198 | if let Some(download_target) = &self.download_target { 199 | if let Some(include_indexes) = self.include_indexes { 200 | command.arg("-o"); 201 | command.arg( 202 | { 203 | let mut path_and_scheme = String::new(); 204 | // Add the user's output path (empty string for current directory) 205 | path_and_scheme.push_str(output_path); 206 | 207 | if *download_target == analyzer::DownloadOption::YtPlaylist { 208 | // Create a directory named after the playlist 209 | #[cfg(target_os = "windows")] 210 | path_and_scheme.push_str("\\%(playlist)s\\"); 211 | 212 | #[cfg(not(target_os = "windows"))] 213 | path_and_scheme.push_str("/%(playlist)s/"); 214 | 215 | if include_indexes { 216 | path_and_scheme.push_str("%(playlist_index)s_"); 217 | }; 218 | path_and_scheme.push_str("%(title)s"); 219 | } else { 220 | // Downloading a yt_video 221 | #[cfg(target_os = "windows")] 222 | path_and_scheme.push_str("\\%(title)s.%(ext)s"); 223 | 224 | #[cfg(not(target_os = "windows"))] 225 | path_and_scheme.push_str("/%(title)s.%(ext)s"); 226 | } 227 | 228 | path_and_scheme 229 | } 230 | ); 231 | Ok(()) 232 | 233 | } else { 234 | Err(BlobdlError::IncludeIndexesNotProvided) 235 | } 236 | } else { 237 | Err(BlobdlError::DownloadTargetNotProvided) 238 | } 239 | } else { 240 | Err(BlobdlError::OutputPathNotProvided) 241 | } 242 | } 243 | 244 | fn choose_format(&self, command: &mut process::Command, format_id: &str) -> BlobResult<()> { 245 | if let Some(media_selected) = &self.media_selected { 246 | if let Some(chosen_format) = &self.chosen_format { 247 | match media_selected { 248 | youtube::MediaSelection::FullVideo => { 249 | match chosen_format { 250 | youtube::VideoQualityAndFormatPreferences::BestQuality => {} 251 | 252 | youtube::VideoQualityAndFormatPreferences::SmallestSize => { 253 | command.arg("-S").arg("+size,+br"); 254 | } 255 | 256 | youtube::VideoQualityAndFormatPreferences::UniqueFormat(_) => { 257 | command.arg("-f").arg(format_id); 258 | } 259 | youtube::VideoQualityAndFormatPreferences::ConvertTo(f) => { 260 | command.arg("--recode-video").arg(f.as_str()); 261 | } 262 | } 263 | // If they are available also download subtitles 264 | command.arg("--embed-subs"); 265 | } 266 | 267 | youtube::MediaSelection::AudioOnly => { 268 | match chosen_format { 269 | youtube::VideoQualityAndFormatPreferences::BestQuality => { 270 | command.arg("-f").arg("bestaudio"); 271 | } 272 | 273 | youtube::VideoQualityAndFormatPreferences::SmallestSize => { 274 | command.arg("-f").arg("worstaudio"); 275 | } 276 | 277 | youtube::VideoQualityAndFormatPreferences::UniqueFormat(_) => { 278 | command.arg("-f").arg(format_id); 279 | } 280 | youtube::VideoQualityAndFormatPreferences::ConvertTo(f) => { 281 | command.arg("-x").arg("--audio-format").arg(f.as_str()); 282 | } 283 | } 284 | } 285 | 286 | youtube::MediaSelection::VideoOnly => { 287 | match chosen_format { 288 | youtube::VideoQualityAndFormatPreferences::BestQuality => { 289 | command.arg("-f").arg("bestvideo"); 290 | } 291 | 292 | youtube::VideoQualityAndFormatPreferences::SmallestSize => { 293 | command.arg("-f").arg("worstvideo"); 294 | } 295 | 296 | youtube::VideoQualityAndFormatPreferences::UniqueFormat(_) => { 297 | command.arg("-f").arg(format_id); 298 | } 299 | youtube::VideoQualityAndFormatPreferences::ConvertTo(f) => { 300 | command.arg("--recode-video").arg(f.as_str()); 301 | } 302 | } 303 | // If they are available also download subtitles 304 | command.arg("--embed-subs"); 305 | } 306 | }; 307 | } else { 308 | return Err(BlobdlError::ChosenFormatNotProvided); 309 | } 310 | } else { 311 | return Err(BlobdlError::MediaSelectedNotProvided); 312 | } 313 | Ok(()) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/assembling/youtube/yt_playlist.rs: -------------------------------------------------------------------------------- 1 | use crate::assembling::youtube; 2 | use crate::assembling::youtube::*; 3 | use crate::error::BlobResult; 4 | use crate::ui_prompts::*; 5 | use dialoguer::console::Term; 6 | use dialoguer::{theme::ColorfulTheme, Select}; 7 | use which::which; 8 | 9 | /// This is a wizard for downloading a youtube playlist 10 | /// 11 | /// It asks for: 12 | /// - Video or Audio 13 | /// - Quality/Format 14 | /// - Output path 15 | /// - Index inclusion 16 | /// 17 | /// Returns a fully configured YtPlaylistConfig, build_command() can be called 18 | /// 19 | /// User config is the information present in a config file. It has user preferences on things like which file format they prefer 20 | /// knowing this blob-dl can avoid asking redundant questions 21 | /// 22 | pub(crate) fn assemble_data(url: &str, user_config: youtube::config::DownloadConfig) -> BlobResult { 23 | let term = Term::buffered_stderr(); 24 | 25 | let media_selected; 26 | if let Some(media) = user_config.media_selected { 27 | // if a media selection was already present in the config file, use that 28 | media_selected = media; 29 | } else { 30 | // Whether the user wants to download video files or audio-only 31 | media_selected = get_media_selection(&term)?; 32 | } 33 | 34 | let chosen_format; 35 | if let Some(format) = user_config.chosen_format { 36 | // With config files it is possible to "force" blob-dl to try to use ffmpeg 37 | if let VideoQualityAndFormatPreferences::ConvertTo(_) = format { 38 | // The user wants their files to be converted to another format 39 | if !which("ffmpeg").is_ok() { 40 | // The conversion cannot be performed because ffmpeg is not installed 41 | chosen_format = format::get_format(&term, url, &media_selected)?; 42 | } else { 43 | // ffmpeg is installed so what was specified in the config file can be used 44 | chosen_format = format; 45 | } 46 | } else { 47 | chosen_format = format; 48 | } 49 | } else { 50 | // Config file didn't say anything about file formats 51 | chosen_format = format::get_format(&term, url, &media_selected)?; 52 | } 53 | 54 | let output_path; 55 | // .trim() trims trailing whitespace at the end of the user-specified path (useful is the user is clumsy) 56 | if let Some(path) = user_config.output_path { 57 | output_path = path; 58 | } else { 59 | output_path = get_output_path(&term)?.trim().to_string(); 60 | } 61 | 62 | let include_indexes; 63 | if let Some(indexes) = user_config.include_indexes { 64 | include_indexes = indexes; 65 | } else { 66 | include_indexes = get_index_preference(&term)?; 67 | } 68 | 69 | Ok(config::DownloadConfig::new_playlist( 70 | url, 71 | output_path, 72 | include_indexes, 73 | chosen_format, 74 | media_selected, 75 | )) 76 | } 77 | 78 | mod format { 79 | /// All the formats a particular playlist can be downloaded in 80 | /// 81 | /// These are divided in videos 82 | /// 83 | #[derive(Debug)] 84 | struct FormatsLibrary { 85 | videos: Vec, 86 | } 87 | impl FormatsLibrary { 88 | pub fn new() -> Self { 89 | FormatsLibrary { videos: vec![] } 90 | } 91 | pub fn add_video(&mut self, video_formats: VideoSpecs) { 92 | self.videos.push(video_formats) 93 | } 94 | pub fn videos(&self) -> &Vec { 95 | &self.videos 96 | } 97 | } 98 | 99 | use super::*; 100 | use crate::assembling::youtube::VideoSpecs; 101 | use crate::error::BlobdlError::JsonSerializationError; 102 | 103 | /// Asks the user to choose a download format and quality 104 | /// 105 | /// The chosen format will be applied to the entire playlist 106 | pub(super) fn get_format(term: &Term, url: &str, media_selected: &MediaSelection) 107 | -> BlobResult 108 | { 109 | 110 | // A list of all the format options that can be picked 111 | let mut format_options: Vec<&str> = vec![]; 112 | // Default choices 113 | format_options.push(BEST_QUALITY_PROMPT_PLAYLIST); 114 | format_options.push(SMALLEST_QUALITY_PROMPT_PLAYLIST); 115 | 116 | if which("ffmpeg").is_ok() { 117 | // If ffmpeg is installed in the system 118 | // Some features are only available with ffmpeg 119 | match media_selected { 120 | MediaSelection::AudioOnly => format_options.push(CONVERT_FORMAT_PROMPT_AUDIO), 121 | _ => format_options.push(CONVERT_FORMAT_PROMPT_VIDEO_PLAYLIST) 122 | } 123 | 124 | format_options.push(YT_FORMAT_PROMPT_PLAYLIST); 125 | 126 | // Set up a prompt for the user 127 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 128 | .with_prompt("Which quality or format do you want to apply to all videos?") 129 | .default(0) 130 | .items(&format_options) 131 | .interact_on(term)?; 132 | match user_selection { 133 | 0 => Ok(VideoQualityAndFormatPreferences::BestQuality), 134 | 1 => Ok(VideoQualityAndFormatPreferences::SmallestSize), 135 | 2 => convert_to_format(term, media_selected), 136 | _ => get_format_from_yt(term, url, media_selected), 137 | } 138 | } else { 139 | println!("{}", FFMPEG_UNAVAILABLE_WARNING); 140 | // ffmpeg isn't installed, so ffmpeg-exclusive features are unavailable (video remuxing) 141 | format_options.push(YT_FORMAT_PROMPT_PLAYLIST); 142 | 143 | // Set up a prompt for the user 144 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 145 | .with_prompt("Which quality or format do you want to apply to all videos?") 146 | .default(0) 147 | .items(&format_options) 148 | .interact_on(term)?; 149 | match user_selection { 150 | 0 => Ok(VideoQualityAndFormatPreferences::BestQuality), 151 | 1 => Ok(VideoQualityAndFormatPreferences::SmallestSize), 152 | _ => get_format_from_yt(term, url, media_selected), 153 | } 154 | } 155 | } 156 | 157 | // Show the user a list of formats common across the whole playlist, picked from those available directly from yt. 158 | fn get_format_from_yt(term: &Term, url: &str, media_selected: &MediaSelection) 159 | -> BlobResult 160 | { 161 | // Get a list of all the formats available for the playlist 162 | let ytdl_formats = get_ytdlp_formats(url)?; 163 | 164 | // If stdout is empty ytdlp had an error and the formats aren't available 165 | if ytdl_formats.stdout.is_empty() { 166 | return Err(JsonSerializationError) 167 | } 168 | 169 | // Filter out formats not available for all the videos 170 | let (intersections, all_available_formats) = get_common_formats(ytdl_formats)?; 171 | 172 | // Ids which the user can pick according to the current media selection (VideoOnly / AudioOnly / FullVideo) 173 | let mut correct_ids = vec![]; 174 | // Format options that will be shown to the user 175 | let mut ui_format_options = vec![]; 176 | 177 | // Only look at ids common across the whole playlist 178 | for id in intersections.iter() { 179 | // Since we are looking for ids common to all videos just checking the first one is fine 180 | if let Some(first_video_formats) = all_available_formats.videos().first() { 181 | for format in first_video_formats.formats() { 182 | // If format and media_selected are compatible and this is the correct id 183 | if check_format(format, media_selected) && format.format_id == *id { 184 | // Add to the list of available formats the current one formatted in a nice way 185 | ui_format_options.push(format.to_string()); 186 | correct_ids.push(id); 187 | } 188 | } 189 | } 190 | } 191 | 192 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 193 | .with_prompt("Which quality do you want to apply to all videos?") 194 | .default(0) 195 | .items(&ui_format_options) 196 | .interact_on(term)?; 197 | 198 | Ok(VideoQualityAndFormatPreferences::UniqueFormat(correct_ids[user_selection].clone())) 199 | } 200 | 201 | /// All the formats for all the videos in a playlist 202 | #[derive(Serialize, Deserialize, Debug)] 203 | struct Playlist { 204 | #[serde(rename = "entries")] 205 | videos: Vec> 206 | } 207 | 208 | /// Finds the formats available for all videos in the playlist and the list of all the available formats 209 | fn get_common_formats(json_formats: process::Output) -> BlobResult<(Vec, FormatsLibrary)> { 210 | // A list of videos, which are Vec of formats 211 | let mut all_available_formats = FormatsLibrary::new(); 212 | 213 | // Compute which formats are common across the entire playlist 214 | let mut intersections: Vec = vec![]; 215 | let mut all_ids: Vec = vec![]; 216 | 217 | let raw_json = std::str::from_utf8(&json_formats.stdout)?; 218 | 219 | let all_playlist_formats: serde_json::error::Result = serde_json::from_str(raw_json); 220 | 221 | match all_playlist_formats { 222 | Ok(all_playlist_formats) => { 223 | for (i, video) in all_playlist_formats.videos.into_iter().enumerate() { 224 | if let Some(video) = video { 225 | // Save the format 226 | all_available_formats.add_video(video.clone()); 227 | // video is a Vector of formats 228 | if i == 0 { 229 | // In the first iteration every format-id belongs in the intersection 230 | for format in video.formats { 231 | intersections.push(format.format_id.to_string()); 232 | all_ids.push(format.format_id.to_string()); 233 | } 234 | } else { 235 | // For all other videos just add their format to the list of all formats 236 | for format in video.formats { 237 | all_ids.push(format.format_id.to_string()); 238 | } 239 | // Actually compute the intersection 240 | intersections = intersection(&intersections, &all_ids); 241 | } 242 | 243 | } 244 | } 245 | }, 246 | Err(err) => return Err(BlobdlError::SerdeError(err)) 247 | } 248 | 249 | Ok((intersections, all_available_formats)) 250 | } 251 | } 252 | 253 | /// Returns an owned intersection Vec 254 | fn intersection<'a, T: Eq + Clone>(vec1: &'a [T], vec2: &'a [T]) -> Vec { 255 | let mut intersections = vec![]; 256 | 257 | for (element_1, element_2) in vec1.iter().zip(vec2.iter()) { 258 | if element_1 == element_2 { 259 | // Intersection element found! 260 | intersections.push(element_1.clone()); 261 | } 262 | } 263 | 264 | intersections 265 | } 266 | 267 | /// Whether the downloaded files should include their index in the playlist as a part of their name 268 | fn get_index_preference(term: &Term) -> BlobResult { 269 | let download_formats = &[ 270 | "Yes", 271 | "No", 272 | ]; 273 | 274 | // Ask the user which format they want the downloaded files to be in 275 | let index_preference = Select::with_theme(&ColorfulTheme::default()) 276 | .with_prompt("Do you want the files to be numbered as in the playlist?") 277 | .default(0) 278 | .items(download_formats) 279 | .interact_on(term)?; 280 | 281 | match index_preference { 282 | 0 => Ok(true), 283 | _ => Ok(false), 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/assembling/youtube/yt_video.rs: -------------------------------------------------------------------------------- 1 | use dialoguer::console::Term; 2 | use dialoguer::{theme::ColorfulTheme, Select}; 3 | use which::which; 4 | use crate::analyzer; 5 | use crate::assembling::youtube; 6 | use crate::assembling::youtube::*; 7 | use crate::error::BlobResult; 8 | use crate::ui_prompts::*; 9 | 10 | /// Returns a ConfigYtVideo object with all the necessary data 11 | /// to start downloading a youtube video 12 | /// 13 | /// Takes in the command line arguments list 14 | /// 15 | /// User config is the information present in a config file. It has user preferences on things like which file format they prefer 16 | /// knowing this blob-dl can avoid asking redundant questions 17 | /// 18 | pub(crate) fn assemble_data(url: &str, playlist_id: usize, user_config: youtube::config::DownloadConfig) -> BlobResult { 19 | let term = Term::buffered_stderr(); 20 | 21 | let media_selected; 22 | if let Some(media) = user_config.media_selected { 23 | media_selected = media; 24 | } else { 25 | // Whether the user wants to download video files or audio-only 26 | media_selected = get_media_selection(&term)?; 27 | } 28 | 29 | let chosen_format; 30 | if let Some(format) = user_config.chosen_format { 31 | // With config files it is possible to "force" blob-dl to try to use ffmpeg 32 | if let VideoQualityAndFormatPreferences::ConvertTo(_) = format { 33 | // The user wants their files to be converted to another format 34 | if !which("ffmpeg").is_ok() { 35 | // The conversion cannot be performed because ffmpeg is not installed 36 | chosen_format = format::get_format(&term, url, &media_selected, playlist_id)?; 37 | } else { 38 | // ffmpeg is installed so what was specified in the config file can be used 39 | chosen_format = format; 40 | } 41 | } else { 42 | chosen_format = format; 43 | } 44 | } else { 45 | chosen_format = format::get_format(&term, url, &media_selected, playlist_id)?; 46 | } 47 | 48 | let output_path; 49 | if let Some(path) = user_config.output_path { 50 | output_path = path; 51 | } else { 52 | // .trim() trims trailing whitespace at the end of the user-specified path (useful is the user is clumsy) 53 | output_path = get_output_path(&term)?.trim().to_string(); 54 | } 55 | 56 | let playlist_index; 57 | if let Some(analyzer::DownloadOption::YtVideo(index)) = user_config.download_target { 58 | // If the user for some reason specified a playlist index in the config file (they would deserve a confusion award) 59 | playlist_index = index; 60 | } else { 61 | playlist_index = playlist_id; 62 | } 63 | 64 | Ok(config::DownloadConfig::new_video( 65 | url, 66 | chosen_format, 67 | output_path, 68 | media_selected, 69 | playlist_index, 70 | )) 71 | } 72 | 73 | mod format { 74 | use super::*; 75 | 76 | /// Asks the user to choose a download format and quality between the ones 77 | /// available for the current video. 78 | /// 79 | /// The options are filtered between video, audio-only and video-only 80 | pub(super) fn get_format(term: &Term, url: &str, media_selected: &MediaSelection, playlist_id: usize) 81 | -> BlobResult 82 | { 83 | // A list of all the format options that can be picked 84 | let mut format_options: Vec<&str> = vec![]; 85 | 86 | // Default options 87 | format_options.push(BEST_QUALITY_PROMPT_SINGLE_VIDEO); 88 | format_options.push(SMALLEST_QUALITY_PROMPT_SINGLE_VIDEO); 89 | 90 | if which("ffmpeg").is_ok() { 91 | // If ffmpeg is installed in the system 92 | // Some features are only available with ffmpeg 93 | format_options.push(CONVERT_FORMAT_PROMPT_VIDEO_SINGLE_VIDEO); 94 | format_options.push(YT_FORMAT_PROMPT_SINGLE_VIDEO); 95 | 96 | 97 | // Set up a prompt for the user 98 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 99 | .with_prompt("Which quality or format do you want to apply to the video?") 100 | .default(0) 101 | .items(&format_options) 102 | .interact_on(term)?; 103 | match user_selection { 104 | 0 => Ok(VideoQualityAndFormatPreferences::BestQuality), 105 | 1 => Ok(VideoQualityAndFormatPreferences::SmallestSize), 106 | 2 => convert_to_format(term, media_selected), 107 | _ => get_format_from_yt(term, url, media_selected, playlist_id), 108 | } 109 | } else { 110 | println!("{}", FFMPEG_UNAVAILABLE_WARNING); 111 | 112 | format_options.push(YT_FORMAT_PROMPT_SINGLE_VIDEO); 113 | 114 | // Set up a prompt for the user 115 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 116 | .with_prompt("Which quality or format do you want to apply to the video?") 117 | .default(0) 118 | .items(&format_options) 119 | .interact_on(term)?; 120 | 121 | // See individual function documentations for more context 122 | match user_selection { 123 | 0 => Ok(VideoQualityAndFormatPreferences::BestQuality), 124 | 1 => Ok(VideoQualityAndFormatPreferences::SmallestSize), 125 | _ => get_format_from_yt(term, url, media_selected, playlist_id), 126 | } 127 | } 128 | } 129 | 130 | /// Presents the user with the formats youtube provides directly for download, without the need for ffmpeg 131 | fn get_format_from_yt(term: &Term, url: &str, media_selected: &MediaSelection, playlist_id: usize) 132 | -> BlobResult 133 | { 134 | // Serialize all available formats from the youtube API (through yt-dlp -F) 135 | let serialized_formats = { 136 | // Get a JSON dump of all the available formats for the current url 137 | let ytdl_formats = get_ytdlp_formats(url)?; 138 | 139 | // Serialize the JSON which contains the format information for the current video 140 | serialize_formats ( 141 | std::str::from_utf8(&ytdl_formats.stdout[..])? 142 | // If `url` refers to a playlist the JSON has multiple roots, only parse one 143 | .lines() 144 | // If the requested video isn't the first in a playlist, only parse its information 145 | .nth(playlist_id-1) 146 | )? 147 | }; 148 | 149 | // Ids which the user can pick according to the current media selection 150 | let mut correct_ids = vec![]; 151 | // Every format which conforms to media_selected will be pushed here 152 | let mut format_options = vec![]; 153 | 154 | // Choose which formats to show to the user 155 | for format in serialized_formats.formats() { 156 | // If format and media_selected are compatible 157 | if check_format(format, media_selected) { 158 | // Add to the list of available formats the current one formatted in a nice way 159 | format_options.push(format.to_string()); 160 | // Update the list of ids which match what the user wants 161 | correct_ids.push(format.format_id.clone()); 162 | } 163 | } 164 | 165 | // Set up a prompt for the user 166 | let user_selection = Select::with_theme(&ColorfulTheme::default()) 167 | .with_prompt("Which format do you want to apply to the video?") 168 | .default(0) 169 | .items(&format_options) 170 | .interact_on(term)?; 171 | 172 | // Return the format corresponding to what the user selected, the choices are limited so there shouldn't be out-of-bounds problems 173 | Ok(VideoQualityAndFormatPreferences::UniqueFormat(correct_ids[user_selection].clone())) 174 | } 175 | } -------------------------------------------------------------------------------- /src/dispatcher.rs: -------------------------------------------------------------------------------- 1 | use crate::analyzer; 2 | use crate::parser; 3 | use crate::assembling; 4 | use crate::error::{BlobResult, BlobdlError}; 5 | use crate::run; 6 | 7 | use directories::ProjectDirs; 8 | use std::fs::{self}; 9 | use std::path::PathBuf; 10 | use std::io::Write; 11 | use colored::Colorize; 12 | use crate::assembling::youtube; 13 | use crate::parser::ConfigFilePreferences; 14 | 15 | /// Calls the builder function according to what the url refers to (video/playlist), then it runs the ytdl-command and handles errors 16 | pub fn dispatch(cli_config: &parser::CliConfig) -> BlobResult<()> { 17 | // Whether a new config file should be generated 18 | let mut should_generate_config = false; 19 | 20 | // user_config is created with data from the config file. Once execution 21 | // reaches the point where questions need to be asked to the user, data which is already 22 | // present in user_config is used instead of being asked the user directly 23 | let user_config = match cli_config.config_file_preference() { 24 | // There is no config file 25 | ConfigFilePreferences::NoConfig => youtube::config::DownloadConfig::empty(), 26 | // The config file is in blob-dl's default location 27 | ConfigFilePreferences::DefaultConfig => { 28 | let path = get_config_path().ok_or(BlobdlError::ConfigFileNotFound)?; 29 | read_config(&path)? 30 | } 31 | ConfigFilePreferences::CustomConfig(custom_path) => { 32 | read_config(custom_path)? 33 | }, 34 | 35 | ConfigFilePreferences::GenerateConfig => { 36 | println!("{} A config file based on your answers will be generated", "[blob-dl]".purple()); 37 | should_generate_config = true; 38 | youtube::config::DownloadConfig::empty() 39 | } 40 | }; 41 | 42 | // Parse what the url refers to 43 | let download_option = analyzer::analyze_url(cli_config.url())?; 44 | 45 | // Generate a command according to the user's preferences 46 | let mut command_and_download_config = assembling::generate_command(cli_config.url(), &download_option, user_config)? ; 47 | 48 | if cli_config.show_command() { 49 | println!("Command generated by blob-dl: {:?}", command_and_download_config.0); 50 | } 51 | 52 | if should_generate_config { 53 | // Currently config-files cannot be generated in an arbitrary location 54 | if let Some(path) = get_config_path() { 55 | let mut tmp = command_and_download_config.1.clone(); 56 | tmp.url = None; 57 | write_config(path.clone(), &tmp)?; 58 | println!("{} Successfully created a config file in {}", "[blob-dl]".purple(), path.display()); 59 | } else { 60 | eprintln!("blob-dl couldn't create a config file"); 61 | } 62 | } 63 | 64 | // Run the command 65 | run::run_and_observe(&mut command_and_download_config.0, &command_and_download_config.1, cli_config.verbosity())?; 66 | 67 | Ok(()) 68 | } 69 | 70 | // Functions to handle config files 71 | /// Get the (default) location of the config file, it depends on what operating system blob-dl is running on 72 | fn get_config_path() -> Option { 73 | ProjectDirs::from("", "", "blob-dl") 74 | .map(|dirs| dirs.config_dir().join("config.json")) 75 | } 76 | 77 | /// This will create a new config file (or overwrite an old one) with the DownloadConfig that is passed in. 78 | /// 79 | /// The path needs to also be passed in because the user could put their config file in an unexpected place and 80 | /// say where it is via a command line argument 81 | fn write_config(path: PathBuf, download_config: &youtube::config::DownloadConfig) -> BlobResult<()> { 82 | if let Some(parent) = path.parent() { 83 | // If a config file has never been created, this creates all the necessary directories 84 | // such as "~/.config/blob-dl/ ..." 85 | fs::create_dir_all(parent)?; 86 | } 87 | 88 | let parsed_json = serde_json::to_string_pretty(download_config)?; 89 | 90 | // If the file already exists, all its contents are wiped 91 | let mut file = fs::File::create(path)?; 92 | file.write_all(parsed_json.as_bytes())?; 93 | 94 | Ok(()) 95 | } 96 | 97 | /// Create a DownloadConfig object from the contents of the config file 98 | // TODO Tell the user if this returns None (no config file was found, but blob-dl will still work as normal) 99 | fn read_config(config_file_path: &PathBuf) -> BlobResult { 100 | let contents = fs::read_to_string(config_file_path)?; 101 | serde_json::from_str(&contents).map_err(|err| {BlobdlError::SerdeError(err)}) 102 | } -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use crate::blobdl_error_message::*; 3 | use crate::ui_prompts::*; 4 | 5 | use colored::Colorize; 6 | 7 | pub type BlobResult = Result; 8 | 9 | /// ### The all-encompassing error type used in this project 10 | /// ## Implements From 11 | /// For the Errors std::io::Error and ParseIntError 12 | /// ## Contains 13 | /// Errors for everything that can go wrong in the project 14 | /// 15 | /// Useless comments go brr 16 | #[derive(Debug)] 17 | pub enum BlobdlError { 18 | QueryNotFound, 19 | UnknownUrl, 20 | UnsupportedWebsite, 21 | DomainNotFound, 22 | UrlParsingError, 23 | UnknownIssue, 24 | MissingArgument, 25 | JsonSerializationError, 26 | Utf8Error, 27 | UrlIndexParsingError, 28 | SerdeError(serde_json::Error), 29 | IoError(std::io::Error), 30 | QueryCouldNotBeParsed, 31 | PlaylistUrlError, 32 | // This is a 'soft' error: it only means that the ytdlp version could not be checked and should not stop the program. It is handled in main 33 | CommandNotSpawned, 34 | // Somehow when building a Command the url turned up as None, it has to do with config files 35 | UrlNotProvided, 36 | FormatPreferenceNotProvided, 37 | OutputPathNotProvided, 38 | DownloadTargetNotProvided, 39 | IncludeIndexesNotProvided, 40 | MediaSelectedNotProvided, 41 | ChosenFormatNotProvided, 42 | 43 | ConfigFileNotFound, 44 | JsonGenerationError, 45 | } 46 | 47 | impl BlobdlError { 48 | // Output an error message according to the error at hand 49 | pub fn report(&self) { 50 | //eprintln!("\n{}\n", USAGE_MSG); 51 | eprint!("{}: ", "ERROR".red()); 52 | 53 | let _ = std::io::stdout().flush(); 54 | 55 | match self { 56 | BlobdlError::QueryNotFound => eprintln!("{}", BROKEN_URL_ERR), 57 | 58 | BlobdlError::UnknownUrl => eprintln!("{}", BROKEN_URL_ERR), 59 | 60 | BlobdlError::UnsupportedWebsite => eprintln!("{}", UNSUPPORTED_WEBSITE_ERR), 61 | 62 | BlobdlError::DomainNotFound => eprintln!("{}", BROKEN_URL_ERR), 63 | 64 | // The link appears to be completely broken 65 | BlobdlError::UrlParsingError => eprintln!("{}", BROKEN_URL_ERR), 66 | 67 | BlobdlError::UnknownIssue => eprintln!("{}", UNKNOWN_ISSUE_ERR), 68 | 69 | BlobdlError::MissingArgument => eprintln!("{}", MISSING_ARGUMENT_ERR), 70 | 71 | BlobdlError::JsonSerializationError => eprintln!("{}", JSON_SERIALIZATION_ERR), 72 | 73 | BlobdlError::Utf8Error => eprintln!("{}", UTF8_ERR), 74 | 75 | BlobdlError::SerdeError(err) => eprintln!("{} {}", SERDE_ERR, err), 76 | 77 | BlobdlError::IoError(err) => eprintln!("{} {}", IO_ERR, err), 78 | 79 | BlobdlError::QueryCouldNotBeParsed => eprintln!("{}", URL_QUERY_COULD_NOT_BE_PARSED), 80 | 81 | BlobdlError::UrlIndexParsingError => eprintln!("{}", URL_INDEX_PARSING_ERR), 82 | 83 | BlobdlError::PlaylistUrlError => eprintln!("{}", PLAYLIST_URL_ERROR), 84 | 85 | // Early return because this should not be treated as a program-ending error 86 | BlobdlError::CommandNotSpawned => return, 87 | 88 | BlobdlError::UrlNotProvided => eprintln!("{}", URL_NOT_PROVIDED_ERROR), 89 | 90 | BlobdlError::FormatPreferenceNotProvided => eprintln!("{}", FORMAT_PREFERENCE_NOT_PROVIDED_ERROR), 91 | 92 | BlobdlError::OutputPathNotProvided => eprintln!("{}", OUTPUT_PATH_NOT_PROVIDED_ERROR), 93 | 94 | BlobdlError::DownloadTargetNotProvided => eprintln!("{}", DOWNLOAD_TARGET_NOT_PROVIDED_ERROR), 95 | 96 | BlobdlError::IncludeIndexesNotProvided => eprintln!("{}", INCLUDE_INDEXES_NOT_PROVIDED_ERROR), 97 | 98 | BlobdlError::MediaSelectedNotProvided => eprintln!("{}", MEDIA_SELECTION_NOT_PROVIDED_ERROR), 99 | 100 | BlobdlError::ChosenFormatNotProvided => eprintln!("{}", CHOSEN_FORMAT_NOT_PROVIDED_ERROR), 101 | 102 | BlobdlError::ConfigFileNotFound => eprintln!("{}", CONFIG_FILE_NOT_FOUND_ERR), 103 | 104 | BlobdlError::JsonGenerationError => eprintln!("{}", JSON_GENERATION_ERR), 105 | } 106 | eprintln!("{}", SEE_HELP_PAGE); 107 | } 108 | } 109 | 110 | // Implementing conversions and boilerplate 111 | impl std::fmt::Display for BlobdlError { 112 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 113 | write!(f, "Hi :) I am the BlobdlError default message, I shouldn't show up, if you see me please report me to the github page") 114 | } 115 | } 116 | impl std::error::Error for BlobdlError {} 117 | 118 | impl From for BlobdlError { 119 | fn from(err: std::io::Error) -> Self { 120 | BlobdlError::IoError(err) 121 | } 122 | } 123 | 124 | impl From for BlobdlError { 125 | fn from(_: std::str::Utf8Error) -> Self { 126 | BlobdlError::Utf8Error 127 | } 128 | } 129 | 130 | impl From for BlobdlError { 131 | fn from(err: serde_json::Error) -> BlobdlError { 132 | BlobdlError::SerdeError(err) 133 | } 134 | } 135 | 136 | // Used in run.rs 137 | /// Stores the information found in yt-dlp's error-lines output 138 | #[derive(Debug)] 139 | pub(crate) struct YtdlpError { 140 | video_id: String, 141 | error_msg: String, 142 | } 143 | 144 | impl YtdlpError { 145 | pub fn video_id(&self) -> &String { 146 | &self.video_id 147 | } 148 | 149 | pub fn error_msg(&self) -> &String { 150 | &self.error_msg 151 | } 152 | } 153 | 154 | impl std::fmt::Display for YtdlpError { 155 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 156 | let mut result ; 157 | result = format!("{} {}", "yt-video id:", self.video_id); 158 | result = format!("{}\n {} {}\n", result, "Reason:", self.error_msg); 159 | 160 | write!(f, "{}", result) 161 | } 162 | } 163 | 164 | impl YtdlpError { 165 | /// Parses a YtdlpError object from a ytdlp line which contains an error 166 | pub fn from_error_output(error_line: &str) -> YtdlpError { 167 | // yt-dlp error line format: ERROR: [...] video_id: reason 168 | let mut section = error_line.split_whitespace(); 169 | 170 | // Skip ERROR: 171 | section.next().unwrap(); 172 | 173 | let mut video_id; 174 | 175 | // for normal errors this should be [youtube] 176 | let youtube = section.next().unwrap(); 177 | 178 | let is_normal_error = youtube == "[youtube]"; 179 | // todo find a decent way to do this 180 | let mut strange_err_msg_beginning = ""; 181 | 182 | if is_normal_error { 183 | // This is a usual error, so the video is in the next section 184 | video_id = section.next().unwrap(); 185 | // Delete the trailing ':' 186 | video_id = &video_id[..video_id.len() - 1]; 187 | } else { 188 | // The video doesn't exist, this happens in errors such as NONEXISTENT_VIDEO (see lib.rs) 189 | strange_err_msg_beginning = youtube; 190 | video_id = "unavailable"; 191 | } 192 | 193 | // Concatenate together the error message and restore whitespace 194 | let error_msg = { 195 | let mut tmp = String::new(); 196 | // I am ashamed 197 | if !is_normal_error { 198 | tmp += strange_err_msg_beginning; 199 | } 200 | 201 | for word in section { 202 | tmp = tmp + " " + word; 203 | } 204 | tmp 205 | }; 206 | 207 | YtdlpError { video_id: video_id.to_string(), error_msg } 208 | } 209 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod parser; 2 | mod assembling; 3 | mod analyzer; 4 | mod dispatcher; 5 | mod run; 6 | mod error; 7 | 8 | pub mod app; 9 | 10 | // Things blob-dl regularly tells the user 11 | pub mod ui_prompts { 12 | pub const FFMPEG_UNAVAILABLE_WARNING: &str = "It looks like ffmpeg and ffprobe aren't installed, which means that some of blob-dl's features aren't available!\nPlease install them for a fuller experience"; 13 | 14 | pub const LONG_ABOUT: &str = "A command line tool used to make downloading youtube videos in various formats easy\nIf you are having problems passing a URL as an argument, try wrapping it in quotes (\"\")!\n\nFor more details check out the github page https://github.com/MicheleCioccarelli/blob-dl\nRecommended yt-dlp version: 2025.03.31"; 15 | 16 | pub const SHORT_ABOUT: &str = "A command line tool used to make downloading youtube videos in various formats easy\nIf you are having problems passing a URL as an argument, try wrapping it in quotes (\"\")!\n\nFor more details check out the github page https://github.com/MicheleCioccarelli/blob-dl\nRecommended yt-dlp version: 2025.03.31"; 17 | 18 | pub const YTDLP_NOT_INSTALLED: &str = "blob-dl is a wrapper around yt-dlp and cannot function without it.\nPlease install yt-dlp from the official github page: https://github.com/yt-dlp/yt-dlp"; 19 | 20 | pub const BEST_QUALITY_PROMPT_PLAYLIST: &str = "Best possible quality for each video"; 21 | 22 | pub const BEST_QUALITY_PROMPT_SINGLE_VIDEO: &str = "Best possible quality"; 23 | 24 | pub const SMALLEST_QUALITY_PROMPT_PLAYLIST: &str = "Smallest file size for each video"; 25 | 26 | pub const SMALLEST_QUALITY_PROMPT_SINGLE_VIDEO: &str = "Smallest file size"; 27 | 28 | pub const YT_FORMAT_PROMPT_PLAYLIST: &str = "Choose a format to download every video in (only the formats which are available for all videos are shown)"; 29 | 30 | pub const YT_FORMAT_PROMPT_SINGLE_VIDEO: &str = "Choose a format to download the video in"; 31 | 32 | pub const CONVERT_FORMAT_PROMPT_VIDEO_PLAYLIST: &str = "Choose a format to recode all the videos to"; 33 | 34 | pub const CONVERT_FORMAT_PROMPT_VIDEO_SINGLE_VIDEO: &str = "Choose a format to recode the video to"; 35 | 36 | pub const CONVERT_FORMAT_PROMPT_AUDIO: &str = "Choose an audio format to convert the audios to"; 37 | 38 | pub const SEE_HELP_PAGE: &str = "Type blob-dl --help for a list of all the available options"; 39 | 40 | pub const USAGE_MSG: &str = "Usage: blob-dl [OPTIONS] [URL]"; 41 | 42 | pub const ERROR_RETRY_PROMPT: &str = "The following videos weren't downloaded but retrying might help, choose which videos to re-download [space bar to select]"; 43 | 44 | pub const UNRECOVERABLE_ERROR_PROMPT: &str = "The following videos could not be downloaded due to unrecoverable errors"; 45 | 46 | pub const DEBUG_REPORT_PROMPT: &str = "By default new errors are flagged as unrecoverable, if any recoverable errors are flagged incorrectly please report them to the github page"; 47 | 48 | pub const SELECT_ALL: &str = "Select all\n"; 49 | pub const SELECT_NOTHING: &str = "Don't re-download anything\n"; 50 | 51 | pub const WRONG_YTDLP_VERSION: &str = "It looks like you have a yt-dlp version which may not work with blob-dl as expected: you may not be able to fetch formats from youtube.\n\ 52 | To fix this you can update your yt-dlp installation to the correct version with the command: sudo yt-dlp --update-to 2025.03.31"; 53 | 54 | pub const COMMAND_NOT_SPAWNED: &str = "An instance of ytdlp (used to check which version of the program you have installed) could not be spawned"; 55 | } 56 | 57 | // Youtube's error messages 58 | // THESE SHOULD NOT BE MODIFIED, they are supposed to match exactly youtube's error messages 59 | mod youtube_error_message { 60 | pub const PRIVATE_VIDEO: &str = " Private video. Sign in if you've been granted access to this video"; 61 | 62 | pub const NONEXISTENT_PLAYLIST: &str = " YouTube said: The playlist does not exist."; 63 | 64 | pub const HOMEPAGE_REDIRECT: &str = " The channel/playlist does not exist and the URL redirected to youtube.com home page"; 65 | 66 | pub const NETWORK_FAIL: &str = " Unable to download API page: (caused by URLError(gaierror(-3, 'Temporary failure in name resolution')))"; 67 | 68 | pub const VIOLENT_VIDEO: &str = " This video has been removed for violating YouTube's policy on violent or graphic content"; 69 | 70 | pub const REMOVED_VIDEO: &str = " Video unavailable. This video has been removed by the uploader"; 71 | 72 | pub const VIDEO_NOT_FOUND: &str = " not found, unable to continue"; 73 | 74 | pub const YTDLP_GAVE_UP: &str = " error: HTTP Error 403: Forbidden. Giving up after 10 retries"; 75 | 76 | pub const NO_API_PAGE: &str = " Unable to download API page: HTTP Error 404: Not Found (caused by ); please report this issue on https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using yt-dlp -U"; 77 | 78 | pub const ENCODER_STREAM_ERROR: &str = " Postprocessing: Error selecting an encoder for stream 0:1"; 79 | 80 | pub const NONEXISTENT_VIDEO: &str = "Incomplete data received"; 81 | 82 | pub const NONEXISTENT_FORMAT: &str = "Requested format is not available"; 83 | 84 | // All copyright error messages begin with this 85 | pub const VIDEO_UNAVAILABLE: &str = " Video unavailable"; 86 | } 87 | 88 | 89 | // blob-dl custom error messages 90 | pub mod blobdl_error_message { 91 | pub const BROKEN_URL_ERR: &str = "The URL you provided wasn't recognized, try using a regular youtube URL"; 92 | 93 | pub const UNSUPPORTED_WEBSITE_ERR: &str = "Currently blob-dl only supports downloading youtube videos or playlists, not content from other websites"; 94 | 95 | pub const UNKNOWN_ISSUE_ERR: &str = "Congrats! You ran into an unknown issue, please file a report on blob-dl's github page :)"; 96 | 97 | pub const MISSING_ARGUMENT_ERR: &str = "You must provide 1 URL"; 98 | 99 | pub const JSON_SERIALIZATION_ERR: &str = "There was a problem serializing this video's format information"; 100 | 101 | pub const UTF8_ERR: &str = "This video's format information contained non-UTF8 characters and broke the parser, best and smallest quality should still work!"; 102 | 103 | pub const SERDE_ERR: &str = "Serde ran into a problem when serializing this video's format information.\nThis most likely happened because the video you are trying to download is private/copyright claimed\nJson-y reason:"; 104 | 105 | pub const IO_ERR: &str = "There was an IO error: "; 106 | 107 | pub const URL_QUERY_COULD_NOT_BE_PARSED: &str = "This URL's query could not be parsed, try using a regular youtube URL"; 108 | 109 | pub const URL_INDEX_PARSING_ERR: &str = "The video's index in the playlist couldn't be parsed, please report this issue to the github page"; 110 | 111 | pub const PLAYLIST_URL_ERROR: &str = "The index/id for the video that you want to download in the playlist could not be parsed.\nTo download just this video try using a URL which links directly to it instead of going through a playlist"; 112 | 113 | // POST_CONFIG_FILE_ERRORS 114 | pub const URL_NOT_PROVIDED_ERROR: &str = "You didn't provide a URL for the video you want to download. The issue most likely has to do with a configuration file.\nTo report this error or learn more about config files please visit the GitHub page"; 115 | 116 | pub const FORMAT_PREFERENCE_NOT_PROVIDED_ERROR: &str = "You didn't provide a format preference for the video you want to download. The issue most likely has to do with a configuration file.\nTo report this error or learn more about config files please visit the GitHub page"; 117 | 118 | pub const OUTPUT_PATH_NOT_PROVIDED_ERROR: &str = "You didn't provide an output path for the video you want to download. The issue most likely has to do with a configuration file.\nTo report this error or learn more about config files please visit the GitHub page"; 119 | 120 | pub const INCLUDE_INDEXES_NOT_PROVIDED_ERROR: &str = "You didn't specify whether indexes should be included in the title of the videos you want to download. The issue most likely has to do with a configuration file.\nTo report this error or learn more about config files please visit the GitHub page"; 121 | 122 | pub const DOWNLOAD_TARGET_NOT_PROVIDED_ERROR: &str = "There was an issue figuring out the download target (whether your link refers to a single video or playlist).\nTo report this error please visit the GitHub page"; 123 | 124 | pub const MEDIA_SELECTION_NOT_PROVIDED_ERROR: &str = "You didn't provide a media selection for the video you want to download (media selection means whether you want to download audio only/video only/full video). The issue most likely has to do with a configuration file.\nTo report this error or learn more about config files please visit the GitHub page"; 125 | 126 | pub const CHOSEN_FORMAT_NOT_PROVIDED_ERROR: &str = "You didn't provide a download format for the video you want to download. The issue most likely has to do with a configuration file.\nTo report this error or learn more about config files please visit the GitHub page"; 127 | 128 | pub const CONFIG_FILE_NOT_FOUND_ERR: &str = "No valid home directory path could be retrieved from the operating system. (this problem has to do with the default location of your config file)"; 129 | 130 | pub const FFMPEG_NOT_AVAILABLE_CONFIG_WARNING: &str = "You are using a config file which tells blob-dl to convert the files you download to a specific format. Doing this requires ffmpeg, which is not installed on your system"; 131 | 132 | pub const JSON_GENERATION_ERR: &str = "yt-dlp didn't generate the json needed to parse formats for your video.\nThis most likely happened because the video you are trying to download is private/copyright claimed\nIf you are running the recommended version of yt-dlp please report this to the GitHub page."; 133 | } 134 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use blob_dl::app; 2 | 3 | fn main() { 4 | app::run(); 5 | } -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | 2 | use clap::{Arg, ArgMatches}; 3 | 4 | use std::path::PathBuf; 5 | 6 | use clap::{arg, value_parser, ArgAction, Command}; 7 | 8 | use crate::ui_prompts::*; 9 | use crate::error::{BlobdlError, BlobResult}; 10 | 11 | pub fn parse_config() -> BlobResult { 12 | let matches = Command::new("blob-dl") 13 | .version("1.1.6") 14 | .author("cioccarellimi@gmail.com") 15 | .about(SHORT_ABOUT) 16 | .long_about(LONG_ABOUT) 17 | .arg( 18 | Arg::new("verbose") 19 | .short('v') 20 | .long("verbose") 21 | .help("Show all the output produced by yt-dlp") 22 | .action(ArgAction::SetTrue), 23 | ) 24 | .arg( 25 | Arg::new("quiet") 26 | .short('q') 27 | .long("quiet") 28 | .help("Silence all output except for the final error summary") 29 | .action(ArgAction::SetTrue), 30 | ) 31 | .arg( 32 | Arg::new("show-command") 33 | .help("Print to the console the command generated by blob-dl") 34 | .long("show-command") 35 | .short('s') 36 | .action(ArgAction::SetTrue), 37 | ) 38 | .arg( 39 | Arg::new("generate-config") 40 | .help("Saves your downloading preferences in a config file. It will be located in blob-dl's default config file location (see the github page for more info)") 41 | .long("generate-config") 42 | .short('g') 43 | .action(ArgAction::SetTrue), 44 | ) 45 | .arg( 46 | Arg::new("use-config-file") 47 | .help("Use the preferences from a config file instead of asking questions. This command will look for a config file in blob-dl's default location") 48 | .long("use-config") 49 | .short('c') 50 | .action(ArgAction::SetTrue), 51 | ) 52 | .arg( 53 | arg!( 54 | -l --"locate-config-file" "Use the preferences from a config file. This command will look for a config file in the path you provide" 55 | ) 56 | .required(false) 57 | .value_parser(value_parser!(PathBuf)), 58 | ) 59 | .arg(Arg::new("URL") 60 | .help("Link to the youtube video/playlist that you want to download") 61 | ) 62 | .get_matches(); 63 | 64 | CliConfig::from(matches) 65 | } 66 | 67 | /// The 3 possible verbosity options for this program 68 | #[derive(Debug)] 69 | pub enum Verbosity { 70 | Verbose, 71 | Default, 72 | Quiet, 73 | } 74 | 75 | #[derive(Debug)] 76 | pub enum ConfigFilePreferences { 77 | /// Don't do anything related to config files 78 | NoConfig, 79 | /// Search for the config file in blob-dl's default location 80 | DefaultConfig, 81 | /// Search for the config file in a user-defined directory 82 | CustomConfig(PathBuf), 83 | /// Create a config file based on the user's answers and place it in blob-dl's default location 84 | GenerateConfig, 85 | } 86 | 87 | /// Holds all the information that can be fetched as a command line argument 88 | #[derive(Debug)] 89 | pub struct CliConfig { 90 | // Refs to this String are stored in other Config objects 91 | url: String, 92 | verbosity: Verbosity, 93 | // Whether to print to the console the final command which is the run by yt-dlp 94 | show_command: bool, 95 | 96 | pub config_file_preference: ConfigFilePreferences, 97 | } 98 | 99 | impl CliConfig { 100 | /// Constructs a CliConfig object based on Clap's output 101 | pub fn from(matches: ArgMatches) -> BlobResult { 102 | 103 | let url = match matches.get_one::("URL") { 104 | Some(url) => url.clone(), 105 | None => return Err(BlobdlError::MissingArgument), 106 | }; 107 | 108 | let verbosity = { 109 | if matches.get_flag("quiet") { 110 | Verbosity::Quiet 111 | } 112 | else if matches.get_flag("verbose") { 113 | Verbosity::Verbose 114 | } 115 | else { 116 | Verbosity::Default 117 | } 118 | }; 119 | let show_command = matches.get_flag("show-command"); 120 | 121 | // The user is supposed to only use one of these at a time 122 | let mut config_file_preference = ConfigFilePreferences::NoConfig; 123 | if let Some(path) = matches.get_one::("locate-config-file") { 124 | config_file_preference = ConfigFilePreferences::CustomConfig(path.clone()); 125 | }; 126 | if matches.get_flag("generate-config") { 127 | config_file_preference = ConfigFilePreferences::GenerateConfig; 128 | } else if matches.get_flag("use-config-file") { 129 | config_file_preference = ConfigFilePreferences::DefaultConfig; 130 | } 131 | 132 | Ok(CliConfig { 133 | url, 134 | verbosity, 135 | show_command, 136 | config_file_preference 137 | }) 138 | } 139 | 140 | pub fn url(&self) -> &String { 141 | &self.url 142 | } 143 | pub fn verbosity(&self) -> &Verbosity { 144 | &self.verbosity 145 | } 146 | pub fn show_command(&self) -> bool { 147 | self.show_command 148 | } 149 | pub fn config_file_preference(&self) -> &ConfigFilePreferences { 150 | &self.config_file_preference 151 | } 152 | } 153 | 154 | /// Check if the user has a version of ytdlp compatible with blob-dl (now it is 22025.03.31) 155 | pub fn is_ytdlp_compatible() ->Result { 156 | let version = std::process::Command::new("yt-dlp") 157 | .arg("--version") 158 | .output(); 159 | 160 | if let Ok(version) = version { 161 | let str = std::str::from_utf8(&version.stdout)?; 162 | 163 | if str == "2025.03.31\n" { 164 | Ok(true) 165 | } else { 166 | Ok(false) 167 | } 168 | 169 | } else { 170 | Err(BlobdlError::CommandNotSpawned) 171 | } 172 | } -------------------------------------------------------------------------------- /src/run.rs: -------------------------------------------------------------------------------- 1 | use std::process::{Command, Stdio}; 2 | use std::io::{BufRead, BufReader}; 3 | use dialoguer::{theme::ColorfulTheme, MultiSelect}; 4 | use dialoguer::console::Term; 5 | use std::collections::HashMap; 6 | use colored::Colorize; 7 | 8 | use crate::youtube_error_message::*; 9 | use crate::ui_prompts::*; 10 | use crate::parser; 11 | use crate::error::{BlobResult, YtdlpError}; 12 | use crate::assembling::youtube::config; 13 | 14 | /// Executes the yt-dlp command and analyzes its output. 15 | /// 16 | /// It filters what to show to the user according to verbosity options 17 | /// 18 | /// It records which videos fail to download and the reason: if trying again can fix the issue the user can choose to retry 19 | pub fn run_and_observe(command: &mut Command, download_config: &config::DownloadConfig, verbosity: &parser::Verbosity) -> BlobResult<()> { 20 | // Run the command and record any errors 21 | if let Some(errors) = run_command(command, verbosity) { 22 | // Some videos could not be downloaded, ask the user which ones they want to try to re-download 23 | let user_selection = ask_for_redownload(&errors); 24 | 25 | // The list of commands that have to be re-run in case of errors 26 | let mut to_be_downloaded = Vec::new(); 27 | 28 | // Selection 0 and 1 are hard-coded (select all | select nothing) 29 | if !user_selection.is_empty() { 30 | if user_selection[0] == 0 { 31 | // The user wants to re-download all the videos 32 | for video_to_re_download in &errors { 33 | // Re-download every video while keeping the current command configuration (quality, naming preference, ...) 34 | to_be_downloaded.push(download_config.build_command_for_video(video_to_re_download.video_id())?); 35 | } 36 | } else if user_selection[0] == 1 { 37 | // The user doesn't want to re-download anything 38 | } else { 39 | // Only re-download the selected videos 40 | for i in user_selection { 41 | // Skip 0 and 1 because they are hard-coded options (select all or nothing) 42 | if i == 0 || i == 1 { 43 | continue; 44 | } 45 | // There is a 1:1 correspondence between the number in user_selection and 46 | // the index of the video it refers to in errors 47 | to_be_downloaded.push(download_config.build_command_for_video(errors[i - 2].video_id().as_str())?); 48 | } 49 | } 50 | } 51 | for mut com in to_be_downloaded { 52 | run_command(&mut com, verbosity); 53 | } 54 | // If no errors occurred, there is nothing to return 55 | Ok(()) 56 | } else { 57 | #[cfg(debug_assertions)] 58 | println!("The command ran without any errors!! :)"); 59 | Ok(()) 60 | } 61 | } 62 | 63 | /// Returns whether it makes sense to try downloading the video again 64 | fn is_recoverable(error: &YtdlpError, table: &HashMap<&'static str, bool>) -> bool { 65 | if error.error_msg().contains(VIDEO_UNAVAILABLE) { 66 | return false; 67 | } 68 | if let Some(result) = table.get(error.error_msg().as_str()) { 69 | if !(*result) { 70 | // The error is documented and unrecoverable 71 | false 72 | } else { 73 | // The error is documented and recoverable 74 | true 75 | } 76 | } else { 77 | // By default undocumented errors are flagged as unrecoverable 78 | false 79 | } 80 | } 81 | 82 | /// A list of all the documented youtube error messages and whether they are recoverable. 83 | fn init_error_msg_lut() -> HashMap<&'static str, bool> { 84 | HashMap::from([ 85 | (PRIVATE_VIDEO, false), 86 | (NONEXISTENT_PLAYLIST, false), 87 | (HOMEPAGE_REDIRECT, false), 88 | (VIOLENT_VIDEO, false), 89 | (REMOVED_VIDEO, false), 90 | (YTDLP_GAVE_UP, false), 91 | (VIDEO_NOT_FOUND, false), 92 | (NETWORK_FAIL, true), 93 | (NO_API_PAGE, false), 94 | (ENCODER_STREAM_ERROR, false), 95 | (NONEXISTENT_VIDEO, false), 96 | (NONEXISTENT_FORMAT, false), 97 | ]) 98 | } 99 | 100 | /// Runs the command and displays the output to the console. 101 | /// 102 | /// If yt-dlp runs into any errors, they are returned in a vector of Ytdlp errors (parsed Strings) 103 | fn run_command(command: &mut Command, verbosity: &parser::Verbosity) -> Option> { 104 | // Run the command and capture its output 105 | let mut youtube_dl = command.stdout(Stdio::piped()) 106 | .stderr(Stdio::piped()) 107 | .spawn() 108 | .expect("Failed to start yt-dlp process"); // TODO Should take away this expect in a future release 109 | 110 | let stdout = BufReader::new(youtube_dl.stdout.take().unwrap()); 111 | let stderr = BufReader::new(youtube_dl.stderr.take().unwrap()); 112 | 113 | // All the errors produced by yt-dlp 114 | let mut errors: Vec = vec![]; 115 | 116 | match verbosity { 117 | parser::Verbosity::Quiet => { 118 | // This has to be run or the command does nothing 119 | for line in stdout.lines().chain(stderr.lines()) { 120 | if let Ok(line) = line { 121 | // Keep track of errors without displaying anything 122 | if line.contains("ERROR:") { 123 | errors.push(YtdlpError::from_error_output(&line)); 124 | } 125 | } else { 126 | // The current line had some problems (non UTF-8 characters, ...) 127 | // Since this is the quiet mode, don't do anything 128 | } 129 | 130 | } 131 | }, 132 | 133 | parser::Verbosity::Default => { 134 | for line in stdout.lines().chain(stderr.lines()) { 135 | if let Ok(line) = line { 136 | // Filter what should be shown to the user 137 | if line.contains("[download]") { 138 | println!("{}{}", "[download]".green(), &line[10..]); 139 | } else if line.contains("Deleting existing file ") { 140 | println!("{}", line); 141 | } else if line.contains("[info]") { 142 | println!("{}{}", "[info]".cyan(), &line[6..]); 143 | } else if line.contains("[VideoConvertor]") { 144 | println!("{}{}", "[VideoConvertor]".purple(), &line[16..]); 145 | } 146 | else if line.contains("ERROR:") { 147 | errors.push(YtdlpError::from_error_output(&line)); 148 | // Color error messages red 149 | println!("{}", line.red()); 150 | } else if line.contains("Usage: yt-dlp [OPTIONS] URL [URL...]") { 151 | // The user messed with config files and blob-dl generated an invalid command for yt-dlp 152 | println!("{}", "blob-dl generated a yt-dlp command that wasn't valid, This was probably a result of errors present in a config file".red()); 153 | } 154 | } else { 155 | eprintln!("{}", "This line couldn't be rendered as UTF-8".yellow()); 156 | } 157 | } 158 | }, 159 | 160 | parser::Verbosity::Verbose => { 161 | // Print to the console everything that yt-dlp is doing 162 | for line in stdout.lines().chain(stderr.lines()) { 163 | if let Ok(line) = line { 164 | if line.contains("ERROR:") { 165 | errors.push(YtdlpError::from_error_output(&line)); 166 | // Color error messages red 167 | println!("{}", line.red()); 168 | } else { 169 | println!("{}", line); 170 | } 171 | } else { 172 | eprintln!("{}", "This line couldn't be rendered as UTF-8".yellow()); 173 | } 174 | } 175 | } 176 | } 177 | 178 | if errors.is_empty() { 179 | None 180 | } else { 181 | Some(errors) 182 | } 183 | } 184 | 185 | /// Shows the user which videos could not be downloaded and returns which have to be re-downloaded based on what the user wants 186 | /// 187 | /// Returns a Vec containing which errors the user wants to re-download 188 | fn ask_for_redownload(errors: &Vec) -> Vec { 189 | let term = Term::buffered_stderr(); 190 | 191 | // Initialize a lut, which contains all documented errors and whether they can be recovered from 192 | let lut = init_error_msg_lut(); 193 | 194 | // The possible choices which will be presented to the user (all recoverable errors) 195 | let mut user_options = Vec::new(); 196 | 197 | let mut unrecoverable_errors = Vec::new(); 198 | 199 | // Default options 200 | user_options.push(String::from(SELECT_ALL)); 201 | user_options.push(String::from(SELECT_NOTHING)); 202 | 203 | for error in errors { 204 | if is_recoverable(error, &lut) { 205 | // It makes sense to try a re-download 206 | user_options.push(error.to_string()) 207 | } else { 208 | // Don't bother asking to re-download the error 209 | unrecoverable_errors.push(error); 210 | } 211 | } 212 | 213 | if !unrecoverable_errors.is_empty() { 214 | println!("{}", UNRECOVERABLE_ERROR_PROMPT.bold().cyan()); 215 | for error in unrecoverable_errors { 216 | println!(" {}", error); 217 | } 218 | } 219 | 220 | if user_options.len() > 2 { 221 | // If user_options has only 2 elements there aren't any videos to re-download 222 | let user_selection = MultiSelect::with_theme(&ColorfulTheme::default()) 223 | .with_prompt(ERROR_RETRY_PROMPT) 224 | .items(&user_options[..]) 225 | .interact_on(&term).unwrap(); 226 | 227 | println!("{}", DEBUG_REPORT_PROMPT); 228 | return user_selection 229 | } 230 | 231 | // The user didn't choose any options so an empty Vec is returned 232 | Vec::new() 233 | } 234 | --------------------------------------------------------------------------------