Compare commits

...

92 commits

Author SHA1 Message Date
Nojyto
658f5ef3e1
[BUGFIX] Fix "File name too long" error when a title is used as a folder name (#1479)
Some checks failed
ytld-sub CI (Linux) / test-lint (push) Has been cancelled
ytld-sub CI (Linux) / test-unit (push) Has been cancelled
ytld-sub CI (Windows) / test-unit (push) Has been cancelled
ytld-sub CI (Windows) / test-integration (push) Has been cancelled
ytld-sub CI (Windows) / test-integration-prebuilt-presets (push) Has been cancelled
ytld-sub CI (Windows) / test-e2e (push) Has been cancelled
ytld-sub CI (Linux) / test-integration (push) Has been cancelled
ytld-sub CI (Linux) / test-integration-prebuilt-presets (push) Has been cancelled
ytld-sub CI (Linux) / test-e2e (push) Has been cancelled
ytld-sub Docker GUI Build / version (push) Has been cancelled
ytld-sub Docker GUI Build / build (3.12) (push) Has been cancelled
ytld-sub Docker Ubuntu Build / version (push) Has been cancelled
ytld-sub Docker Ubuntu Build / build (3.12) (push) Has been cancelled
ytld-sub Docker Build / version (push) Has been cancelled
ytld-sub Docker Build / build (3.12) (push) Has been cancelled
ytld-sub Release / version (push) Has been cancelled
ytld-sub CI (Linux) / codecov-upload (push) Has been cancelled
ytld-sub Docker GUI Build / package-arm64 (push) Has been cancelled
ytld-sub Docker GUI Build / package-amd64 (push) Has been cancelled
ytld-sub Docker GUI Build / deploy (push) Has been cancelled
ytld-sub Docker Ubuntu Build / package-arm64 (push) Has been cancelled
ytld-sub Docker Ubuntu Build / package-amd64 (push) Has been cancelled
ytld-sub Docker Ubuntu Build / deploy (push) Has been cancelled
ytld-sub Docker Build / package-arm64 (push) Has been cancelled
ytld-sub Docker Build / package-amd64 (push) Has been cancelled
ytld-sub Docker Build / deploy (push) Has been cancelled
ytld-sub Release / build-linux (push) Has been cancelled
ytld-sub Release / build-windows (push) Has been cancelled
ytld-sub Release / github-release (push) Has been cancelled
ytld-sub Release / pypi-publish (push) Has been cancelled
Some presets put the video title into a folder name (not just the file name). The code only shortened the file name, never the folder name. So a title with multi-byte characters (like bold/fancy Unicode, which take 4 bytes each) could make the folder name go over the OS 255-byte limit and crash with OSError: [Errno 36] File name too long.

This fix shortens every folder in the path too, not just the file name, while keeping the file extension. output_directory is left alone so real folders aren't touched.

Added a test with a title made of 4-byte bold Unicode characters.

Relates to #1092, #1189

-- Thanks @Nojyto for the contribution!
2026-06-23 09:05:10 -07:00
dependabot[bot]
1c4633b815
Bump yt-dlp from 2026.3.17 to 2026.6.9 (#1475)
Bumps [yt-dlp](https://github.com/yt-dlp/yt-dlp) from 2026.3.17 to 2026.6.9.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.03.17...2026.06.09)

---
updated-dependencies:
- dependency-name: yt-dlp
  dependency-version: 2026.6.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 04:17:20 -04:00
dependabot[bot]
19b0e621ca
[DEV] Bump ruff from 0.15.12 to 0.15.16 (#1474)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.12 to 0.15.16.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.12...0.15.16)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 13:59:07 -07:00
Jesse Bannon
4421c4eede
[BUGFIX] Fix output sanitization type checking (#1467)
In the inspection output, we check function's return types via issubclass to see if an optimization can be made. Guard against this check in case the return type isn't a class -- it could be a Union[..., ...] from a conditional.
2026-05-10 09:19:27 -07:00
Jesse Bannon
b195f37412
[DEV] Disable soundcloud e2e test in CI (#1448) 2026-05-10 08:59:32 -07:00
dependabot[bot]
108c7cfa14
[DEV] Bump ruff from 0.15.11 to 0.15.12 (#1465)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.11 to 0.15.12.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.11...0.15.12)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 15:50:05 -07:00
dependabot[bot]
772f01a734
[DEV] Bump ruff from 0.15.10 to 0.15.11 (#1462)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.10 to 0.15.11.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.10...0.15.11)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 08:21:50 -07:00
Xavier Salazar
bb96f00ca5
[DOCS] Update inspect usage in first config docs (#1460)
Remove invalid CLI args in example
2026-04-14 22:35:44 -07:00
Jesse Bannon
915d445e21 [DOCS] Fix command arg type 2026-04-13 11:00:29 -07:00
Jesse Bannon
970c74ba45
[FEATURE] inspect subcommand (#1407)
Subscription file syntax is designed to minimize boiler-plate when authoring new subscriptions.
You can now unpack any subscription using the ``inspect`` sub-command to see its boiler-plate *preset format*.

```
ytdl-sub inspect --config /path/to/config.yaml --match "BBC News" /path/to/subscriptions.yaml
```

This can be utilized for numerous purposes including:

* Ensuring your custom preset is getting applied correctly.
* Figuring out which variables set things like file names, metadata, etc.
* Understanding how subscription syntax translates to preset representation.

The default ``--level`` of inspect will fill in defined variables. Using ``--level original`` will present the subscription's raw layout with no fill.
2026-04-13 10:10:58 -07:00
dependabot[bot]
e34b9b6295
[DEV] Bump ruff from 0.15.8 to 0.15.10 (#1458)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.8 to 0.15.10.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.8...0.15.10)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:31:50 -07:00
dependabot[bot]
49e3b5f887
[DEV] Bump ruff from 0.15.7 to 0.15.8 (#1456)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.7 to 0.15.8.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.7...0.15.8)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 13:50:14 -07:00
dependabot[bot]
5cb9bbea9c
[DEV] Bump ruff from 0.15.6 to 0.15.7 (#1455)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.6 to 0.15.7.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.6...0.15.7)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 16:53:47 -07:00
dependabot[bot]
52a1ceaeea
Bump yt-dlp[default] from 2026.3.13 to 2026.3.17 (#1454)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2026.3.13 to 2026.3.17.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.03.13...2026.03.17)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.3.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 21:16:50 -07:00
Jesse Bannon
1156e0070a
[DEV] Make inspect resolution more human readable (#1451)
Removes a lot of the boilerplate output around `concat` and `sanitize`.
2026-03-13 16:27:56 -07:00
dependabot[bot]
efbac43b9f
[DEV] Bump ruff from 0.15.5 to 0.15.6 (#1449)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.5 to 0.15.6.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.5...0.15.6)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 13:53:38 -07:00
dependabot[bot]
a6aea037c3
Bump yt-dlp[default] from 2026.3.3 to 2026.3.13 (#1450)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2026.3.3 to 2026.3.13.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.03.03...2026.03.13)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.3.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 13:34:35 -07:00
dependabot[bot]
cb3952de5c
[DEV] Bump pylint from 4.0.1 to 4.0.5 (#1437)
Bumps [pylint](https://github.com/pylint-dev/pylint) from 4.0.1 to 4.0.5.
- [Release notes](https://github.com/pylint-dev/pylint/releases)
- [Commits](https://github.com/pylint-dev/pylint/compare/v4.0.1...v4.0.5)

---
updated-dependencies:
- dependency-name: pylint
  dependency-version: 4.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 12:36:32 -07:00
dependabot[bot]
0983a594fd
Update pytest requirement from <9.0,>=7.2 to >=7.2,<10.0 (#1380)
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/7.2.0...9.0.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 11:39:56 -07:00
Jesse Bannon
57fd6901e4
[DEV] Linter: Use ruff over black and isort (#1445)
Will eventually replace pylint with ruff
2026-03-09 11:39:01 -07:00
Jesse Bannon
a758a8870d
[DOCS] Overhaul configuration file getting started guide (#1172)
Updates the configuration file's docs to be cleaner. Removes sphinx autodocs entirely in favor of our custom-built one.
2026-03-09 10:18:50 -07:00
Ross Patterson
ff66ff5be0
fix(docker): Add default crontab download command (#1321)
* docs(docker): Recommended image DEFAULT_WORKSPACE

* fix(docker): Add default crontab download command

Clarify the `/config/ytdl-sub-configs/cron` script with explanatory comments and a
default `--dry-run` command. Also change the wrapper script to echo commands for easier
debugging. To update an existing script, move the old script aside, restart the
container to regenerate it, and edit the new script.

Also clarify the Automating page in the Getting Started guide docs.

* fix(docker): Unintentional unattended dry runs

[PR feedback](https://github.com/jmbannon/ytdl-sub/pull/1321#discussion_r2315215600)
prompted me to reconsider having a default command at all. We should assume,
unfortunately, that many new users will just skim the docs enough to enable the image's
cron integration but not actually incrementally test their configuration. In those
cases, they'd end up sending dry-run non-download requests for all their subscriptions
every 6 hours for no good reason. There's just no way to provide a default command that
isn't also providing a footgun.

* docs(docker): More open cron schedule generator

From [PR
feedback](https://github.com/jmbannon/ytdl-sub/pull/1321#discussion_r2320521024), this
seems like less of an ad than the previous and the source for the page is itself open source.

* docs(docker): Document image environment footgun

From [PR
feedback](https://github.com/jmbannon/ytdl-sub/pull/1321#discussion_r2320515419).

* docs(automate): Avoid external link 404 responses

* docs(automate): False simultaneous run warning

* docs(automate): Clarify Docker daemon restarts

* docs(automate): Revert run start non-recommended

* docs(automate): Remove footgun manual run command

Addressing this underlying issue requires more thought and should be a separate PR.

* docs(automate): Restore env var footgun in example
2026-03-09 09:27:27 -07:00
dependabot[bot]
64820daf6c
Bump yt-dlp[default] from 2026.2.21 to 2026.3.3 (#1440)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2026.2.21 to 2026.3.3.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.02.21...2026.03.03)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 19:21:53 -08:00
dependabot[bot]
3b5f9cba58
Bump yt-dlp[default] from 2026.2.4 to 2026.2.21 (#1438)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2026.2.4 to 2026.2.21.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.02.04...2026.02.21)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.2.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 15:10:39 -08:00
dependabot[bot]
e300c3d23a
Bump yt-dlp[default] from 2026.1.31 to 2026.2.4 (#1432)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2026.1.31 to 2026.2.4.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.01.31...2026.02.04)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.2.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 14:32:42 -08:00
dependabot[bot]
d526545abb
Bump yt-dlp[default] from 2026.1.29 to 2026.1.31 (#1430)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2026.1.29 to 2026.1.31.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.01.29...2026.01.31)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.1.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-02 20:27:33 -08:00
Jesse Bannon
ced547b14a
[FEATURE] Arg to shuffle subscriptions (#1263)
Closes https://github.com/jmbannon/ytdl-sub/issues/1234

Adds `--shuffle` support which shuffles the order of subscriptions when downloading.
2026-02-02 10:23:08 -08:00
Jesse Bannon
c163f9766a
[BACKEND] Configurable resolution level when validating variables (#1415)
Expands variable validation to also include support for partial resolution.

In short, this will partially execute script code until it hits unresolved runtime variables, and store that as the new script representation. This functionality will allow for the upcoming `inspect` command, which will show users their script code in action without having to dry-run.
2026-02-02 09:50:52 -08:00
dependabot[bot]
6b7805c6e1
Bump yt-dlp[default] from 2025.12.8 to 2026.1.29 (#1428)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.12.8 to 2026.1.29.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.12.08...2026.01.29)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2026.1.29
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 21:24:07 -08:00
Jesse Bannon
d6eda27371
[BACKEND] Less string casting behind the scenes (#1425)
Simplifies internal scripts by not casting things to String when unnecessary.
2026-01-27 10:15:32 -08:00
Jesse Bannon
d37945db7e
[HOTFIX] Fix StringFormattingException regarding keep_max_files (#1427)
Closes this issue from last release: https://github.com/jmbannon/ytdl-sub/issues/1426
2026-01-26 17:30:44 -08:00
Jesse Bannon
a097c6a476
[BACKEND][v2] More flexible formatter post_process (#1424)
Makes the underlying formatting process better handle native (non-string) types in a more flexible way. 2nd attempt since the last one got reverted due to breaking changes.
2026-01-25 22:35:51 -08:00
Jesse Bannon
97ecae79db
[DEV] More tests around passed-in ytdl-options (#1423) 2026-01-25 09:55:26 -08:00
Jesse Bannon
c1431c8d55
Revert "[BACKEND] More flexible formatter post_process (#1421)" (#1422)
This reverts commit 60cdbad8c7.

Caused a breaking change to ytdl_options
2026-01-24 00:22:18 -08:00
Jesse Bannon
60cdbad8c7
[BACKEND] More flexible formatter post_process (#1421)
Makes the underlying formatting process better handle native (non-string) types in a more flexible way.
2026-01-23 16:36:17 -08:00
Jesse Bannon
264e458c1c
[BACKEND] Cache cycle checks (#1420)
Small speedup to make cycle validation faster.
2026-01-23 10:58:14 -08:00
Jesse Bannon
c4e112e8d5
[BUGFIX] Fix soundcloud not downloading tracks (#1419)
The /tracks URL is currently broken in yt-dlp. Now uses the input URL which will download the entire artists' page instead to get non-album tracks.
2026-01-23 09:23:34 -08:00
Jesse Bannon
97df4dac1d
[BACKEND] Preserve dict formatter value (#1416)
Do not modify the original definition of dict validators.
2026-01-16 13:51:33 -08:00
Jesse Bannon
942c983f60
[BACKEND] Sorted video tags (#1417)
For reproducibility in tests.
2026-01-16 13:42:22 -08:00
Jesse Bannon
530d2f7666
[BACKEND] Partial resolve support for Scripts (#1410)
Adds the mechanism to partially resolve an entire script (aka every underlying variable in ytdl-sub). This will be used in the upcoming `inspect` sub-command where it will dump a subscription's raw contents at multiple levels of resolution.

Potentially useful to optimize script execution as well.
2026-01-12 17:34:34 -08:00
Jesse Bannon
41a5bf60aa
[BACKEND] Remove cast and legacy_bracket_safety usage (#1414)
Removes unused casts and calls to the legacy_bracket_safety function.
2026-01-11 15:47:50 -08:00
dependabot[bot]
3a07d058aa
[DOCS] Update sphinx requirement from <9,>=7 to >=7,<10 (#1411)
Updates the requirements on [sphinx](https://github.com/sphinx-doc/sphinx) to permit the latest version.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: sphinx
  dependency-version: 9.1.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-06 12:59:22 -08:00
Jesse Bannon
6b99c31e45
[BACKEND] Simplify script quote generation (#1409)
Internal script -> string generation used triple-quotes redundantly. Try to use them less to make it more human-readable.
2026-01-06 12:58:37 -08:00
Jesse Bannon
b2056bec5d
[BACKEND] Array support for URLs (#1406)
Allows for the `download` plugin to take in a script-based array of URLs. Prebuilt presets are now greatly reduced in size by constructing urls as an array versus 100+ separate URL variables.

Eventually, we can completely remove the limit of the number of URLs in a subscription.
2025-12-31 15:24:44 -08:00
Jesse Bannon
c46de048ca
[FEATURE] %range function (#1405)
Add `%range` function, equivalent to Python's `range`.
2025-12-31 09:06:08 -08:00
Jesse Bannon
869fc0b836
[BUGFIX] Fix custom function lambda cycle detection (#1404)
Cycles detection did not take into account custom functions' variable usage. This is now fixed.
2025-12-31 08:22:26 -08:00
Jesse Bannon
7ac525f875
[BUGFIX] Fix custom function lambda variable dependency issue, part 2 (#1403)
Part two of https://github.com/jmbannon/ytdl-sub/pull/1402 which actually completes the fix.
2025-12-30 23:46:07 -08:00
Jesse Bannon
d373935fff
[BUGFIX] Fix custom function lambda variable dependency issue (#1402)
There was a bug that did not properly check variable dependency when using lambda functions. It could still work depending on order of definitions. This fix should make it work no matter the order.
2025-12-30 19:53:15 -08:00
Jesse Bannon
1abe2a44f5
[BACKEND] Refactor validation, preparation for subscription dissect (#1398)
Completely rewrite how subscription validation is performed. Optimizes it quite a bit while also adding backend support for the upcoming `dissect` sub-command, where you can resolve any subscription into its 'raw' form for easier debugging when making scripting changes.
2025-12-30 17:03:59 -08:00
Jesse Bannon
344753cc63
[BACKEND] Optimize script resolution (#1400)
Speeds up scripting in the backend.
2025-12-29 17:38:34 -08:00
Jesse Bannon
0f96d8e24e
[BACKEND] Disallow override names conflicting with plugin names (#1399)
Disallow override variable names conflicting with plugin names.

May cause backward incompatible issues from `date_range` being an old override variable name for the `Only Recent` preset. Update this variable name to `only_recent_date_range` to resolve. More info in https://ytdl-sub.readthedocs.io/en/latest/deprecation_notices.html
2025-12-26 15:30:37 -08:00
dependabot[bot]
157a0b59c4
Bump yt-dlp[default] from 2025.11.12 to 2025.12.8 (#1396)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.11.12 to 2025.12.8.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.11.12...2025.12.08)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.12.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 15:41:54 -08:00
Jesse Bannon
a5ae69550e
[BACKEND] Validate write permissions on working directory and output directory (#1395)
Closes https://github.com/jmbannon/ytdl-sub/issues/1394
Should help debug user issues like https://github.com/jmbannon/ytdl-sub/issues/1148

Checks permissions on the following:
- working directory
- output directory
- cookiefile (if specified)

And will fail immediately with a verbose message.
2025-12-06 15:54:41 -08:00
Jesse Bannon
8d5d2be6a7
[FEATURE] TV Show Collection multi-url, s00 support (#1391)
Closes https://github.com/jmbannon/ytdl-sub/issues/1251, https://github.com/jmbannon/ytdl-sub/issues/1290
Required for https://github.com/jmbannon/ytdl-sub/issues/1383

Adds both s00 support and multi-url support per season, up to 11 URLs currently. This can and should be extended, but with a more principled approach instead of the current method: AI generating boiler-plate YAML 🙂 

```
   Plex TV Show Collection:
     = Music:
       "~Beyond the Guitar":
         s00_name: "Specials"
         s00_url:
           - "https://www.youtube.com/watch?v=vXzguOdulAI"
           - "https://www.youtube.com/watch?v=IGwYDvaGAz0"
         s01_name: "Videos"
         s01_url:
           - "https://www.youtube.com/c/BeyondTheGuitar"
           - "https://www.youtube.com/@BeyondTheGuitarAcademy"
         s02_name: "Covers"
         s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
```
2025-11-28 10:14:18 -08:00
Jesse Bannon
19f47cd914
[BACKEND] Optimize script interpreter (#1390)
Makes scripting runtime much faster.
2025-11-27 18:43:27 -08:00
Jesse Bannon
24e71ce733
[DOCKER] Make defaults script executable (#1388)
Fixes https://github.com/jmbannon/ytdl-sub/issues/1387 and some potential docker issues by not having this file ran on start.
2025-11-26 20:22:22 -08:00
Colin Davis
63753c5649
[RFC] Add preserve_mtime config (#1382)
Closes https://github.com/jmbannon/ytdl-sub/issues/386

Adds the ability to preserve mtime via Output Options. Usage:

```
output_options:
  preserve_mtime: True
```

Thanks @e1ven for the contribution!
2025-11-17 22:42:13 -08:00
dependabot[bot]
08180c0412
Bump yt-dlp[default] from 2025.10.22 to 2025.11.12 (#1381)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.10.22 to 2025.11.12.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.10.22...2025.11.12)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.11.12
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-12 16:37:17 -08:00
Maxim Borisov
a74c79d014
[DOCKER] Ensure correct cron log file permissions (#1378)
Co-authored-by: bedlamzd <vcs@bedlamzd.dev>
2025-11-09 09:51:57 -08:00
Colin Davis
2c33c3b49b
[DOCKER] Add curl-cffi and yt-dlp-ejs dependencies
Adds recommended deps from yt-dlp README
2025-11-07 17:01:48 -08:00
Snuffy2
e507e6ed46
[DOCKER] Disable pip break-system-packages (#1373)
Fixes `UPDATE_YT_DLP_ON_START` when set to 'nightly' as the pip command is missing --break-system-packages. Simplifies dockerfiles by setting this globally via pip config file.

Thanks @Snuffy2 for the contribution!
2025-11-06 21:08:10 -08:00
Jesse Bannon
ed55f3a3f7
[FEATURE] cli-to-sub argument to convert yt-dlp args to ytdl-sub (#1376)
Example usage:
```
$ ytdl-sub cli-to-sub -S vcodec:h264,res:480,acodec:m4a
ytdl_options:
  format_sort:
  - vcodec:h264
  - res:480
  - acodec:m4a
```
2025-11-06 21:05:18 -08:00
dependabot[bot]
c3ca3c6379
[DEV] Bump isort from 6.1.0 to 7.0.0 (#1359)
Bumps [isort](https://github.com/PyCQA/isort) from 6.1.0 to 7.0.0.
- [Release notes](https://github.com/PyCQA/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyCQA/isort/compare/6.1.0...7.0.0)

---
updated-dependencies:
- dependency-name: isort
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 08:45:00 -07:00
dependabot[bot]
d3e978e517
[DEV] Bump pylint from 3.3.8 to 4.0.1 (#1361)
* Bump pylint from 3.3.8 to 4.0.1

Bumps [pylint](https://github.com/pylint-dev/pylint) from 3.3.8 to 4.0.1.
- [Release notes](https://github.com/pylint-dev/pylint/releases)
- [Commits](https://github.com/pylint-dev/pylint/compare/v3.3.8...v4.0.1)

---
updated-dependencies:
- dependency-name: pylint
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* update fixes

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jesse Bannon <jbann1994@gmail.com>
2025-10-26 23:57:35 -07:00
dependabot[bot]
a5dd438398
Bump yt-dlp[default] from 2025.10.14 to 2025.10.22 (#1366)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.10.14 to 2025.10.22.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.10.14...2025.10.22)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.10.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-23 01:28:34 -07:00
Jesse Bannon
7364aac00c
[BUGFIX] Embedded null byte ffmpeg fix (#1363)
Closes https://github.com/jmbannon/ytdl-sub/issues/710

Fixes the notorious embedded null byte error seen during FFMPEG metadata writes when handling special characters.
2025-10-16 08:01:37 -07:00
dependabot[bot]
1b2e34bad4
Bump yt-dlp[default] from 2025.9.26 to 2025.10.14 (#1362)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.9.26 to 2025.10.14.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.09.26...2025.10.14)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.10.14
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 13:39:24 -07:00
Jesse Bannon
863eae68a2
[FEATURE] New parameter for required subtitle languages, and fixes (#1357)
Closes https://github.com/jmbannon/ytdl-sub/issues/1269

Adds the new paramter `languages_required`, which forces an entry to have this set of subtitle files, otherwise it will error.
In addition, better file checking has been added to ensure they exist before attempting to move.
2025-10-11 17:22:13 -07:00
Jesse Bannon
f62b47888f
[FEATURE] Ability to modify webpage_url (#1356)
Closes https://github.com/jmbannon/ytdl-sub/issues/1353

Provides a way to modify webpage_url before actual entry downloads. 
Use-case: remove `#__youtubedl_smuggle` parameter in the URL
2025-10-11 17:21:13 -07:00
dependabot[bot]
e6624ee329
[DEV] Bump isort from 6.0.1 to 6.1.0 (#1351)
Bumps [isort](https://github.com/PyCQA/isort) from 6.0.1 to 6.1.0.
- [Release notes](https://github.com/PyCQA/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyCQA/isort/compare/6.0.1...6.1.0)

---
updated-dependencies:
- dependency-name: isort
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 21:26:30 -07:00
Jesse Bannon
2deafc9f2d
[DOCKER] Include Deno dependency, other fixes (#1345)
Closes
- Deno requirement: https://github.com/jmbannon/ytdl-sub/issues/1340
  - yt-dlp announcement: https://github.com/yt-dlp/yt-dlp/issues/14404
- Docker cron log double-logging: https://github.com/jmbannon/ytdl-sub/issues/1202 (thanks @timetocode for https://github.com/jmbannon/ytdl-sub/pull/1330 !)
- Missing pip dependency when updating yt-dlp: https://github.com/jmbannon/ytdl-sub/issues/1346
2025-09-27 16:43:34 -07:00
Jesse Bannon
609bec8aaf
[DOCKER] Do not remove pip for headless image (#1347)
Closes https://github.com/jmbannon/ytdl-sub/issues/1346

Keeps pip in the image to be able to update yt-dlp
2025-09-27 15:11:56 -07:00
Jesse Bannon
cc3a36e66a [HOTFIX] Fix CRON_SCHEDULE env var 2025-09-27 14:58:29 -07:00
Jesse Bannon
4f8490d88c
[DOCKER] Env variable to update yt-dlp on start (#1344)
Closes https://github.com/jmbannon/ytdl-sub/issues/1154

For docker images, add the ability to set the variable `UPDATE_YT_DLP_ON_START`, which will update yt-dlp to the specified version if a new one exists.

Supports ``stable``,  ``nightly``, ``master``.
2025-09-27 14:09:57 -07:00
Rob Helgeson
548187abed
bump yt-dlp to 2025.9.26 (#1342)
manual, no dependabot
2025-09-26 21:30:29 -07:00
dependabot[bot]
1d75ea3f06
Bump yt-dlp[default] from 2025.9.5 to 2025.9.23 (#1338)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.9.5 to 2025.9.23.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.09.05...2025.09.23)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.9.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 15:09:32 -07:00
Jesse Bannon
45c155c039
[FEATURE] resolution_assert_ignore_titles to ignore specific low-resolution videos (#1336)
Closes https://github.com/jmbannon/ytdl-sub/issues/1313

Adds `resolution_assert_ignore_titles` variable to be able to ignore specific videos in a subscription where 360p is suspected to be the only option.

Ideally, it would be nice if ytdl-sub could determine whether 360p is actually the only resolution or not automatically, and download if it's true. We need to do more research to determine if this is possible.

In the meantime, the above variable should help manually get around this. Usage:

```
   # use tilda mode to set override variables to the subscription
   "~My Subscription":
     url: "https://youtube.com/@channel"
     resolution_assert_ignore_titles:
       - "This 360p Video Title"
```
2025-09-13 11:32:54 -07:00
dependabot[bot]
0c34f819de
Bump yt-dlp[default] from 2025.8.27 to 2025.9.5 (#1335)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.8.27 to 2025.9.5.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.08.27...2025.09.05)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.9.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-09 08:46:19 -07:00
Ross Patterson
72419a3e98
[DOCS] How to test subscription downloads (#1318)
Revise the "Getting Started" page for testing subscription downloads.
2025-09-06 08:11:06 -07:00
Jesse Bannon
a7cf361821
[DOCKER] Make default paths same as docker defaults (#1332)
Changes documentation and default configs to use the `/tv_shows` default path shown in the docker compose setup.
Also changes working directory and file lock directory to not be in /tmp, which often causes permission issues.
2025-09-05 22:50:47 -07:00
Ross Patterson
b9aefb4bea
[DOCS] Cleanup confusing remnant text (#1333)
I'm not even sure what word I may have been trying for with "software", but it makes no
sense as is and is better without.
2025-09-05 19:39:09 -07:00
Jesse Bannon
2e67a57bc9
[BACKEND] Make resolution print debug-level (#1326)
```
Resolution assert is enabled, will fail on low-quality video downloads and presume throttle. Disable using the override variable `enable_resolution_assert: False
```
Spams during the initialization phase. Ideally, we should only print this once the subscription has begun downloading. Perhaps via a `print` plugin? Either way, set this to debug log level.
2025-09-01 14:33:46 -07:00
Jesse Bannon
3eb838eb40
[DOCKER] Set default workspace separately for GUI image (#1328)
Another attempt to fix some broken cron setups
2025-09-01 11:26:36 -07:00
Jesse Bannon
c2afe05c11
[DOCKER] Fix & issue in cron (#1325)
Fixes the following issue in some people's cron runs:
```
/bin/sh: 4: /config/.cron_wrapper: Syntax error: "&" unexpected
```
2025-08-31 18:59:59 -07:00
dependabot[bot]
1e0c9928a1
[DEV] Update pytest-rerunfailures requirement from <16,>=14 to >=14,<17 (#1317)
Updates the requirements on [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) to permit the latest version.
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/14.0...16.0)

---
updated-dependencies:
- dependency-name: pytest-rerunfailures
  dependency-version: '16.0'
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 19:52:02 -07:00
Ross Patterson
321556ecf7
[DEV] docs: Sphinx stale cross refs false success (#1319)
* ci(docs): RTD Sphinx warnings false successes

Use the same options on readthedocs.com that we use locally to fail on all warnings,
mostly to better catch stale cross references.

* docs(usage): Yet another stale Sphinx cross ref

I confirmed the previous RTD CI fix in the previous commit by pushing it to a draft PR
before pushing this change and the RTD action failed with the warning this commit
fixes. After pushing this commit, the RTD action succeeded.

* build(docs): Sphinx stale cross refs false success

I figured out why I kept getting warnings for broken Sphinx cross-refs *after* the
changes that caused them have already been merged, changes I know I ran `$ make docs`
for before pushing. The issue is that by default Sphinx only builds changed files for
faster iterations while editing, but it only catches broken cross-refs when it builds
files. So if changing, for example, a section name in one page that is referenced from
another page that you do *not* change, then the warning will be missed until something
changes that other page, such as a pull or rebase.

I considered adding a separate `./Makefile` target for incremental builds
in the inner loop of making changes. But I opted to just remove the `--write-all` CLI
option locally while editing in the inner loop, because the uncommitted change will
remind me to revert it and run a full rebuild before pushing.
2025-08-29 15:59:06 -07:00
Ross Patterson
746370ef9a
[DOCS] usage: Stale CLI options help output (#1311)
* docs(quick): Stale Sphinx ref link target name

Not sure how I missed this in my local testing, but this should have been a part a
previous PR.

Refs #1304

* docs(usage): Stale CLI options help output

From [merged PR
feedback](https://github.com/jmbannon/ytdl-sub/pull/1305#discussion_r2304503286).

Refs #1305
2025-08-29 10:08:07 -07:00
Jesse Bannon
ccdad4d4cd
[FEATURE] Apply entry throttle protection sleep post-completion (#1316)
Closes https://github.com/jmbannon/ytdl-sub/issues/1294

Applies entry sleep per download *after* the download is fully complete and moved to the output folder.
2025-08-29 09:52:24 -07:00
dependabot[bot]
efd5a88f92
Bump yt-dlp[default] from 2025.8.22 to 2025.8.27 (#1314)
Bumps [yt-dlp[default]](https://github.com/yt-dlp/yt-dlp) from 2025.8.22 to 2025.8.27.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2025.08.22...2025.08.27)

---
updated-dependencies:
- dependency-name: yt-dlp[default]
  dependency-version: 2025.8.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 01:00:34 -07:00
Jesse Bannon
df8b4a1df8
[FEATURE] Override variable support for throttle protection ranges (#1315)
Adds support for setting throttle_protection range values using static override variables. `sleep_per_download_s` has additional support for entry variables since it's used per entry.

We can now experiment with scaling this value based on entry attributes, such as duration. Example:

```
throttle_protection:
  sleep_per_download_s:
    min: >-
      {
        %mul(5.5, %pow( duration, 0.4 ))
      }
    max: >-
      {
        %mul(6.5, %pow( duration, 0.6 ))
      }
```
2025-08-28 23:55:57 -07:00
Ross Patterson
adb08ed5f9
[DOCS] start: Clarify subscriptions file tutorial (#1310)
* docs(presets): Stale copied prebuilt comment

* docs(start): How to configure subscriptions

Revise the subscriptions file Getting Started tutorial from the perspective of a new
user. The idea is that the ref docs describe a component as the code sees it, whereas
the how-to/tutorial/getting-started docs provide a narrative description of the
component from the external perspective of a typical new user.

* docs(start): Document subscription override mode

It's debatable whether this belongs here. Having moved onto my own custom presets, the
only use cases for override mode in my subscriptions file are for those series that also
have TVDB metadata. Those ytdl-sub downloads I integrate into my Sonarr library need
per-subscription overrides such as:

- Sonarr manages years 2020-2023 for one series and ytdl-sub fills in
  from there, so set a `sonarr_series_after: "20240101"` override

- each series needs a directory named per Sonarr's configuration with the TVDB ID, the subscription key,
  but also a series name prefix without the TVDB ID for each episode file, so set a
  `sonarr_series_prefix: "Foo Series (2020)"` override

So are there other use cases for override mode that are more common and less niche than
mine?

* docs(docker): Revert example bind mount volumes

Per [PR feedback](https://github.com/jmbannon/ytdl-sub/pull/1310#discussion_r2306067263).
2025-08-28 07:09:05 -07:00
Ross Patterson
283ccc8608
fix(docker): Default config available in WORKDIR (#1303)
Adds better support to run commands via `docker exec ...` by setting the working directory to be ytdl-sub's working directory.

Thanks @rpatterson for the contribution 😊
2025-08-27 20:17:57 -07:00
246 changed files with 8612 additions and 3281 deletions

5
.gitignore vendored
View file

@ -146,8 +146,11 @@ docker/testing/volumes
.local/
.ytdl-sub-working-directory
.ytdl-sub-lock
ffmpeg.exe
ffprobe.exe
tools/docgen/out
tools/docgen/out
prof/

View file

@ -7,6 +7,7 @@ build:
sphinx:
configuration: docs/source/conf.py
fail_on_warning: true
python:
install:
@ -14,4 +15,4 @@ python:
- method: pip
path: .
extra_requirements:
- docs
- docs

View file

@ -36,12 +36,12 @@ endif
all: check_lint docs docker docker_ubuntu docker_gui
lint:
python3 -m isort .
python3 -m black .
python3 -m ruff format .
python3 -m ruff check --fix .
python3 -m pylint src
check_lint:
isort . --check-only --diff \
&& black . --check \
ruff format --check . \
&& ruff check . \
&& pylint src/
wheel: clean
$(shell echo "__pypi_version__ = \"$(PYPI_VERSION)\"" > src/ytdl_sub/__init__.py)
@ -63,7 +63,8 @@ executable: clean
mv dist/ytdl-sub dist/ytdl-sub${EXEC_SUFFIX}
docs:
REGENERATE_DOCS=1 pytest tests/unit/docgen/test_docgen.py
sphinx-build --fail-on-warning --nitpicky -b html docs/source/ docs/build/
sphinx-build --write-all --fail-on-warning --nitpicky -b html \
"./docs/source/" "./docs/build/"
clean:
rm -rf \
.pytest_cache/ \

View file

@ -68,7 +68,7 @@ __preset__:
# Pass any arg directly to yt-dlp's Python API
ytdl_options:
cookiefile: "/config/cookie.txt"
cookiefile: "/config/ytdl-sub-configs/cookie.txt"
###################################################################
# TV Show Presets. Can replace Plex with Plex/Jellyfin/Emby/Kodi

View file

@ -7,16 +7,20 @@ FROM ghcr.io/linuxserver/baseimage-alpine:edge
ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/"
# Working directory used at both build and run times:
ENV DEFAULT_WORKSPACE="/config"
COPY root/ /
RUN mkdir -p /config && \
RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache --repository=http://dl-3.alpinelinux.org/alpine/edge/main/ \
vim \
g++ \
nano \
unzip \
make \
deno \
libffi-dev \
"python3>=3.10" \
py3-pip \
@ -28,6 +32,7 @@ RUN mkdir -p /config && \
"aria2>=1.36.0" && \
ffmpeg -version && \
aria2c --version && \
deno --version && \
# Install phantomjs if using x86_64, ensure it is properly installed
if [[ $(uname -m) == "x86_64" ]]; then \
echo "installing phantomjs" && \
@ -41,29 +46,30 @@ RUN mkdir -p /config && \
phantomjs --version && \
cd -; \
fi && \
echo "hi" && \
# Install ytdl-sub, ensure it is installed properly
python3 -m pip install --break-system-packages --no-cache-dir ytdl_sub-*.whl && \
# Configure pip globally
echo -e "[global]\nbreak-system-packages = true\nroot-user-action = ignore\nno-cache-dir = true" > /etc/pip.conf && \
# Install ytdl-sub and yt-dlp dependencies, ensure they are installed properly
python3 -m pip install ytdl_sub-*.whl curl-cffi yt-dlp-ejs && \
ytdl-sub -h && \
# Delete unneeded packages after install
rm ytdl_sub-*.whl && \
apk del \
g++ \
make \
libffi-dev \
py3-pip \
py3-setuptools
libffi-dev && \
python3 -m pip --help
###############################################################################
# CONTAINER CONFIGS
ENV EDITOR="nano" \
HOME="/config" \
HOME="${DEFAULT_WORKSPACE}" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config \
CRON_SCRIPT="/config/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
CRON_SCRIPT="${DEFAULT_WORKSPACE}/cron" \
CRON_WRAPPER_SCRIPT="${DEFAULT_WORKSPACE}/.cron_wrapper" \
LOGS_TO_STDOUT="${DEFAULT_WORKSPACE}/.cron.log" \
LSIO_FIRST_PARTY=false
VOLUME /config
VOLUME "${DEFAULT_WORKSPACE}"
WORKDIR "${DEFAULT_WORKSPACE}"

View file

@ -4,6 +4,8 @@ FROM lscr.io/linuxserver/code-server:4.98.2
ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/"
# Working directory used at both build and run times:
ENV DEFAULT_WORKSPACE="/config/ytdl-sub-configs"
###############################################################################
# YTDL-SUB INSTALL
@ -21,6 +23,7 @@ RUN mkdir -p /config && \
vim \
g++ \
nano \
unzip \
make \
python3-pip \
fontconfig \
@ -59,8 +62,13 @@ RUN mkdir -p /config && \
echo "Phantom JS version:" && \
phantomjs --version ; \
fi && \
# Install ytdl-sub, ensure it is installed properly
pip install --no-cache-dir --break-system-packages ytdl_sub-*.whl && \
# Install Deno, required for YouTube downloads
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
deno --help && \
# Configure pip globally
echo -e "[global]\nbreak-system-packages = true\nroot-user-action = ignore\nno-cache-dir = true" > /etc/pip.conf && \
# Install ytdl-sub and yt-dlp dependencies, ensure they are installed properly
python3 -m pip install ytdl_sub-*.whl curl-cffi yt-dlp-ejs && \
ytdl-sub -h && \
# Delete unneeded packages after install
rm ytdl_sub-*.whl && \
@ -68,11 +76,11 @@ RUN mkdir -p /config && \
g++ \
make \
xz-utils \
bzip2 \
python3-venv && \
bzip2 && \
apt-get autoremove -y && \
apt-get purge -y --auto-remove && \
rm -rf /var/lib/apt/lists/*
rm -rf /var/lib/apt/lists/* && \
python3 -m pip --help
###############################################################################
# CONTAINER CONFIGS
@ -80,10 +88,10 @@ RUN mkdir -p /config && \
ENV EDITOR="nano" \
HOME="/config" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config/ytdl-sub-configs \
CRON_SCRIPT="/config/ytdl-sub-configs/cron" \
CRON_SCRIPT="${DEFAULT_WORKSPACE}/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
LSIO_FIRST_PARTY=false
VOLUME /config
VOLUME /config
WORKDIR "${DEFAULT_WORKSPACE}"

1
docker/Dockerfile.headless Symbolic link
View file

@ -0,0 +1 @@
Dockerfile

View file

@ -7,13 +7,15 @@ ARG DEBIAN_FRONTEND=noninteractive
ENV OPENSSL_CONF="/etc/ssl"
# For downloading thumbnails
ENV SSL_CERT_DIR="/etc/ssl/certs/"
# Working directory used at both build and run times:
ENV DEFAULT_WORKSPACE="/config"
###############################################################################
# YTDL-SUB INSTALL
SHELL ["/bin/bash", "-c"]
COPY root/ /
RUN mkdir -p /config && \
RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
apt-get -y update && \
apt-get -y upgrade && \
apt-get install --no-install-recommends -y \
@ -24,6 +26,7 @@ RUN mkdir -p /config && \
vim \
g++ \
nano \
unzip \
make \
python3-pip \
fontconfig \
@ -62,8 +65,13 @@ RUN mkdir -p /config && \
echo "Phantom JS version:" && \
phantomjs --version ; \
fi && \
# Install ytdl-sub, ensure it is installed properly
pip install --no-cache-dir --break-system-packages ytdl_sub-*.whl && \
# Install Deno, required for YouTube downloads
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
deno --help && \
# Configure pip globally
echo -e "[global]\nbreak-system-packages = true\nroot-user-action = ignore\nno-cache-dir = true" > /etc/pip.conf && \
# Install ytdl-sub and yt-dlp dependencies, ensure they are installed properly
python3 -m pip install ytdl_sub-*.whl curl-cffi yt-dlp-ejs && \
ytdl-sub -h && \
# Delete unneeded packages after install
rm ytdl_sub-*.whl && \
@ -71,22 +79,22 @@ RUN mkdir -p /config && \
g++ \
make \
xz-utils \
bzip2 \
python3-venv && \
bzip2 && \
apt-get autoremove -y && \
apt-get purge -y --auto-remove && \
rm -rf /var/lib/apt/lists/*
rm -rf /var/lib/apt/lists/* && \
python3 -m pip --help
###############################################################################
# CONTAINER CONFIGS
ENV EDITOR="nano" \
HOME="/config" \
HOME="${DEFAULT_WORKSPACE}" \
DOCKER_MODS=linuxserver/mods:universal-stdout-logs|linuxserver/mods:universal-cron \
DEFAULT_WORKSPACE=/config \
CRON_SCRIPT="/config/cron" \
CRON_WRAPPER_SCRIPT="/config/.cron_wrapper" \
LOGS_TO_STDOUT=/config/.cron.log \
CRON_SCRIPT="${DEFAULT_WORKSPACE}/cron" \
CRON_WRAPPER_SCRIPT="${DEFAULT_WORKSPACE}/.cron_wrapper" \
LOGS_TO_STDOUT="${DEFAULT_WORKSPACE}/.cron.log" \
LSIO_FIRST_PARTY=false
VOLUME /config
VOLUME "${DEFAULT_WORKSPACE}"
WORKDIR "${DEFAULT_WORKSPACE}"

24
docker/root/custom-cont-init.d/defaults Normal file → Executable file
View file

@ -17,12 +17,28 @@ echo "Starting ytdl-sub..."
echo "alias ls='ls --color=auto'" > /config/.bashrc && \
echo "cd ." >> /config/.bashrc
# always create empty cron log file on start
echo "" > "$LOGS_TO_STDOUT"
# permissions
chown -R ${PUID:-abc}:${PGID:-abc} \
/config
# always create empty cron log file on start
echo "" > "$LOGS_TO_STDOUT"
# update command reference:
# https://github.com/yt-dlp/yt-dlp/wiki/Installation#with-pip
if [ "$UPDATE_YT_DLP_ON_START" == "stable" ] ; then
echo "UPDATE_YT_DLP_ON_START is set to stable, attempting to update to a new stable version of yt-dlp if it exists."
python3 -m pip install -U "yt-dlp[default]"
elif [ "$UPDATE_YT_DLP_ON_START" == "nightly" ] ; then
echo "UPDATE_YT_DLP_ON_START is set to nightly, attempting to update to the latest nightly version of yt-dlp."
python3 -m pip install -U --pre "yt-dlp[default]"
elif [ "$UPDATE_YT_DLP_ON_START" == "master" ] ; then
echo "UPDATE_YT_DLP_ON_START is set to master, pulling yt-dlp's latest commit for install."
python3 -m pip install -U pip hatchling wheel
python3 -m pip install --force-reinstall "yt-dlp[default] @ https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz"
else
echo "UPDATE_YT_DLP_ON_START is not set, using packaged version."
fi
# set up cron
if [ "$CRON_SCHEDULE" != "" ] ; then
@ -31,9 +47,11 @@ if [ "$CRON_SCHEDULE" != "" ] ; then
# create cron script wrapper
echo '#!/bin/bash' > "$CRON_WRAPPER_SCRIPT"
# Echo commands for easier user debugging:
echo "set -x" >> "$CRON_WRAPPER_SCRIPT"
echo "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" >> "$CRON_WRAPPER_SCRIPT"
echo "cd \"$DEFAULT_WORKSPACE\"" >> "$CRON_WRAPPER_SCRIPT"
echo ". \"$CRON_SCRIPT\" |& tee -a \"$LOGS_TO_STDOUT\"" >> "$CRON_WRAPPER_SCRIPT"
echo ". \"$CRON_SCRIPT\" >> \"$LOGS_TO_STDOUT\" 2>&1" >> "$CRON_WRAPPER_SCRIPT"
chmod +x "$CRON_WRAPPER_SCRIPT"
chown abc:abc "$CRON_WRAPPER_SCRIPT"

View file

@ -19,4 +19,4 @@ configuration:
# Avoid unnecessarily long large file renames, set this to a path on the same
# filesystem as the destination for downloaded files in the `overrides: /
# *_directory:` paths:
working_directory: "/media/Library/tmp/.ytdl-sub-working-directory"
working_directory: ".ytdl-sub-working-directory"

View file

@ -1,4 +1,15 @@
echo "Beginning cron job..."
# Place your ytdl-sub command(s) here.
# This script is executed in the same relative path as this file.
#
# This script is executed in the same directory as this file which also contains the
# default `./config.yaml` and `./subscriptions.yaml`, so you don't need to use the
# `--config` CLI option or pass a `SUBPATH` to the `$ ytdl-sub sub` sub-command.
#
# Test your configuration and subscriptions carefully before automating downloads to
# prevent triggering throttles or bans:
#
# https://ytdl-sub.readthedocs.io/en/latest/guides/getting_started/downloading.html
#
# Once you've tested your configuration and you're ready to download entries unattended,
# remove the next line and un-comment the following line:
echo "WARNING: Read /config/ytdl-sub-configs/cron and modify to automate downloads."
# ytdl-sub sub

View file

@ -6,13 +6,13 @@
__preset__:
overrides:
# Root folder of all ytdl-sub TV Shows
tv_show_directory: "/media/Library/Videos/Series"
tv_show_directory: "/tv_shows"
# Root folder of all ytdl-sub Music
music_directory: "/media/Library/Music"
music_directory: "/music"
# Root folder of all ytdl-sub Music Videos
music_video_directory: "/media/Library/Videos/Music"
music_video_directory: "/music_videos"
# For 'Only Recent' preset, only keep vids within this range and limit
# only_recent_date_range: "2months"
@ -20,7 +20,7 @@ __preset__:
# Pass any arg directly to yt-dlp's Python API
# ytdl_options:
# cookiefile: "/config/cookie.txt"
# cookiefile: "/config/ytdl-sub-configs/cookie.txt"
###################################################################
# Subscriptions nested under this will use the

46
docker/testing/Makefile Normal file
View file

@ -0,0 +1,46 @@
# Local building and testing of the Docker image variants.
# Defensive settings for make:
# https://tech.davis-hansson.com/p/make/
SHELL:=bash
.ONESHELL:
.SHELLFLAGS:=-eu -o pipefail -c
.SILENT:
.DELETE_ON_ERROR:
MAKEFLAGS+=--warn-undefined-variables
MAKEFLAGS+=--no-builtin-rules
export PS1?=$$
# Prefix echoed recipe commands with the recipe line number for debugging:
export PS4?=:$$LINENO+
VARIANTS=headless gui ubuntu
ROOT_PREREQS:=$(shell find ../root -type f)
# Finished with `$(shell)`, echo recipe commands going forward
.SHELLFLAGS+= -x
### Top-level targets:
.PHONY: all
all: build
.PHONY: build
build: $(VARIANTS:%=./build/ytdl-sub-%.log)
.PHONY: run
run: build $(VARIANTS:%=./volumes/ytdl-sub-%/)
docker compose up
### Real targets:
# Re-build the local images when changes require it.
./build/ytdl-sub-%.log: ../Dockerfile.% $(ROOT_PREREQS)
mkdir -pv "$(dir $(@))"
docker compose build "$(@:build/ytdl-sub-%.log=ytdl-sub-%)" |&
tee -a "$(@)"
# Ensure volumes are owned by the developer's normal user:
./volumes/ytdl-sub-%/:
mkdir -pv "$(@)"

View file

@ -1,39 +1,50 @@
services:
ytdl-sub-gui:
image: ytdl-sub-gui:local
container_name: ytdl-sub-gui
build:
context: "../"
dockerfile: "./Dockerfile.gui"
image: "ytdl-sub-gui:local"
container_name: "ytdl-sub-gui"
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- CRON_SCHEDULE="*/1 * * * *"
- CRON_RUN_ON_START=true
PUID: "1000"
PGID: "1000"
TZ: "America/Los_Angeles"
CRON_SCHEDULE: '*/1 * * * *'
CRON_RUN_ON_START: "true"
UPDATE_YT_DLP_ON_START: "stable"
volumes:
- ./volumes/ytdl-sub-gui:/config
- "./volumes/ytdl-sub-gui/:/config/"
ports:
- 8443:8443
restart: unless-stopped
ytdl-sub:
image: ytdl-sub:local
container_name: ytdl-sub
- "8443:8443"
restart: "unless-stopped"
ytdl-sub-headless:
build:
context: "../"
image: "ytdl-sub:local"
container_name: "ytdl-sub-headless"
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- CRON_SCHEDULE="*/1 * * * *"
- CRON_RUN_ON_START=true
PUID: "1000"
PGID: "1000"
TZ: "America/Los_Angeles"
CRON_SCHEDULE: '*/1 * * * *'
CRON_RUN_ON_START: "true"
UPDATE_YT_DLP_ON_START: "stable"
volumes:
- ./volumes/ytdl-sub:/config
restart: unless-stopped
- "./volumes/ytdl-sub-headless/:/config/"
restart: "unless-stopped"
ytdl-sub-ubuntu:
image: ytdl-sub-ubuntu:local
container_name: ytdl-sub-ubuntu
build:
context: "../"
dockerfile: "./Dockerfile.ubuntu"
image: "ytdl-sub-ubuntu:local"
container_name: "ytdl-sub-ubuntu"
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- CRON_SCHEDULE="*/1 * * * *"
- CRON_RUN_ON_START=true
PUID: "1000"
PGID: "1000"
TZ: "America/Los_Angeles"
CRON_SCHEDULE: '*/1 * * * *'
CRON_RUN_ON_START: "true"
UPDATE_YT_DLP_ON_START: "stable"
volumes:
- ./volumes/ytdl-sub:/config
restart: unless-stopped
- "./volumes/ytdl-sub-ubuntu/:/config/"
restart: "unless-stopped"

View file

@ -7,7 +7,7 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "ytdl-sub"
copyright = "2024, Jesse Bannon"
copyright = "2026, Jesse Bannon"
author = "Jesse Bannon"
release = ""
@ -15,10 +15,8 @@ release = ""
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosectionlabel",
"sphinx.ext.extlinks",
"sphinx.ext.napoleon",
"sphinx_copybutton",
"sphinx_design",
]
@ -70,19 +68,3 @@ extlinks = {
"lsio-gh": ("https://github.com/linuxserver/%s", "%s image"),
"ytdl-sub-gh": ("https://github.com/jmbannon/ytdl-sub/%s", "src %s"),
}
# -- Options for autodoc ----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration
# Automatically extract typehints when specified and place them in
# descriptions of the relevant function/method.
autodoc_default_options = {
"autodoc_typehints_format": "short",
"autodoc_class_signature": "separated",
"add_module_names": False,
# "add_class_names": False,
}
python_use_unqualified_type_names = True
napoleon_numpy_docstring = True
napoleon_use_rtype = False

View file

@ -1,7 +1,13 @@
==================
..
WARNING: This RST file is generated from docstrings in:
The respective function docstrings within ytdl_sub/config/config_validator.py
In order to make a change to this file, edit the respective docstring
and run `make docs`. This will automatically sync the Python RST-based
docstrings into this file. If the docstrings and RST file are out of sync,
it will fail TestDocGen tests in GitHub CI.
Configuration File
==================
ytdl-sub is configured using a ``config.yaml`` file.
The ``config.yaml`` is made up of two sections:
@ -11,113 +17,118 @@ The ``config.yaml`` is made up of two sections:
configuration:
presets:
You can jump to any section and subsection of the config using the navigation section to
the left.
Note for Windows users, paths can be represented with ``C:/forward/slashes/like/linux``.
If you wish to represent paths like Windows, you will need to
``C:\\double\\bashslash\\paths`` in order to escape the backslash character.
configuration
-------------
The ``configuration`` section contains app-wide configs applied to all presets and
subscriptions.
.. autoclass:: ytdl_sub.config.config_validator.ConfigOptions()
:members:
:member-order: bysource
:exclude-members: subscription_value, persist_logs, experimental
persist_logs
~~~~~~~~~~~~
Without this key, ``ytdl-sub`` only prints output to it's ``stdout`` and ``stderr``. If
your configuration includes the ``persist_logs:`` key, then ``ytdl-sub`` also writes log
files to disk.
.. warning::
The log files grow rapidly if ``keep_successful_logs:`` is ``true``, the default, and
may fill up disk space. Set ``keep_successful_logs: false`` or prune the log files
regularly.
For example:
If you prefer to use a Windows backslash, note that it must have
``C:\\double\\bashslash\\paths`` in order to escape the backslash character. This is due
to it being a YAML escape character.
.. code-block:: yaml
configuration:
dl_aliases:
mv: "--preset music_video"
u: "--download.url"
experimental:
enable_update_with_info_json: True
ffmpeg_path: "/usr/bin/ffmpeg"
ffprobe_path: "/usr/bin/ffprobe"
file_name_max_bytes: 255
lock_directory: "/tmp"
persist_logs:
logs_directory: "/path/to/log/directory"
keep_successful_logs: True
logs_directory: "/var/log/ytdl-sub-logs"
.. autoclass:: ytdl_sub.config.config_validator.PersistLogsValidator()
:members:
:member-order: bysource
umask: "022"
working_directory: ".ytdl-sub-working-directory"
dl_aliases
----------
.. _dl_aliases:
presets
-------
Each key under ``presets:`` defines a `formula` for how to format downloaded media and
metadata. The key is the name of the preset and the value is a mapping that defines the
preset.
.. note::
The ``presets:`` key at the top of the configuration file contains multiple
user-defined presets, but *each preset* itself may include a ``preset:`` key that
defines *that preset's* base presets. For example:
.. code-block:: yaml
presets:
Foo Preset:
preset:
- "Jellyfin TV Show by Date"
- "Only Recent"
preset
~~~~~~
Presets support inheritance by defining one or more parent presets:
Alias definitions to shorten :ref:`dl arguments <usage:Download Options>`. For example,
.. code-block:: yaml
presets:
custom_preset:
...
parent_preset:
...
child_preset:
preset:
- "parent_preset"
configuration:
dl_aliases:
mv: "--preset music_video"
u: "--download.url"
In the example above, ``child_preset`` inherits all fields defined in ``parent_preset``.
Use parent presets where possible to reduce duplicate yaml definitions.
Simplifies
Presets also support inheritance from multiple presets:
.. code-block:: bash
.. code-block:: yaml
ytdl-sub dl --preset "Jellyfin Music Videos" --download.url "youtube.com/watch?v=a1b2c3"
child_preset:
preset:
- "custom_preset"
- "parent_preset"
to
In this example, ``child_preset`` will inherit all fields from ``custom_preset`` and
``parent_preset`` in that order. The bottom-most preset has the highest priority. More
specifically, presets are merged using `mergedeep`_ via `a TYPESAFE_ADDITIVE merge`_,
which means:
.. code-block:: bash
- if two conflicting keys arent lists or mappings, overwrite the higher priority one
- otherwise, combine then re-evaluate
ytdl-sub dl --mv --u "youtube.com/watch?v=a1b2c3"
If you are only inheriting from one preset, using a single string instead of a list is
valid, for example ``preset: "parent_preset"``, but we recommend always using a list for
consistent readability between presets.
experimental
------------
Experimental flags reside under the ``experimental`` key.
.. _`mergedeep`:
https://mergedeep.readthedocs.io/en/latest/
.. _`a TYPESAFE_ADDITIVE merge`:
https://mergedeep.readthedocs.io/en/latest/index.html#merge-strategies
``enable_update_with_info_json``
Enables modifying subscription files using info.json files using the argument
``--update-with-info-json``. This feature is still being tested and has the ability to
destroy files. Ensure you have a full backup before usage. You have been warned!
ffmpeg_path
-----------
Path to ffmpeg executable. Defaults to ``/usr/bin/ffmpeg`` for Linux,
``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows.
ffprobe_path
------------
Path to ffprobe executable. Defaults to ``/usr/bin/ffprobe`` for Linux,
``./ffprobe.exe`` in the same directory as ytdl-sub for Windows.
file_name_max_bytes
-------------------
Max file name size in bytes. Most OS's typically default to 255 bytes.
lock_directory
--------------
The directory to temporarily store file locks, which prevents multiple instances
of ``ytdl-sub`` from running. Note that file locks do not work on
network-mounted directories. Ensure that this directory resides on the host
machine. Defaults to ``/tmp``.
persist_logs
------------
By default, no logs are persisted. Specifying this key will enable persisted logs. The following
options are available.
``keep_successful_logs``
Defaults to ``True``. When this key is ``False``, only write log files for failed
subscriptions.
``logs_directory``
Required field. Write log files to this directory with names like
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``.
umask
-----
Umask in octal format to apply to every created file. Defaults to ``022``.
working_directory
-----------------
The directory to temporarily store downloaded files before moving them into their final
directory. Defaults to ``.ytdl-sub-working-directory``, created in the same directory
that ytdl-sub is invoked from.
Presets
=======
Custom presets are defined in this section. Refer to the
:ref:`Getting Started Guide<guides/getting_started/first_config:Basic Configuration>`
on how to configure.

View file

@ -28,7 +28,6 @@ Extracts audio from a video file.
The codec to output after extracting the audio. Supported codecs are aac, flac, mp3, m4a,
opus, vorbis, wav, and best to grab the best possible format at runtime.
``enable``
:expected type: Optional[OverridesFormatter]
@ -37,7 +36,6 @@ Extracts audio from a video file.
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``quality``
:expected type: Float
@ -45,7 +43,6 @@ Extracts audio from a video file.
Optional. Specify ffmpeg audio quality. Insert a value between ``0`` (better) and ``9``
(worse) for variable bitrate, or a specific bitrate like ``128`` for 128k.
----------------------------------------------------------------------------------------------------
chapters
@ -84,14 +81,12 @@ chapters and remove specific ones. Can also remove chapters using regex.
Defaults to False. If chapters do not exist in the video/description itself, attempt to
scrape comments to find the chapters.
``embed_chapters``
:expected type: Optional[Boolean]
:description:
Defaults to True. Embed chapters into the file.
``enable``
:expected type: Optional[OverridesFormatter]
@ -100,7 +95,6 @@ chapters and remove specific ones. Can also remove chapters using regex.
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``force_key_frames``
:expected type: Optional[Boolean]
@ -108,7 +102,6 @@ chapters and remove specific ones. Can also remove chapters using regex.
Defaults to False. Force keyframes at cuts when removing sections. This is slow due to
needing a re-encode, but the resulting video may have fewer artifacts around the cuts.
``remove_chapters_regex``
:expected type: Optional[List[RegexString]
@ -116,7 +109,6 @@ chapters and remove specific ones. Can also remove chapters using regex.
List of regex patterns to match chapter titles against and remove them from the
entry.
``remove_sponsorblock_categories``
:expected type: Optional[List[String]]
@ -125,7 +117,6 @@ chapters and remove specific ones. Can also remove chapters using regex.
categories that are specified in ``sponsorblock_categories`` or "all", which removes
everything specified in ``sponsorblock_categories``.
``sponsorblock_categories``
:expected type: Optional[List[String]]
@ -134,7 +125,6 @@ chapters and remove specific ones. Can also remove chapters using regex.
"intro", "outro", "selfpromo", "preview", "filler", "interaction", "music_offtopic",
"poi_highlight", or "all" to include all categories.
----------------------------------------------------------------------------------------------------
date_range
@ -169,14 +159,12 @@ intended download files.
:description:
Only download videos after or on this datetime, inclusive.
``before``
:expected type: Optional[OverridesFormatter]
:description:
Only download videos only before this datetime, not inclusive.
``breaks``
:expected type: Optional[OverridesFormatter]
@ -184,7 +172,6 @@ intended download files.
Toggle to enable breaking subsequent metadata downloads if an entry's upload date
is out of range. Defaults to True.
``enable``
:expected type: Optional[OverridesFormatter]
@ -193,7 +180,6 @@ intended download files.
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``type``
:expected type: Optional[OverridesFormatter]
@ -201,7 +187,6 @@ intended download files.
Which type of date to use. Must be either ``upload_date`` or ``release_date``.
Defaults to ``upload_date``.
----------------------------------------------------------------------------------------------------
download
@ -305,7 +290,6 @@ Also supports custom ffmpeg conversions:
- Video: avi, flv, mkv, mov, mp4, webm
- Audio: aac, flac, mp3, m4a, opus, vorbis, wav
``convert_with``
:expected type: Optional[String]
@ -314,7 +298,6 @@ Also supports custom ffmpeg conversions:
yt-dlp whereas ``ffmpeg`` specifies it will be converted using a custom command specified
with ``ffmpeg_post_process_args``. Defaults to ``yt-dlp``.
``enable``
:expected type: Optional[OverridesFormatter]
@ -323,7 +306,6 @@ Also supports custom ffmpeg conversions:
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``ffmpeg_post_process_args``
:expected type: Optional[OverridesFormatter]
@ -336,7 +318,6 @@ Also supports custom ffmpeg conversions:
The output file will use the extension specified in ``convert_to``. Post-processing args
can still be set with ``convert_with`` set to ``yt-dlp``.
----------------------------------------------------------------------------------------------------
filter_exclude
@ -472,7 +453,6 @@ with a ``.nfo`` extension. You can add any values into the NFO.
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``kodi_safe``
:expected type: OverridesBooleanFormatterValidator
@ -481,14 +461,12 @@ with a ``.nfo`` extension. You can add any values into the NFO.
emojis and some foreign language characters. Setting this to True will replace those
characters with '□'.
``nfo_name``
:expected type: EntryFormatter
:description:
The NFO file name.
``nfo_root``
:expected type: EntryFormatter
@ -501,7 +479,6 @@ with a ``.nfo`` extension. You can add any values into the NFO.
<episodedetails>
</episodedetails>
``tags``
:expected type: NfoTags
@ -538,7 +515,6 @@ with a ``.nfo`` extension. You can add any values into the NFO.
<genre>Comedy</genre>
<genre>Drama</genre>
----------------------------------------------------------------------------------------------------
output_directory_nfo_tags
@ -570,7 +546,6 @@ Usage:
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``kodi_safe``
:expected type: OverridesBooleanFormatterValidator
@ -579,14 +554,12 @@ Usage:
emojis and some foreign language characters. Setting this to True will replace those
characters with '□'.
``nfo_name``
:expected type: EntryFormatter
:description:
The NFO file name.
``nfo_root``
:expected type: EntryFormatter
@ -599,7 +572,6 @@ Usage:
<tvshow>
</tvshow>
``tags``
:expected type: NfoTags
@ -634,7 +606,6 @@ Usage:
<genre>Comedy</genre>
<genre>Drama</genre>
----------------------------------------------------------------------------------------------------
output_options
@ -669,7 +640,6 @@ Defines where to output files and thumbnails after all post-processing has compl
The file name to store a subscriptions download archive placed relative to
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
``file_name``
:expected type: EntryFormatter
@ -677,7 +647,6 @@ Defines where to output files and thumbnails after all post-processing has compl
The file name for the media file. This can include directories such as
``"Season {upload_year}/{title}.{ext}"``, and will be placed in the output directory.
``info_json_name``
:expected type: Optional[EntryFormatter]
@ -686,7 +655,6 @@ Defines where to output files and thumbnails after all post-processing has compl
as ``"Season {upload_year}/{title}.{info_json_ext}"``, and will be placed in the output
directory. Can be set to empty string or `null` to disable info json writes.
``keep_files_after``
:expected type: Optional[OverridesFormatter]
@ -698,7 +666,6 @@ Defines where to output files and thumbnails after all post-processing has compl
files after ``19000101``, which implies all files. Can be used in conjunction with
``keep_max_files``.
``keep_files_before``
:expected type: Optional[OverridesFormatter]
@ -710,7 +677,6 @@ Defines where to output files and thumbnails after all post-processing has compl
files before ``now``, which implies all files. Can be used in conjunction with
``keep_max_files``.
``keep_files_date_eval``
:expected type: str
@ -720,7 +686,6 @@ Defines where to output files and thumbnails after all post-processing has compl
perform evaluation for keep_files_before/after and keep_max_files. Defaults
to the entry's upload_date_standardized variable.
``keep_max_files``
:expected type: Optional[OverridesFormatter]
@ -730,7 +695,6 @@ Defines where to output files and thumbnails after all post-processing has compl
Only keeps N most recently uploaded videos. If set to <= 0, ``keep_max_files`` will not be
applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``.
``maintain_download_archive``
:expected type: Optional[Boolean]
@ -745,7 +709,6 @@ Defines where to output files and thumbnails after all post-processing has compl
Defaults to False.
``migrated_download_archive_name``
:expected type: Optional[OverridesFormatter]
@ -755,13 +718,19 @@ Defines where to output files and thumbnails after all post-processing has compl
name first, and fallback to ``download_archive_name``. It will always save to this file
and remove the original ``download_archive_name``.
``output_directory``
:expected type: OverridesFormatter
:description:
The output directory to store all media files downloaded.
``preserve_mtime``
:expected type: Optional[Boolean]
:description:
Preserve the video's original upload time as the file modification time.
When True, sets the file's mtime to match the video's upload_date from
yt-dlp metadata. Defaults to False.
``thumbnail_name``
@ -771,7 +740,6 @@ Defines where to output files and thumbnails after all post-processing has compl
as ``"Season {upload_year}/{title}.{thumbnail_ext}"``, and will be placed in the output
directory. Can be set to empty string or `null` to disable thumbnail writes.
----------------------------------------------------------------------------------------------------
overrides
@ -836,7 +804,6 @@ used with no modifications.
If a file has no chapters and is set to "pass", then ``chapter_title`` is
set to the entry's title and ``chapter_index``, ``chapter_count`` are both set to 1.
----------------------------------------------------------------------------------------------------
square_thumbnail
@ -883,7 +850,6 @@ Usage:
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``kodi_safe``
:expected type: OverridesBooleanFormatterValidator
@ -892,14 +858,12 @@ Usage:
emojis and some foreign language characters. Setting this to True will replace those
characters with '□'.
``nfo_name``
:expected type: EntryFormatter
:description:
The NFO file name.
``nfo_root``
:expected type: EntryFormatter
@ -912,7 +876,6 @@ Usage:
<season>
</season>
``tags``
:expected type: NfoTags
@ -926,7 +889,6 @@ Usage:
<title>My custom season name!</title>
</season>
----------------------------------------------------------------------------------------------------
subtitles
@ -954,7 +916,6 @@ It will set the respective language to the correct subtitle file.
:description:
Defaults to False. Whether to allow auto generated subtitles.
``embed_subtitles``
:expected type: Optional[Boolean]
@ -962,7 +923,6 @@ It will set the respective language to the correct subtitle file.
Defaults to False. Whether to embed the subtitles into the video file. Note that
webm files can only embed "vtt" subtitle types.
``enable``
:expected type: Optional[OverridesFormatter]
@ -971,7 +931,6 @@ It will set the respective language to the correct subtitle file.
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``languages``
:expected type: Optional[List[String]]
@ -979,6 +938,12 @@ It will set the respective language to the correct subtitle file.
Language code(s) to download for subtitles. Supports a single or list of multiple
language codes. Defaults to only "en".
``languages_required``
:expected type: Optional[List[String]]
:description:
Language code(s) that are required to be present for downloads to continue. If missing,
ytdl-sub will throw an error. NOTE: currently this only checks file-based subtitles.
``subtitles_name``
@ -989,14 +954,12 @@ It will set the respective language to the correct subtitle file.
and will be placed in the output directory. ``lang`` is dynamic since you can download
multiple subtitles. It will set the respective language to the correct subtitle file.
``subtitles_type``
:expected type: Optional[String]
:description:
Defaults to "srt". One of the subtitle file types "srt", "vtt", "ass", "lrc".
----------------------------------------------------------------------------------------------------
throttle_protection
@ -1005,6 +968,9 @@ Provides options to make ytdl-sub look more 'human-like' to protect from throttl
range-based values, a random number will be chosen within the range to avoid sleeps looking
scripted.
Range min and max values support static override variables within their definitions.
``sleep_per_download_s`` supports both static and override variables.
:Usage:
.. code-block:: yaml
@ -1034,14 +1000,12 @@ scripted.
this field can be set using an override variable to easily toggle whether this plugin
is enabled or not via Boolean.
``max_downloads_per_subscription``
:expected type: Optional[Range]
:description:
Number of downloads to perform per subscription.
``sleep_per_download_s``
:expected type: Optional[Range]
@ -1049,7 +1013,6 @@ scripted.
Number in seconds to sleep between each download. Does not include time it takes for
ytdl-sub to perform post-processing.
``sleep_per_request_s``
:expected type: Optional[Range]
@ -1059,14 +1022,12 @@ scripted.
download for the entry. Also, yt-dlp only supports a single value at this time for this,
so will always use the max value.
``sleep_per_subscription_s``
:expected type: Optional[Range]
:description:
Number in seconds to sleep between each subscription.
``subscription_download_probability``
:expected type: Optional[Float]
@ -1075,7 +1036,6 @@ scripted.
recommended to set if you run ytdl-sub in a cron-job, that way you are statistically
guaranteed over time to eventually download the subscription.
----------------------------------------------------------------------------------------------------
video_tags

View file

@ -521,6 +521,13 @@ pow
:description:
``**`` operator. Returns the exponential of the base and exponent value.
range
~~~~~
:spec: ``range(end: Integer, start: Optional[Integer], step: Optional[Integer]) -> Array``
:description:
Returns the desired range of Integers in the form of an Array.
sub
~~~
:spec: ``sub(values: Numeric, ...) -> Numeric``
@ -830,7 +837,7 @@ behavior.
sanitize
~~~~~~~~
:spec: ``sanitize(value: AnyArgument) -> String``
:spec: ``sanitize(value: AnyArgument, ...) -> String``
Sanitize a string using yt-dlp's ``sanitize_filename`` method to ensure it's safe to use
for file/directory names on any OS.

View file

@ -25,11 +25,13 @@ supply a cookies file path.
.. code-block:: yaml
__preset__:
# Variables that override defaults from `overrides:` for presets in YAML keys:
overrides:
tv_show_directory: "/tv_shows"
# Directly set plugin options:
ytdl_options:
cookiefile: "/config/cookie.txt"
cookiefile: "/config/ytdl-sub-configs/cookie.txt"
Layout
------

View file

@ -1,10 +1,8 @@
Debugging
=========
Run with the ``--log-level verbose`` CLI option to see more information in the output,
such as all ``yt-dlp`` logs. Run with ``--log-level debug`` to show all log messages,
often too much information for normal operation but useful when investigating a specific
problem.
Run with ``--log-level debug`` to show all log messages, often too much information for
normal operation but useful when investigating a specific problem.
:ref:`ytdl-sub builds on yt-dlp <introduction:motivation>`, which is in itself a complex
tool. It performs an intricate and fragile task, web scraping, which in turn :ref:`is

View file

@ -1,6 +1,15 @@
Deprecation Notices
===================
Dec 2025
--------
Override variables names can no longer be plugin names, to avoid the common pitfall of
defining a plugin underneath ``overrides``.
In the past, there was usage of a ``date_range`` override variable in a few example configs
that complimented the ``Only Recent`` preset. This overrride variable usage needs to be
replaced with ``only_recent_date_range``.
Sep 2024
--------

View file

@ -172,7 +172,7 @@ need to be removed:
necessary>`. To re-download, those options must be disabled or modified. Disable
:ref:`the 'break_on_existing' option <config_reference/plugins:ytdl_options>`, set
:ref:`the 'date_range:' plugin <config_reference/plugins:date_range>`, and :ref:`limit
the subscriptions <guides/getting_started/first_download:faster iteration cycle>` to
the subscriptions <guides/getting_started/downloading:preview>` to
download only the files that you've renamed in the steps above.
Set the appropriate dates, :ref:`including a sufficient margin
@ -182,11 +182,11 @@ need to be removed:
.. code-block:: shell
ytdl-sub sub -o "\
ytdl-sub --match="NOVA PBS" sub -o "\
--ytdl_options.break_on_existing False \
--date_range.after 20240101 \
--date_range.before 20250101 \
" --match="NOVA PBS"
"
...download a file missing from the archive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -287,3 +287,8 @@ tell yt-dlp to explicitly download English metadata using.
:alt:
The Plex Agents settings page has Local Media Assets enabled for Personal Media
Shows and Movies tabs.
...ytdl-sub errors when downloading a 360p video with resolution assert
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
:ref:`See how to either ignore this specific video or disable resolution assertion entirely here. <resolution assert handling>`

View file

@ -52,9 +52,10 @@ above command will rebuild those as well.
Testing
-------
Tests are written using pytest. Many of them evaluate checksums of output files to ensure no unintended
changes are introduced to the way ``ytdl-sub`` produces files. This checksum can be inaccurate for
end-to-end tests, but are reliable for integration tests.
Tests are written using pytest. Many of them evaluate checksums of output files to
ensure no unintended changes are introduced to the way ``ytdl-sub`` produces files. This
checksum can be inaccurate for end-to-end tests, but are reliable for integration tests.
If integration tests are failing, ensure...
@ -63,6 +64,20 @@ If integration tests are failing, ensure...
- your local ``ytdl-sub`` dependencies are up-to-date
Docker
------
Test changes to the Docker image variants locally:
.. code-block:: shell
cd ./docker/testing/
make -j run
See ``./docker/testing/docker-compose.yml`` for the Compose services for each image
variant.
IDE Setup
---------

View file

@ -1,51 +1,80 @@
Automating Downloads
====================
Automating
==========
:ref:`Guide for Docker and Unraid Containers <guides/getting_started/automating_downloads:docker and unraid>`
Automate downloading your subscriptions by running the :ref:`'sub' sub-command
<usage:subscriptions options>` periodically. There are various tools that can run
commands on a schedule you may use any of them that work with your installation
method. Most users use `cron`_ in `Docker containers <docker and unraid_>`_.
:ref:`Guide for Linux <guides/getting_started/automating_downloads:linux>`
:ref:`Guide for Windows <guides/getting_started/automating_downloads:windows>`
.. _cron scheduling syntax: https://crontab.guru/#0_*/6_*_*_*
.. _docker-unraid-setup:
Docker and Unraid
-----------------
Cron is preconfigured in every ytdl-sub docker container. Enable by adding the following
ENV variables to your docker setup.
:doc:`The 'ytdl-sub' Docker container images <../install/docker>` provide optional cron
support. Enable cron support by setting `a cron schedule`_ in the ``CRON_SCHEDULE``
environment variable:
.. code-block:: yaml
:caption: ./compose.yaml
:emphasize-lines: 4
services:
ytdl-sub:
environment:
- CRON_SCHEDULE="0 */6 * * *"
- CRON_RUN_ON_START=false
CRON_SCHEDULE: "0 */6 * * *"
# WARNING: See "Getting Started" -> "Automating" docs regarding throttles/bans:
# CRON_RUN_ON_START: false
Then recreate the container to apply the change and start it to generate the default
``/config/ytdl-sub-configs/cron`` script. Read the comments in that script and edit as
appropriate.
- ``CRON_SCHEDULE`` follows the standard `cron scheduling syntax`_. The above value will
run the script once every 6 hours.
- ``CRON_RUN_ON_START`` toggles whether to run your cron script on container start in
addition to the cron schedule.
The container cron wrapper script will write output from the cron job to
``/config/ytdl-sub-configs/.cron.log``. The default image ``ENTRYPOINT`` will ``$ tail
...`` that file so you can monitor the cron job in the container's output and thus also
in the Docker logs.
The cron script will reside in the main directory with the file name ``cron``. Cron
logs should show when viewing the Docker logs.
You may also set the ``CRON_RUN_ON_START`` environment variable to ``true`` to have the
image run your cron script whenever the container starts in addition to the cron
schedule.
.. warning::
Using ``CRON_RUN_ON_START`` may cause your cron script to run too often and may
trigger throttles and bans. When enabled, your cron script will run *whenever* the
container starts including when the host reboots, when ``# dockerd`` restarts such as
when upgrading Docker itself, when a new image is pulled, when something applies
Compose changes, etc.. This may result in running ``ytdl-sub`` right before or after
the next cron scheduled run.
.. _linux-setup:
Linux
-----
Must configure crontab manually, like so:
Linux, Mac OS X, BSD, or other UNIX's
-------------------------------------
For installations on systems already running ``# crond``, you can also use cron to run
``ytdl-sub`` periodically. Write a script to run ``ytdl-sub`` in the cron job. Be sure
the script changes to the same directory as your configuration and uses the full path to
``ytdl-sub``:
.. code-block:: shell
:caption: ~/.local/bin/ytdl-sub-cron
:emphasize-lines: 2,3
crontab -e
0 */6 * * * /config/run_cron
#!/bin/bash
cd "~/.config/ytdl-sub/"
~/.local/bin/ytdl-sub --dry-run sub -o '--ytdl_options.max_downloads 3' |&
tee -a "~/.local/state/ytdl-sub/.cron.log"
Then tell ``# crond`` when to run the script:
.. code-block:: console
echo "0 */6 * * * ${HOME}/.local/bin/ytdl-sub-cron" | crontab "-"
Remove the ``--dry-run`` and ``-o ...`` CLI options from your cron script when you've
tested your configuration and you're ready to download entries unattended.
.. _windows-setup:
@ -53,9 +82,51 @@ Must configure crontab manually, like so:
Windows
-------
To be tested (please contact code owner or join the discord server if you can test this
out for us)
For most Windows users, the best way to run commands periodically is `the Task
Scheduler`_:
.. code-block:: powershell
.. attention::
ytdl-sub.exe --config \path\to\config\config.yaml sub \path\to\config\subscriptions.yaml
These instructions are untested. Use at your own risk. If you use them, whether they
work or not, please let us know how it went in `a support post in Discord`_ or `a new
GitHub issue`_.
#. Open the Task Scheduler app.
#. Click ``Create Basic Task`` at the top of the right sidebar.
#. Set all the fields as appropriate until you get to the ``Action``...
#. For the ``Action``, select ``Start a program``...
#. Click ``Browse...`` to the installed ``ytdl-sub.exe`` executable...
#. Add CLI arguments to ``Add arguments (optional):``, for example ``--dry-run sub -o
'--ytdl_options.max_downloads 3'``...
#. Set ``Start in (optional):`` to the directory containing your configuration.
#. Finish the rest of the ``Create Basic Task`` wizard.
Next Steps
----------
At this point, ``ytdl-sub`` should run periodically and keep your subscriptions current
in your media library without your intervention. As your :doc:`subscriptions file
<./subscriptions>` grows or you discover new use cases, it becomes worth while to
simplify things by :doc:`defining your own custom presets <./first_config>`.
.. _`cron`:
https://en.wikipedia.org/wiki/Cron
.. _`a cron schedule`:
https://crontab.cronhub.io/
.. _`the Task Scheduler`:
https://learn.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-start-page
.. _`a support post in Discord`:
https://discord.com/channels/994270357957648404/1084886228266127460
.. _`a new GitHub issue`:
https://github.com/jmbannon/ytdl-sub/issues/new

View file

@ -0,0 +1,69 @@
Downloading
===========
Once you've :doc:`defined your subscriptions <./subscriptions>`, it's time to test your
configuration and try your first download. As a web scraping tool, :ref:`it's important
to minimize the requests sent to external services
<guides/getting_started/index:minimize the work to only what's necessary>` to avoid
triggering throttling or bans. Further, a full download of even one subscription can
take significant time. Test each change to your subscriptions carefully and quickly as
follows.
Preview
-------
Preview what ``ytdl-sub`` would do for this subscription. Run the :ref:`'sub'
sub-command <usage:subscriptions options>` with CLI options to restrict requests as much
as possible:
- Pull metadata and *simulate* a download without actually downloading any media files
using the ``--dry-run`` CLI option.
- Limit requests by narrowing the run to one subscription by giving a
subscription name to the ``--match`` CLI option.
- Stop after just a few downloads to further minimize requests and make testing faster
using the ``max_downloads`` setting from ``yt-dlp``.
Change to the directory containing your ``./subscriptions.yaml`` file and run with those
options:
.. code-block:: console
cd "/config/ytdl-sub-configs/"
ytdl-sub --dry-run --match="NOVA PBS" sub -o '--ytdl_options.max_downloads 3'
Examine the output carefully, investigate anything that doesn't look right and repeat
this step until everything looks right.
Review
------
Review the results of real downloads. Run it again without the ``--dry-run`` option to
actually download media and place the files in your library:
.. code-block:: console
ytdl-sub --match="NOVA PBS" sub -o '--ytdl_options.max_downloads 3'
Examine the output carefully again. Then examine how the resulting downloads work in
your library. Repeat with a larger value for ``max_downloads`` and examine the output
and downloads again.
Next Steps
----------
Once you're `previewed <preview_>`_ and `reviewed <review_>`_ successful downloads of
each of your subscriptions, you're ready to run a full download of all your
subscriptions. Run the sub-command without the CLI options you used to limit what
``ytdl-sub`` does while testing:
.. code-block:: console
ytdl-sub sub
If you're ready to let ``ytdl-sub`` run unattended, it's time to :doc:`automate
downloads <./automating_downloads>`.

View file

@ -3,19 +3,39 @@ Basic Configuration
A configuration file serves two purposes:
1. Set advanced functionality that is not specifiable in a subscription file, such as
working directory location. These are set underneath ``configuration``.
2. Create custom presets, which can drastically simplify your subscription file. These
are defined underneath ``presets``. Presets are intended to be applicable and
reusable across multiple subscriptions.
1. Set application-level functionality that is not specifiable in a subscription file.
Below is a common configuration:
.. note::
ytdl-sub does not require a configuration file. However,
certain application settings may be desirable for tweak, such as setting
``working_directory`` to make ytdl-sub perform the initial download
to an SSD drive.
2. Create custom presets.
.. note::
In the prior Initial Subscription examples, we leveraged the prebuilt preset
``Jellyfin TV Show by Date``. This preset is entirely built using the same
YAML configuration system offered to users by using a configuration file.
The following section attempts to demystify and explain how to...
- Set an application setting
- Know whether or not custom presets are actually needed
- How to create a custom preset
- How to use a custom preset on subscriptions
-------------
how this works, and show-case how
.. code-block:: yaml
:linenos:
configuration:
working_directory: "/media/Library/tmp/.ytdl-sub-working-directory"
working_directory: ".ytdl-sub-working-directory"
presets:
TV Show:
@ -37,7 +57,7 @@ Below is a common configuration:
max: 36
overrides:
tv_show_directory: "/media/Library/Videos/Series"
tv_show_directory: "/tv_shows"
TV Show Only Recent:
preset:
@ -59,7 +79,7 @@ filesystem as your library in the ``overrides: / *_directory:`` paths:
:lineno-start: 1
configuration:
working_directory: "/media/Library/tmp/.ytdl-sub-working-directory"
working_directory: ".ytdl-sub-working-directory"
Preset Section
@ -222,3 +242,22 @@ Be sure to tell ytdl-sub to use your config by using the argument ``--config
If you run ytdl-sub in the same directory, and the config file is named ``config.yaml``,
it will use it by default.
Visualizing a subscription in Preset form
-----------------------------------------
Subscription file syntax is designed to minimize boiler-plate when authoring new subscriptions.
You can unpack any subscription using the ``inspect`` sub-command to see its boiler-plate *preset format*.
.. code-block:: bash
ytdl-sub inspect --match "BBC News" /path/to/subscriptions.yaml
This can be utilized for numerous purposes including:
* Ensuring your custom preset is getting applied correctly.
* Figuring out which variables set things like file names, metadata, etc.
* Understanding how subscription syntax translates to preset representation.
The default ``--level`` of inspect will fill in defined variables. Using ``--level original`` will
present the subscription's raw layout with no fill.

View file

@ -1,56 +0,0 @@
Initial Download
================
Once you have a ``subscriptions.yaml`` file created, you can perform your first
download. Access ``ytdl-sub``, navigate to the directory containing your
``subscriptions.yaml`` file.
Dry Run
-------
Performing a dry run is important when applying any change to your subscriptions to
ensure output looks as expected. Dry runs will pull metadata to *simulate* a download
without actually downloading the media file.
.. code-block:: shell
ytdl-sub --dry-run sub subscriptions.yaml
Faster Iteration Cycle
----------------------
Testing subscriptions can take quite some time to perform a full download. This can be
speed up by applying an override via command-line to set max number of downloads.
.. code-block:: shell
ytdl-sub --dry-run sub subscriptions.yaml -o '--ytdl_options.max_downloads 3'
Having many subscriptions could still make this dry run take a while. A subset of
subscriptions can be dry ran using a match.
.. code-block:: shell
:caption: Only run subscriptions that have PBS in their names
ytdl-sub --dry-run sub subscriptions.yaml -o '--ytdl_options.max_downloads 3' --match PBS
Downloading
-----------
Once the subscriptions file is validated, a download can be performed by omitting the
dry run argument.
.. code-block:: shell
ytdl-sub sub subscriptions.yaml
Multiple subscription file names can be provided to perform a download on all of them. A
single file named ``subscriptions.yaml`` does not require a file name specification
since it will look for that file name by default, making the following command valid.
.. code-block:: shell
ytdl-sub sub

View file

@ -1,128 +0,0 @@
Initial Subscription
====================
Your first subscription file should look something like this:
.. code-block:: yaml
:linenos:
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
# Can choose between:
# - Plex TV Show by Date:
# - Jellyfin TV Show by Date:
# - Kodi TV Show by Date:
#
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
YouTube Releases:
= Jazz: # Sets genre tag to "Jazz"
"Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases"
YouTube Full Albums:
= Lofi:
"Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i"
Let's break this down:
.. code-block:: yaml
:lineno-start: 1
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
The first :ref:`__preset__ <config_reference/subscription_yaml:File Preset>` section is
where we can set modifications that apply to every subscription in this file.
This snippet specifically adds two :ref:`override <config_reference/plugins:Overrides>`
variables, which are used by the presets below.
.. note::
It is tempting to put any override underneath ``overrides``. Keep in mind that this
section is solely for variable defining. Other :ref:`plugins
<config_reference/plugins:Plugins>` need to be set at the same indentation level as
``overrides``, not within it.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
On line 11, we set the key to ``Jellyfin TV Show by Date``. This is a :ref:`prebuilt
preset <prebuilt_presets/index:prebuilt presets>` that configures subscriptions to look
like TV shows in the Jellyfin media player (can be changed to one of the presets
outlined in the comment above). Setting it as a YAML key implies that all subscriptions
underneath it will *inherit* this preset.
This preset expects the variable ``tv_show_directory`` to be set, which we do above.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
Line 12 sets the key to ``= Documentaries``. When keys are prefixed with ``=``, it means
we are setting the genre. This value will get written to the respective metadata tags
for both TV show and music presets.
Behind the scenes, this sets the override variable ``subscription_indent_1``. Further
documentation can be found here for :ref:`subscription syntax
<config_reference/subscription_yaml:Subscription File>` and :ref:`subscription variables
<config_reference/scripting/static_variables:Subscription Variables>`.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
Line 13 is where we define our first subscription. We set the subscription name to
``NOVA PBS``, and the subscription value to ``https://www.youtube.com/@novapbs``.
To see how presets ingest subscription definitions, refer to the :ref:`preset references
<config_reference/prebuilt_presets/tv_show:TV Show>`, we can see that
``{subscription_name}`` is used to set the ``tv_show_name`` variable.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
Line 15 underneath ``Jellyfin TV Show by Date``, but at the same level as ``=
Documentaries``. This means we'll inherit the TV show preset, but not the documentaries
indent variable. We instead set the indent variables to ``= Kids | = TV-Y``. This sets
two indent variables. We can set multiple presets and/or indent variables on the same
key by using ``|`` as a separator.
Referring to the :ref:`TV show preset reference
<config_reference/prebuilt_presets/tv_show:TV Show>`, the first two indent variables map
to the TV show genre and TV show content rating.
The above info should be enough to understand the rest of the subscription file.

View file

@ -39,7 +39,7 @@ For most users, ``ytdl-sub`` works as follows:
Subscriptions use presets
~~~~~~~~~~~~~~~~~~~~~~~~~
Run ``$ ytdl-sub sub`` to read :doc:`a subscription file <./first_sub>` that defines
Run ``$ ytdl-sub sub`` to read :doc:`a subscription file <./subscriptions>` that defines
what subscriptions to download and place into your media library. Each subscription
selects which :doc:`presets <../../prebuilt_presets/index>` to apply. Those presets
configure how each subscription is downloaded and placed in the media library.
@ -94,11 +94,12 @@ Genres are also more often shared between subscriptions than not. To accommodate
reality, the ancestor keys of subscriptions may also use :ref:`the special '= ...'
prefix to pass specific overrides
<config_reference/scripting/static_variables:subscription_indent_i>` supported by the
preset. By convention in the built-in presets, the first ``= ...`` value specifies the
genre for all descendant subscriptions.
preset. By convention in the pre-built media type presets, the first ``= ...`` value
specifies the genre for all descendant subscriptions.
Finally, ancestor keys may use the ``... | ...`` special character to combine multiple
presets and/or genres for the descendant subscriptions beneath.
Finally, ancestor keys may use :ref:`the '... | ...' special character
<config_reference/subscription_yaml:multi keys>` to combine multiple presets and/or
genres for the descendant subscriptions beneath.
The configuration file extends pre-defined presets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -133,28 +134,24 @@ use no preset at all and will just run ``yt-dlp`` without any customization or p
processing. The subscriptions file has special support for :ref:`overriding the presets
of all subscriptions in the file <config_reference/subscription_yaml:file preset>`. The
configuration file supports :ref:`a few special options
<config_reference/config_yaml:configuration>` that are not about defining presets. See
<config_reference/config_yaml:Configuration File>` that are not about defining presets. See
:doc:`the reference documentation <../../config_reference/index>` for technically
complete details, but for almost all of the use cases served by ``ytdl-sub``, the above
is accurate and representative.
Ready to Start?
---------------
Next Steps
----------
Now that you've completed your install of ``ytdl-sub``, it's time to get started. It is
recommended to go through the below sections in order to fully grasp ytdl-sub.
With ``ytdl-sub`` installed and the above understood, the next step is to :doc:`start
adding subscriptions <./subscriptions>`.
.. toctree::
:maxdepth: 2
first_sub
first_download
automating_downloads
first_config
.. toctree::
:hidden:
subscriptions
downloading
automating_downloads
first_config
quick_start

View file

@ -37,31 +37,12 @@ instructions to the letter.
``"https://www.youtube.com/@novapbs"`` value to the URL of the channel or playlist
for this subscription.
#. Preview what ``ytdl-sub`` would do for this subscription:
Run the :ref:`'sub' sub-command <usage:sub options>` but with the ``max_downloads``
setting from ``yt-dlp`` along with the ``--dry-run`` and ``--match`` options from
``ytdl-sub`` to minimize requests and prevent actual downloads. Be sure to update the
``--match="..."`` value with the subscription name::
$ ytdl-sub --dry-run sub -o '--ytdl_options.max_downloads 3' --match="NOVA PBS"
Examine the output carefully.
#. Review the results of real downloads:
Run it again without the ``--dry-run`` option to actually download media and place
the files in your library::
$ ytdl-sub sub -o '--ytdl_options.max_downloads 3' --match="NOVA PBS"
Examine the output carefully, then examine how the downloads work in your
library. Repeat with a larger value for ``max_downloads`` and examine the output and
downloads again.
#. :ref:`Preview <guides/getting_started/downloading:preview>` and :ref:`Review
<guides/getting_started/downloading:review>` the subscription.
#. Add the rest of your subscriptions:
Repeat steps #4-7 for each of your subscriptions. Be sure to repeat the preview and
Repeat steps #3-6 for each of your subscriptions. Be sure to repeat the preview and
review steps for each subscription. In general, move slowly and carefully review
everything. It's best to catch issues early :ref:`to avoid repeating downloads and to
minimize requests <guides/getting_started/index:minimize the work to only what's

View file

@ -0,0 +1,167 @@
Subscriptions
=============
Once you understand :ref:`how ytdl-sub works
<guides/getting_started/index:architecture>`, it's time to start writing your
:doc:`../../config_reference/subscription_yaml`.
Media library paths
-------------------
Everyone's media library may use different paths so ``ytdl-sub`` can't provide
defaults. Tell ``ytdl-sub`` where to put your media using :ref:`overrides
<guides/getting_started/index:presets and subscriptions accept overrides>`:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 3-
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
music_video_directory: "/music_videos"
See the reference documentation for details about :ref:`the '__preset__:' special key
<config_reference/subscription_yaml:file preset>`.
Media library software and media types
--------------------------------------
Different media library software, such as `Jellyfin`_, `Kodi`_, Plex, or Emby, have
different requirements for where media files are placed, how those files are named, how
metadata is formatted, and more. Those software also have different requirements for
different types of media, such as shows/series, music, music videos, etc.. Use
:doc:`prebuilt presets <../../prebuilt_presets/index>` in :ref:`YAML keys
<guides/getting_started/index:subscriptions are grouped by indentation>` to tell
``ytdl-sub`` which media library software and media type to process downloaded files
for.
The actual subscription is defined in the lowest indentation level YAML keys. The
example below defines a subscription named ``NOVA PBS`` to archive downloads from the
entries in the ``https://www.youtube.com/@novapbs`` URL.
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 1,4
Jellyfin TV Show by Date:
"NOVA PBS": "https://www.youtube.com/@novapbs"
Bandcamp:
"Emily Hopkins": "https://emilyharpist.bandcamp.com/"
.. _`Jellyfin`:
https://jellyfin.org/
.. _`Kodi`:
https://kodi.tv/
Which entries
-------------
The :doc:`helper presets <../../prebuilt_presets/helpers>` also provide support for
controlling which entries are downloaded and archived. These presets are intended to be
combined with the library software and media type presets.
Combine presets using :ref:`the '.. | ...' special character
<guides/getting_started/index:subscriptions are grouped by indentation>` in the YAML
keys:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 2,6
# Only download entries whose upload date is within the past 2 months:
Kodi TV Show by Date | Only Recent:
"NOVA PBS": "https://www.youtube.com/@novapbs"
# Only download 20 entries per run:
Soundcloud Discography | Chunk Downloads:
"UKNOWY": "https://soundcloud.com/uknowymunich"
What format, quality, or resolution
-----------------------------------
The :doc:`media quality presets <../../prebuilt_presets/media_quality>` provide support
for controlling which ``yt-dlp`` media "format" to download, such as ``1080p`` video
resolution or ``320k`` audio bitrate.
Users may also group and combine presets :ref:`using the YAML hierarchy
<guides/getting_started/index:subscriptions are grouped by indentation>`. Subscriptions
merge all the presets from their ancestor YAML keys. The hierarchy indentation depth may
be as deep as needed to group your subscriptions for easy maintenance:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 3,7,12
Jellyfin TV Show by Date | Only Recent:
# Download the highest resolution available:
Max Video Quality:
"NOVA PBS": "https://www.youtube.com/@novapbs"
"National Geographic": "https://www.youtube.com/@NatGeo"
# Download the highest resolution available that is 720p or less:
Max 720p:
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
Soundcloud Discography | Chunk Downloads:
# Only download audio using the Opus codec, not MP3 or other codecs:
Max Opus Quality:
"UKNOWY": "https://soundcloud.com/uknowymunich"
Genre and rating metadata
-------------------------
Presets may also support using arbitrary values from :ref:`YAML keys prefixed with '=
...' <guides/getting_started/index:subscriptions are grouped by indentation>`. The ``=
...`` prefix may be used at any indentation depth and may also be combined with presets
and other ``= ...`` values using the ``... | ...`` special character to best group your
subscriptions.
:ref:`By convention <config_reference/scripting/static_variables:subscription_indent_i>`
in the built-in library software and media type presets, the first ``= ...`` value
specifies the genre for all descendant subscriptions. For the ``TV Show ...`` presets,
the second ``= ...`` value specifies the rating for all descendant subscriptions:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 1,3
= Kids:
Jellyfin TV Show by Date | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
"Kids Toys Play": "https://www.youtube.com/@KidsToysPlayChannel"
Soundcloud Discography:
"Foo Kids Band": "https://soundcloud.com/foo-kids-band"
Override variables for one subscription
---------------------------------------
Most variable overrides aren't actually specific to just one subscription and should be
set in :doc:`your own custom presets <./first_config>`. But use :ref:`the override mode
'~...' prefix <config_reference/subscription_yaml:override mode>` when an override is
specific to only one subscription and will never be shared with another:
.. code-block:: yaml
:caption: subscriptions.yaml
:emphasize-lines: 2-
Jellyfin TV Show by Date:
"~NOVA PBS":
url: "https://www.youtube.com/@novapbs"
tv_show_directory: "/media/Unique/Series/Path"
Next Steps
----------
Once you've defined your subscriptions, it's time to :doc:`test your configuration and
try your first download <./downloading>`.

View file

@ -38,6 +38,13 @@ For example::
$ docker compose run --rm --user="${PUID}:${PGID}" --entrypoint="ytdl-sub" ytdl-sub sub
.. note::
In `the recommended GUI image <gui image_>`_, the ``DEFAULT_WORKSPACE`` directory is
``/config/ytdl-sub-configs/`` which is used throughout the documentation and
examples. In the headless images, that directory is just ``/config/``, so substitute
that path if using a headless image.
Install with Docker Compose
---------------------------
@ -113,6 +120,22 @@ host. The following command is for the gui image:
See `the Docker reference <https://docs.docker.com/engine/reference/run/>`_ for further
details.
Environment Variables
---------------------
``ytdl-sub`` docker images support the following environment variables.
.. csv-table:: Docker Environment Variables
:header: "Name", "Supported Values", "Description"
:widths: 15, 10, 60
"``PUID``", "integer", "User ID"
"``PGID``", "integer", "Group ID"
"``TZ``", "timezone", "Optional. Timezone to use in the logs. For supported values, see this `list <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List>`_. "
"``CRON_SCHEDULE``", "cron schedule `format <https://crontab.guru/#0_*/6_*_*_*>`_", "Optional. Schedule to run the ``cron`` file in ytdl-sub's container. More info :ref:`here <guides/getting_started/automating_downloads:docker and unraid>`."
"``CRON_RUN_ON_START``", "true/false", "Optional. Whether to run the cron script on container start."
"``UPDATE_YT_DLP_ON_START``", "stable/nightly/master", "Optional. Whether to update yt-dlp to the latest configured version on container start."
For the GUI image, you can set LSIO's underlying code-server `env variables <https://docs.linuxserver.io/images/docker-code-server/#environment-variables-e>`_ as well."
Configuration
-------------

View file

@ -1,3 +1,6 @@
==============
Helper Presets
==============
@ -176,3 +179,59 @@ following overrides:
enable_resolution_assert: false
# Change the resolution below which to assume downloading is throttled:
resolution_assert_height_gte: 720
.. _resolution assert handling:
Handling Low Quality Videos
~~~~~~~~~~~~~~~~~~~~~~~~~~~
A side effect from throttle protection's resolution assert is, if the only resolution available is 360p or lower, it will
error. You can either disable resolution assert entirely (see above), or ignore specific titles in the subscription
using the ``resolution_assert_ignore_titles`` variable. Add a subset of the title (case-sensitive) as a list entry
to your subscription, like so:
.. code-block:: yaml
# use tilda mode to set override variables to the subscription
"~My Subscription":
url: "https://youtube.com/@channel"
resolution_assert_ignore_titles:
- "This 360p Video Title"
_url
----
All prebuilt presets share the same internal ``_multi_url`` preset which comes equipped with
a few available customizations.
Sibling Metadata
~~~~~~~~~~~~~~~~
*Sibling* refers to any entry within the same *playlist*. For channel downloads, this would
imply **every** video that gets downloaded since yt-dlp treats the channel as the *playlist*.
Setting the variable ``include_sibling_metadata`` will include all sibling metadata within
each individual entry's metadata. This is used specifically for music presets. When downloading
a playlist as an album for example, it will take the max year amongst all the other sibling's metadata
to have a consistent album year that can be used in file or directory naming.
Webpage URL
~~~~~~~~~~~
``ytdl-sub`` performs downloads in two stages.
1. Metadata scrape from the original URL
2. Individual entry downloads
For step 2, ``ytdl-sub`` will use the ``webpage_url`` variable by default for the input URL to yt-dlp.
This can be modified in case it's not working as expected by using the variable ``modified_webpage_url``.
Example:
.. code-block:: yaml
:caption:
Removes yt-dlp smuggle data from the URL
overrides:
modified_webpage_url: >-
{ %regex_sub("#__youtubedl_smuggle=.*", "", webpage_url) }

View file

@ -160,6 +160,8 @@ Two main use cases of a collection are:
2. Organize one or more YouTube channels/playlists, where each season represents a
separate channel/playlist.
Today, ytdl-supports up to 40 seasons with 11 URLs per season.
Example
~~~~~~~
@ -185,6 +187,29 @@ Must define ``tv_show_directory``. Available presets:
s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
Other notable features include:
* TV show poster info is pulled from the first URL in s01.
* Duplicate videos in different URLs (channel /videos vs playlist) will not download twice.
* The video will attributed to the season with the highest number.
* Individual seasons support both single and multi URL.
* s00 is supported for specials.
.. code-block:: yaml
"~Beyond the Guitar":
s00_name: "Specials"
s00_url:
- "https://www.youtube.com/watch?v=vXzguOdulAI"
- "https://www.youtube.com/watch?v=IGwYDvaGAz0"
s01_name: "Videos"
s01_url:
- "https://www.youtube.com/c/BeyondTheGuitar"
- "https://www.youtube.com/@BeyondTheGuitarAcademy"
s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
Advanced Usage
~~~~~~~~~~~~~~

View file

@ -19,14 +19,16 @@ example ``$ ytdl-sub --dry-run sub ...``:
-h, --help show this help message and exit
-v, --version show program's version number and exit
-c CONFIGPATH, --config CONFIGPATH
path to the config yaml, uses ./config.yaml if not provided
path to the config yaml, uses config.yaml if not provided
-d, --dry-run preview what a download would output, does not perform any video downloads or writes to output directories
-l quiet|info|verbose|debug, --log-level quiet|info|verbose|debug
level of logs to print to console, defaults to info
level of logs to print to console, defaults to verbose
-t TRANSACTIONPATH, --transaction-log TRANSACTIONPATH
path to store the transaction log output of all files added, modified, deleted
-st, --suppress-transaction-log
do not output transaction logs to console or file
-nc, --suppress-colors
do not use colors in ytdl-sub output
-m MATCH [MATCH ...], --match MATCH [MATCH ...]
match subscription names to one or more substrings, and only run those subscriptions
@ -35,7 +37,7 @@ Subscriptions Options
---------------------
Download all subscriptions specified in each :doc:`subscriptions file
<./guides/getting_started/first_sub>`.
<./guides/getting_started/subscriptions>`.
.. code-block::
@ -58,7 +60,7 @@ Download Options
----------------
Download a single subscription in the form of CLI arguments instead of from :doc:`a
subscriptions file <./guides/getting_started/first_sub>`:
subscriptions file <./guides/getting_started/subscriptions>`:
.. code-block::
@ -85,8 +87,7 @@ Using the command:
--overrides.tv_show_name "Rick A" \
--overrides.url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
See how to shorten commands using `download aliases
<https://ytdl-sub.readthedocs.io/en/latest/config_reference/config_yaml.html#ytdl_sub.config.config_validator.ConfigOptions.dl_aliases>`_.
See how to shorten commands using :ref:`download aliases <config_reference/config_yaml:dl_aliases>`.
View Options
@ -103,3 +104,43 @@ Preview the source variables for a given URL. Helpful to create new subscription
-sc, --split-chapters
View source variables after splitting by chapters
CLI to SUB Options
------------------
Convert yt-dlp cli arguments to ytdl-sub `ytdl_options` arguments.
.. code-block::
ytdl-sub cli-to-sub [YT-DLP ARGS]
Inspect
-------
Inspect a single subscription's underlying preset representation.
This can be utilized for numerous purposes including:
* Ensuring your custom preset is getting applied correctly.
* Figuring out which variables set things like file names, metadata, etc.
* Understanding how subscription syntax translates to preset representation.
Usage:
.. code-block:: bash
ytdl-sub inspect --match "Game Chops" --mock 'title=Lets Play' examples/music_subscriptions.yaml
.. code-block:: text
:caption: Additional Options
-l 0,1,2,3, --level 0,1,2,3
level of inspection to perform:
0 - original present the subscription as-is
1 - fill fill in defined values
2 - resolve resolve all possible variables (default)
3 - internal resolve all variables to their internal representation
-m MATCH [MATCH ...], --match MATCH [MATCH ...]
match subscription names to one or more substrings, and only run those subscriptions
-o DL_OVERRIDE, --dl-override DL_OVERRIDE
override all subscription config values using `dl` syntax, i.e. --dl-override='--ytdl_options.max_downloads 3'
-k VAR=VALUE, --mock VAR=VALUE
ability to mock one or more variable values, i.e. --mock 'title=Lets Play'

View file

@ -69,7 +69,7 @@ presets:
# ytdl_options lets you pass any arg into yt-dlp's Python API
ytdl_options:
# Set the cookie file
# cookiefile: "/config/youtube_cookies.txt"
# cookiefile: "/config/ytdl-sub-configs/youtube_cookies.txt"
# For YouTube, get English metadata if multiple languages are present
extractor_args:

View file

@ -24,6 +24,6 @@ TV Show Only Recent:
# to set only for that subscriptions
"~BBC News":
url: "https://www.youtube.com/@BBCNews" # use url2, url3, ... for multi-url in this form
date_range: "2weeks"
only_recent_date_range: "2weeks"
"Frontline PBS": "https://www.youtube.com/@frontline"
"Whitehouse": "https://www.bitchute.com/channel/zWsYVmCOu4JA/" # Supports non-YT sites

View file

@ -15,7 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]
dependencies = [
"yt-dlp[default]==2025.8.22",
"yt-dlp[default]==2026.6.9",
"colorama~=0.4",
"mergedeep~=1.3",
"mediafile~=0.12",
@ -43,16 +43,15 @@ where = ["src"]
[project.optional-dependencies]
test = [
"coverage[toml]>=6.3,<8.0",
"pytest>=7.2,<9.0",
"pytest-rerunfailures>=14,<16",
"pytest>=7.2,<10.0",
"pytest-rerunfailures>=14,<17",
]
lint = [
"black==24.10.0",
"isort==6.0.1",
"pylint==3.3.8",
"pylint==4.0.5",
"ruff==0.15.16",
]
docs = [
"sphinx>=7,<9",
"sphinx>=7,<10",
"sphinx-rtd-theme>=2,<4",
"sphinx-book-theme~=1.0",
"sphinx-copybutton~=0.5",
@ -66,15 +65,6 @@ build = [
[project.scripts]
ytdl-sub = "ytdl_sub.main:main"
[tool.isort]
profile = "black"
line_length = 100
force_single_line = true
[tool.black]
line_length = 100
target-version = ["py310"]
[tool.pylint.MASTER]
disable = [
"C0115", # Missing class docstring
@ -100,3 +90,27 @@ include = [
exclude_also = [
"raise UNREACHABLE.*",
]
# ruff
[tool.ruff]
line-length = 100
indent-width = 4
# Assume Python 3.10
target-version = "py310"
[tool.ruff.lint]
extend-select = ["I"]
[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

View file

@ -1,28 +1,31 @@
import gc
import os
import random
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict
from typing import List
from typing import Optional
from typing import Dict, List, Optional
from yt_dlp.utils import sanitize_filename
from ytdl_sub.cli.output_summary import output_summary
from ytdl_sub.cli.output_transaction_log import _maybe_validate_transaction_log_file
from ytdl_sub.cli.output_transaction_log import output_transaction_log
from ytdl_sub.cli.output_transaction_log import (
_maybe_validate_transaction_log_file,
output_transaction_log,
)
from ytdl_sub.cli.parsers.cli_to_sub import print_cli_to_sub
from ytdl_sub.cli.parsers.dl import DownloadArgsParser
from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME
from ytdl_sub.cli.parsers.main import parser
from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME, parser
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.validators.variable_validation import ResolutionLevel
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled, ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_lock import working_directory_lock
from ytdl_sub.utils.logger import Logger
# pylint: disable=too-many-branches
logger = Logger.get()
# View is a command to run a simple dry-run on a URL using the `_view` preset.
@ -74,6 +77,7 @@ def _download_subscriptions_from_yaml_files(
subscription_override_dict: Dict,
update_with_info_json: bool,
dry_run: bool,
shuffle: bool,
) -> List[Subscription]:
"""
Downloads all subscriptions from one or many subscription yaml files.
@ -90,6 +94,8 @@ def _download_subscriptions_from_yaml_files(
Whether to actually download or update using existing info json
dry_run
Whether to dry run or not
shuffle
Whether to shuffle the subscription download order
Returns
-------
@ -111,6 +117,10 @@ def _download_subscriptions_from_yaml_files(
subscription_override_dict=subscription_override_dict,
)
if shuffle:
logger.info("Shuffling subscriptions")
random.shuffle(subscriptions)
for subscription in subscriptions:
with subscription.exception_handling():
logger.info(
@ -195,6 +205,44 @@ def _view_url_from_cli(config: ConfigFile, url: str, split_chapters: bool) -> Su
return subscription
def _parse_inspect_mocks(mocks: Optional[List[str]]) -> Dict[str, str]:
out: Dict[str, str] = {}
for mock in mocks or []:
spl = mock.split("=", 1)
if len(spl) == 1:
raise ValidationException("inspect mock must be in the form of VAR=VALUE")
out[spl[0].strip()] = spl[1]
return out
def _inspect(
config: ConfigFile,
subscription_paths: List[str],
subscription_matches: List[str],
subscription_override_dict: Dict,
inspection_level: int,
mocks: Dict[str, str],
) -> None:
subscriptions: List[Subscription] = []
for path in subscription_paths:
subscriptions += Subscription.from_file_path(
config=config,
subscription_path=path,
subscription_matches=subscription_matches,
subscription_override_dict=subscription_override_dict,
)
if len(subscriptions) > 1:
print(
"inspect can only inspect a single subscription. Use --match to filter for a single one"
)
return
print(subscriptions[0].resolved_yaml(resolution_level=inspection_level, mocks=mocks))
def main() -> List[Subscription]:
"""
Entrypoint for ytdl-sub, without the error handling
@ -206,6 +254,10 @@ def main() -> List[Subscription]:
args, extra_args = parser.parse_known_args()
if args.subparser == "cli-to-sub":
print_cli_to_sub(args=extra_args)
return []
# Load the config
if args.config:
config = ConfigFile.from_file_path(args.config)
@ -217,6 +269,23 @@ def main() -> List[Subscription]:
subscriptions: List[Subscription] = []
if args.subparser == "inspect":
subscription_override_dict = {}
if args.dl_override:
subscription_override_dict = DownloadArgsParser.from_dl_override(
override=args.dl_override, config=config
).to_subscription_dict()
_inspect(
config=config,
subscription_paths=args.subscription_paths,
subscription_matches=args.match,
subscription_override_dict=subscription_override_dict,
inspection_level=ResolutionLevel.level_number(args.inspection_level),
mocks=_parse_inspect_mocks(args.mock),
)
return []
# If transaction log file is specified, make sure we can open it
_maybe_validate_transaction_log_file(transaction_log_file_path=args.transaction_log)
@ -248,6 +317,7 @@ def main() -> List[Subscription]:
subscription_override_dict=subscription_override_dict,
update_with_info_json=args.update_with_info_json,
dry_run=args.dry_run,
shuffle=args.shuffle,
)
# One-off download
@ -263,7 +333,7 @@ def main() -> List[Subscription]:
_view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters)
)
else:
raise ValidationException("Must provide one of the commands: sub, dl, view")
raise ValidationException("Must provide one of the commands: sub, dl, view, cli-to-sub")
if not args.suppress_transaction_log:
output_transaction_log(

View file

@ -1,5 +1,4 @@
from typing import List
from typing import Optional
from typing import List, Optional
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException

View file

@ -0,0 +1,63 @@
from typing import List
import yt_dlp
import yt_dlp.options
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.yaml import dump_yaml
logger = Logger.get()
# pylint: disable=missing-function-docstring
##############################################################
# --- BEGIN ----
# Copy of https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py
create_parser = yt_dlp.options.create_parser
def parse_patched_options(opts):
patched_parser = create_parser()
patched_parser.defaults.update(
{
"ignoreerrors": False,
"retries": 0,
"fragment_retries": 0,
"extract_flat": False,
"concat_playlist": "never",
"update_self": False,
}
)
yt_dlp.options.create_parser = lambda: patched_parser
try:
return yt_dlp.parse_options(opts)
finally:
yt_dlp.options.create_parser = create_parser
default_opts = parse_patched_options([]).ydl_opts
def cli_to_api(opts, cli_defaults=False):
opts = (yt_dlp.parse_options if cli_defaults else parse_patched_options)(opts).ydl_opts
diff = {k: v for k, v in opts.items() if default_opts[k] != v}
if "postprocessors" in diff:
diff["postprocessors"] = [
pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]
]
return diff
# --- END ----
##############################################################
def print_cli_to_sub(args: List[str]) -> None:
api_args = cli_to_api(args)
if not api_args:
logger.info("Does not resolve to any yt-dlp args")
return
print(dump_yaml({"ytdl_options": api_args}))

View file

@ -1,10 +1,7 @@
import hashlib
import re
import shlex
from typing import Any
from typing import Dict
from typing import List
from typing import Tuple
from typing import Any, Dict, List, Tuple
from mergedeep import mergedeep

View file

@ -1,6 +1,6 @@
import argparse
import dataclasses
from typing import List
from typing import Dict, List
from ytdl_sub import __local_version__
from ytdl_sub.utils.logger import LoggerLevels
@ -172,6 +172,10 @@ class SubArguments:
short="-o",
long="--dl-override",
)
SHUFFLE = CLIArgument(
short="-sh",
long="--shuffle",
)
subscription_parser = subparsers.add_parser("sub")
@ -197,6 +201,13 @@ subscription_parser.add_argument(
help="override all subscription config values using `dl` syntax, "
"i.e. --dl-override='--ytdl_options.max_downloads 3'",
)
subscription_parser.add_argument(
SubArguments.SHUFFLE.short,
SubArguments.SHUFFLE.long,
action="store_true",
help="shuffle subscription order when downloading",
default=False,
)
###################################################################################################
# DOWNLOAD PARSER
@ -221,3 +232,81 @@ view_parser.add_argument(
help="View source variables after splitting by chapters",
)
view_parser.add_argument("url", help="URL to view source variables for")
###################################################################################################
# CLI-TO-SUB PARSER
cli_to_sub_parser = subparsers.add_parser("cli-to-sub")
###################################################################################################
# INSPECT PARSER
class InspectArguments:
LEVEL = CLIArgument(
short="-l",
long="--level",
)
LevelChoices: Dict[str, str] = {
"0": "original",
"1": "fill",
"2": "resolve",
"3": "internal",
}
MOCK = CLIArgument(
short="-k",
long="--mock",
)
inspect_parser = subparsers.add_parser("inspect", formatter_class=argparse.RawTextHelpFormatter)
inspect_parser.add_argument(
InspectArguments.LEVEL.short,
InspectArguments.LEVEL.long,
metavar=",".join(str(i) for i in range(4)),
type=str,
help="""level of inspection to perform:
0 - original present the subscription as-is
1 - fill fill in defined values
2 - resolve resolve all possible variables (default)
3 - internal resolve all variables to their internal representation
""",
default="resolve",
choices=list(InspectArguments.LevelChoices.keys())
+ list(InspectArguments.LevelChoices.values()),
dest="inspection_level",
)
inspect_parser.add_argument(
MainArguments.MATCH.short,
MainArguments.MATCH.long,
dest="match",
nargs="+",
action="extend",
type=str,
help="match subscription names to one or more substrings, and only run those subscriptions",
default=[],
)
inspect_parser.add_argument(
"subscription_paths",
metavar="SUBPATH",
nargs="*",
help="path to subscription files, uses subscriptions.yaml if not provided",
default=["subscriptions.yaml"],
)
inspect_parser.add_argument(
SubArguments.OVERRIDE.short,
SubArguments.OVERRIDE.long,
type=str,
help="override all subscription config values using `dl` syntax, "
"i.e. --dl-override='--ytdl_options.max_downloads 3'",
)
inspect_parser.add_argument(
InspectArguments.MOCK.short,
InspectArguments.MOCK.long,
metavar="VAR=VALUE",
action="append",
help="ability to mock one or more variable values, i.e. --mock 'title=Lets Play'",
)

View file

@ -1,6 +1,5 @@
import os
from typing import Any
from typing import Dict
from typing import Any, Dict
from ytdl_sub.config.config_validator import ConfigValidator
from ytdl_sub.config.preset import Preset

View file

@ -1,27 +1,34 @@
import os
import posixpath
from typing import Any
from typing import Dict
from typing import Optional
from typing import Any, Dict, Optional
from mergedeep import mergedeep
from yt_dlp.utils import datetime_from_str
from ytdl_sub.config.defaults import DEFAULT_FFMPEG_PATH
from ytdl_sub.config.defaults import DEFAULT_FFPROBE_PATH
from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
from ytdl_sub.config.defaults import (
DEFAULT_FFMPEG_PATH,
DEFAULT_FFPROBE_PATH,
DEFAULT_LOCK_DIRECTORY,
MAX_FILE_NAME_BYTES,
)
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
from ytdl_sub.validators.file_path_validators import FFmpegFileValidator
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import FFmpegFileValidator, FFprobeFileValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import IntValidator
from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import StringValidator
from ytdl_sub.validators.validators import (
BoolValidator,
IntValidator,
LiteralDictValidator,
StringValidator,
)
class ExperimentalValidator(StrictDictValidator):
"""
Experimental flags reside under the ``experimental`` key.
"""
_optional_keys = {"enable_update_with_info_json"}
_allow_extra_keys = True
@ -43,6 +50,11 @@ class ExperimentalValidator(StrictDictValidator):
class PersistLogsValidator(StrictDictValidator):
"""
By default, no logs are persisted. Specifying this key will enable persisted logs. The following
options are available.
"""
_required_keys = {"logs_directory"}
_optional_keys = {"keep_logs_after", "keep_successful_logs"}
@ -67,8 +79,8 @@ class PersistLogsValidator(StrictDictValidator):
@property
def logs_directory(self) -> str:
"""
Write log files to this directory with names like
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. (required)
Required field. Write log files to this directory with names like
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``.
"""
return self._logs_directory.value
@ -93,15 +105,54 @@ class PersistLogsValidator(StrictDictValidator):
@property
def keep_successful_logs(self) -> bool:
"""
If the ``persist_logs:`` key is in the configuration, then ``ytdl-sub`` *always*
writes log files for the subscription both for successful downloads and when it
encounters an error while downloading. When this key is ``False``, only write
log files for errors. (default ``True``)
Defaults to ``True``. When this key is ``False``, only write log files for failed
subscriptions.
"""
return self._keep_successful_logs.value
class ConfigOptions(StrictDictValidator):
"""
ytdl-sub is configured using a ``config.yaml`` file.
The ``config.yaml`` is made up of two sections:
.. code-block:: yaml
configuration:
presets:
Note for Windows users, paths can be represented with ``C:/forward/slashes/like/linux``.
If you prefer to use a Windows backslash, note that it must have
``C:\\\\double\\\\bashslash\\\\paths`` in order to escape the backslash character. This is due
to it being a YAML escape character.
.. code-block:: yaml
configuration:
dl_aliases:
mv: "--preset music_video"
u: "--download.url"
experimental:
enable_update_with_info_json: True
ffmpeg_path: "/usr/bin/ffmpeg"
ffprobe_path: "/usr/bin/ffprobe"
file_name_max_bytes: 255
lock_directory: "/tmp"
persist_logs:
keep_successful_logs: True
logs_directory: "/var/log/ytdl-sub-logs"
umask: "022"
working_directory: ".ytdl-sub-working-directory"
"""
_optional_keys = {
"working_directory",
"umask",
@ -147,11 +198,18 @@ class ConfigOptions(StrictDictValidator):
key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
)
if not FileHandler.is_path_writable(self.working_directory):
raise SubscriptionPermissionError(
"ytdl-sub does not have permissions to the working directory: "
f"{self.working_directory}"
)
@property
def working_directory(self) -> str:
"""
The directory to temporarily store downloaded files before moving them into their final
directory. (default ``./.ytdl-sub-working-directory``)
directory. Defaults to ``.ytdl-sub-working-directory``, created in the same directory
that ytdl-sub is invoked from.
"""
# Expands tildas to actual paths, use native os sep
return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
@ -159,7 +217,7 @@ class ConfigOptions(StrictDictValidator):
@property
def umask(self) -> Optional[str]:
"""
Umask in octal format to apply to every created file. (default ``022``)
Umask in octal format to apply to every created file. Defaults to ``022``.
"""
return self._umask.value
@ -168,7 +226,7 @@ class ConfigOptions(StrictDictValidator):
"""
.. _dl_aliases:
Alias definitions to shorten ``ytdl-sub dl`` arguments. For example,
Alias definitions to shorten :ref:`dl arguments <usage:Download Options>`. For example,
.. code-block:: yaml
@ -220,23 +278,23 @@ class ConfigOptions(StrictDictValidator):
The directory to temporarily store file locks, which prevents multiple instances
of ``ytdl-sub`` from running. Note that file locks do not work on
network-mounted directories. Ensure that this directory resides on the host
machine. (default ``/tmp``)
machine. Defaults to ``/tmp``.
"""
return self._lock_directory.value
@property
def ffmpeg_path(self) -> str:
"""
Path to ffmpeg executable. (default ``/usr/bin/ffmpeg`` for Linux,
``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows)
Path to ffmpeg executable. Defaults to ``/usr/bin/ffmpeg`` for Linux,
``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows.
"""
return self._ffmpeg_path.value
@property
def ffprobe_path(self) -> str:
"""
Path to ffprobe executable. (default ``/usr/bin/ffprobe`` for Linux,
``./ffprobe.exe`` in the same directory as ytdl-sub for Windows)
Path to ffprobe executable. Defaults to ``/usr/bin/ffprobe`` for Linux,
``./ffprobe.exe`` in the same directory as ytdl-sub for Windows.
"""
return self._ffprobe_path.value

View file

@ -2,6 +2,8 @@ import os
from ytdl_sub.utils.system import IS_WINDOWS
# pylint: disable=invalid-name
def _existing_path(*paths: str) -> str:
"""
@ -22,7 +24,7 @@ if IS_WINDOWS:
MAX_FILE_NAME_BYTES = 255
else:
DEFAULT_LOCK_DIRECTORY = "/tmp"
DEFAULT_LOCK_DIRECTORY = ".ytdl-sub-lock"
DEFAULT_FFMPEG_PATH = os.getenv(
"YTDL_SUB_FFMPEG_PATH", _existing_path("/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg")
)

View file

@ -1,26 +1,30 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
import mergedeep
from typing import Any, Dict, Iterable, Optional, Set, Type, TypeVar
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES
from ytdl_sub.entries.variables.override_variables import OverrideHelpers
from ytdl_sub.entries.variables.override_variables import (
REQUIRED_OVERRIDE_VARIABLE_NAMES,
OverrideHelpers,
)
from ytdl_sub.script.parser import parse
from ytdl_sub.script.script import Script
from ytdl_sub.script.types.resolvable import Resolvable
from ytdl_sub.script.types.function import BuiltInFunction
from ytdl_sub.script.types.resolvable import Resolvable, String
from ytdl_sub.script.types.syntax_tree import SyntaxTree
from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
from ytdl_sub.utils.exceptions import InvalidVariableNameException
from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.exceptions import (
InvalidVariableNameException,
StringFormattingException,
ValidationException,
)
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.utils.scriptable import Scriptable
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import (
StringFormatterValidator,
UnstructuredDictFormatterValidator,
)
ExpectedT = TypeVar("ExpectedT")
class Overrides(UnstructuredDictFormatterValidator, Scriptable):
@ -88,6 +92,24 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
return True
def ensure_variable_names_not_a_plugin(self, plugin_names: Iterable[str]) -> None:
"""
Throws an error if an override variable or function has the same name as a
preset key. This is to avoid confusion when accidentally defining things in
overrides that are meant to be in the preset.
"""
for name in self.keys:
if name.startswith("%"):
name = name[1:]
if name in plugin_names:
raise self._validation_exception(
f"Override variable with name {name} cannot be used since it is"
" the name of a plugin. Perhaps you meant to define it as a plugin? If so,"
" indent it left to make it at the same level as overrides.",
exception_class=InvalidVariableNameException,
)
def ensure_variable_name_valid(self, name: str) -> None:
"""
Ensures the variable name does not collide with any entry variables or built-in functions.
@ -115,29 +137,35 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
)
def initial_variables(
self, unresolved_variables: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
self, unresolved_variables: Optional[Dict[str, SyntaxTree]] = None
) -> Dict[str, SyntaxTree]:
"""
Returns
-------
Variables and format strings for all Override variables + additional variables (Optional)
"""
initial_variables: Dict[str, str] = {}
mergedeep.merge(
initial_variables,
self.dict_with_format_strings,
unresolved_variables if unresolved_variables else {},
)
return ScriptUtils.add_sanitized_variables(initial_variables)
initial_variables: Dict[str, SyntaxTree] = self.dict_with_parsed_format_strings
if unresolved_variables:
initial_variables |= unresolved_variables
return ScriptUtils.add_sanitized_parsed_variables(initial_variables)
def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides":
"""
Initialize the override script with any unresolved variables
"""
self.script.add(
self.script.add_parsed(
self.initial_variables(
unresolved_variables={
var_name: f"{{%throw('Plugin variable {var_name} has not been created yet')}}"
var_name: SyntaxTree(
ast=[
BuiltInFunction(
name="throw",
args=[
String(f"Plugin variable {var_name} has not been created yet")
],
)
]
)
for var_name in unresolved_variables
}
)
@ -158,10 +186,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
script = entry.script
unresolvable = entry.unresolvable
# Update the script internally so long as we are not supplying overrides
# that could alter the script with one-off state
update = function_overrides is None
try:
return script.resolve_once(
dict({"tmp_var": formatter.format_string}, **(function_overrides or {})),
unresolvable=unresolvable,
update=update,
)["tmp_var"]
except ScriptVariableNotResolved as exc:
raise StringFormattingException(
@ -176,7 +209,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
formatter: StringFormatterValidator,
entry: Optional[Entry] = None,
function_overrides: Optional[Dict[str, str]] = None,
) -> str:
expected_type: Type[ExpectedT] = str,
) -> ExpectedT:
"""
Parameters
----------
@ -186,6 +220,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
Optional. Entry to add source variables to the formatter
function_overrides
Optional. Explicit values to override the overrides themselves and source variables
expected_type
The expected type that should return. Defaults to string.
Returns
-------
@ -196,37 +232,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
StringFormattingException
If the formatter that is trying to be resolved cannot
"""
return formatter.post_process(
str(
self._apply_to_resolvable(
formatter=formatter, entry=entry, function_overrides=function_overrides
)
)
out = formatter.post_process(
self._apply_to_resolvable(
formatter=formatter, entry=entry, function_overrides=function_overrides
).native
)
def apply_overrides_formatter_to_native(
self,
formatter: OverridesStringFormatterValidator,
) -> Any:
"""
Parameters
----------
formatter
Overrides formatter to apply
if not isinstance(out, expected_type):
raise StringFormattingException(
f"Expected type {expected_type.__name__}, but received '{out.__class__.__name__}'"
)
Returns
-------
The native python form of the resolved variable
"""
return self._apply_to_resolvable(
formatter=formatter, entry=None, function_overrides=None
).native
def evaluate_boolean(
self, formatter: StringFormatterValidator, entry: Optional[Entry] = None
) -> bool:
"""
Apply a formatter, and evaluate it to a boolean
"""
output = self.apply_formatter(formatter=formatter, entry=entry)
return ScriptUtils.bool_formatter_output(output)
return out

View file

@ -1,20 +1,15 @@
from abc import ABC
from abc import abstractmethod
from abc import ABC, abstractmethod
from functools import cached_property
from typing import Dict
from typing import Generic
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import Dict, Generic, List, Optional, Tuple, Type
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.validators.options import OptionsValidatorT
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.config.validators.options import OptionsValidatorT, ToggleableOptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
from ytdl_sub.ytdl_additions.enhanced_download_archive import (
DownloadArchiver,
EnhancedDownloadArchive,
)
# pylint: disable=unused-argument
@ -48,7 +43,7 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
Returns True if enabled, False if disabled.
"""
if isinstance(self.plugin_options, ToggleableOptionsDictValidator):
return self.overrides.evaluate_boolean(self.plugin_options.enable)
return self.overrides.apply_formatter(self.plugin_options.enable, expected_type=bool)
return True
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
@ -119,6 +114,17 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
"""
return None
def post_completion_entry(self, file_metadata: FileMetadata) -> None:
"""
After the entry file is moved to its final location, run this hook.
Parameters
----------
file_metadata
Metadata about the completed entry's file download
"""
return None
def post_process_subscription(self):
"""
After all downloaded files have been post-processed, apply a subscription-wide post process

View file

@ -1,15 +1,12 @@
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import Dict, List, Optional, Tuple, Type
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin import SplitPlugin
from ytdl_sub.config.plugin.plugin import Plugin, SplitPlugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.downloaders.url.downloader import UrlDownloaderCollectionVariablePlugin
from ytdl_sub.downloaders.url.downloader import UrlDownloaderThumbnailPlugin
from ytdl_sub.downloaders.url.downloader import (
UrlDownloaderCollectionVariablePlugin,
UrlDownloaderThumbnailPlugin,
)
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
from ytdl_sub.plugins.chapters import ChaptersPlugin
from ytdl_sub.plugins.date_range import DateRangePlugin
@ -92,6 +89,12 @@ class PluginMapping:
EmbedThumbnailPlugin,
]
_ORDER_POST_COMPLETION: List[Type[Plugin]] = [
# Throttle protection should always be last
# to not sleep over other logic
ThrottleProtectionPlugin
]
@classmethod
def _order_by(
cls, plugin_types: List[Type[Plugin]], operation: PluginOperation
@ -102,6 +105,8 @@ class PluginMapping:
ordering = cls._ORDER_MODIFY_ENTRY
elif operation == PluginOperation.POST_PROCESS:
ordering = cls._ORDER_POST_PROCESS
elif operation == PluginOperation.POST_COMPLETION:
ordering = cls._ORDER_POST_COMPLETION
else:
raise ValueError("PluginOperation does not support ordering")

View file

@ -6,3 +6,4 @@ class PluginOperation(Enum):
MODIFY_ENTRY_METADATA = 0
MODIFY_ENTRY = 1
POST_PROCESS = 2
POST_COMPLETION = 3

View file

@ -1,11 +1,7 @@
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import Iterable, List, Optional, Set, Tuple, Type
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.config.validators.options import OptionsValidatorT
from ytdl_sub.config.validators.options import OptionsValidator, OptionsValidatorT
class PresetPlugins:
@ -44,3 +40,34 @@ class PresetPlugins:
if plugin_type in plugin_option_types:
return self.plugin_options[plugin_option_types.index(plugin_type)]
return None
def get_added_and_modified_variables(
self, additional_options: List[OptionsValidator]
) -> Iterable[Tuple[OptionsValidator, Set[str], Set[str]]]:
"""
Iterates and returns the plugin options, added variables, modified variables
"""
for plugin_options in self.plugin_options + additional_options:
added_variables: Set[str] = set()
modified_variables: Set[str] = set()
for plugin_added_variables in plugin_options.added_variables(
unresolved_variables=set(),
).values():
added_variables |= set(plugin_added_variables)
for plugin_modified_variables in plugin_options.modified_variables().values():
modified_variables = plugin_modified_variables
yield plugin_options, added_variables, modified_variables
def get_all_variables(self, additional_options: List[OptionsValidator]) -> Set[str]:
"""
Returns set of all added and modified variables' names.
"""
all_variables: Set[str] = set()
for _, added, modified in self.get_added_and_modified_variables(additional_options):
all_variables.update(added)
all_variables.update(modified)
return all_variables

View file

@ -1,7 +1,5 @@
import copy
from typing import Any
from typing import Dict
from typing import List
from typing import Any, Dict, List, Set
from mergedeep import mergedeep
@ -9,18 +7,14 @@ from ytdl_sub.config.config_validator import ConfigValidator
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
from ytdl_sub.config.plugin.preset_plugins import PresetPlugins
from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.config.validators.variable_validation import VariableValidation
from ytdl_sub.config.preset_options import OutputOptions, YTDLOptions
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES, PUBLISHED_PRESET_NAMES
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.yaml import dump_yaml
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import StringListValidator
from ytdl_sub.validators.validators import validation_exception
from ytdl_sub.validators.validators import StringListValidator, validation_exception
PRESET_KEYS = {
"preset",
@ -55,6 +49,12 @@ class _PresetShell(StrictDictValidator):
class Preset(_PresetShell):
"""
Custom presets are defined in this section. Refer to the
:ref:`Getting Started Guide<guides/getting_started/first_config:Basic Configuration>`
on how to configure.
"""
@classmethod
def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None:
"""
@ -172,6 +172,37 @@ class Preset(_PresetShell):
mergedeep.merge({}, *reversed(presets_to_merge), strategy=mergedeep.Strategy.ADDITIVE)
)
def _initialize_overrides_script(self, overrides: Overrides) -> Overrides:
"""
Do some gymnastics to initialize the Overrides script.
"""
unresolved_variables: Set[str] = set()
for (
plugin_options,
added_variables,
modified_variables,
) in self.plugins.get_added_and_modified_variables(
additional_options=[self.downloader_options, self.output_options]
):
for added_variable in added_variables:
if not overrides.ensure_added_plugin_variable_valid(added_variable=added_variable):
# pylint: disable=protected-access
raise plugin_options._validation_exception(
f"Cannot use the variable name {added_variable} because it exists as a"
" built-in ytdl-sub variable name."
)
# pylint: enable=protected-access
# Set unresolved as variables that are added but do not exist as
# entry/override variables since they are created at run-time
unresolved_variables |= added_variables | modified_variables
# Initialize overrides with unresolved variables + modified variables to throw an error.
# For modified variables, this is to prevent a resolve(update=True) to setting any
# dependencies until it has been explicitly added
return overrides.initialize_script(unresolved_variables=unresolved_variables)
def __init__(self, config: ConfigValidator, name: str, value: Any):
super().__init__(name=name, value=value)
@ -192,13 +223,10 @@ class Preset(_PresetShell):
)
self.plugins: PresetPlugins = self._validate_and_get_plugins()
self.overrides = self._validate_key(key="overrides", validator=Overrides, default={})
VariableValidation(
downloader_options=self.downloader_options,
output_options=self.output_options,
plugins=self.plugins,
).initialize_preset_overrides(overrides=self.overrides).ensure_proper_usage()
self.overrides = self._initialize_overrides_script(
overrides=self._validate_key(key="overrides", validator=Overrides, default={})
)
self.overrides.ensure_variable_names_not_a_plugin(plugin_names=PRESET_KEYS)
@property
def name(self) -> str:
@ -227,11 +255,18 @@ class Preset(_PresetShell):
"""
return cls(config=config, name=preset_name, value=preset_dict)
@property
def yaml(self) -> str:
def yaml(self, subscription_only: bool) -> str:
"""
Parameters
----------
subscription_only:
Only include the subscription contents, not the surrounding boiler-plate.
Returns
-------
Preset in YAML format
"""
if subscription_only:
return dump_yaml(self._value)
return dump_yaml({"presets": {self._name: self._value}})

View file

@ -1,21 +1,22 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
from typing import Any, Dict, Optional, Set
from ytdl_sub.config.defaults import DEFAULT_DOWNLOAD_ARCHIVE_NAME
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import OptionsDictValidator
from ytdl_sub.entries.script.variable_definitions import VARIABLES as v
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.utils.exceptions import SubscriptionPermissionError, ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import (
OverridesStringFormatterFilePathValidator,
StringFormatterFileNameValidator,
)
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesIntegerFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StandardizedDateValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import (
OverridesIntegerFormatterValidator,
OverridesStringFormatterValidator,
StandardizedDateValidator,
StringFormatterValidator,
UnstructuredOverridesDictFormatterValidator,
)
from ytdl_sub.validators.validators import BoolValidator
@ -57,12 +58,24 @@ class YTDLOptions(UnstructuredOverridesDictFormatterValidator):
def to_native_dict(self, overrides: Overrides) -> Dict:
"""
Materializes the entire ytdl-options dict from OverrideStringFormatters into
native python
native python.
"""
return {
key: overrides.apply_overrides_formatter_to_native(val)
out = {
key: overrides.apply_formatter(val, expected_type=object)
for key, val in self.dict.items()
}
if "cookiefile" in out:
if not FileHandler.is_file_existent(out["cookiefile"]):
raise ValidationException(
f"Specified cookiefile {out['cookiefile']} but it does not exist as a file."
)
if not FileHandler.is_file_readable(out["cookiefile"]):
raise SubscriptionPermissionError(
f"Cannot read cookiefile {out['cookiefile']} due to permissions issue."
)
return out
# Disable for proper docstring formatting
@ -107,6 +120,7 @@ class OutputOptions(OptionsDictValidator):
"keep_max_files",
"download_archive_standardized_date",
"keep_files_date_eval",
"preserve_mtime",
}
@classmethod
@ -170,6 +184,10 @@ class OutputOptions(OptionsDictValidator):
default=f"{{{v.upload_date_standardized.variable_name}}}",
)
self._preserve_mtime = self._validate_key_if_present(
key="preserve_mtime", validator=BoolValidator, default=False
)
if (
self._keep_files_before or self._keep_files_after or self._keep_max_files
) and not self.maintain_download_archive:
@ -309,6 +327,17 @@ class OutputOptions(OptionsDictValidator):
"""
return self._keep_max_files
@property
def preserve_mtime(self) -> bool:
"""
:expected type: Optional[Boolean]
:description:
Preserve the video's original upload time as the file modification time.
When True, sets the file's mtime to match the video's upload_date from
yt-dlp metadata. Defaults to False.
"""
return self._preserve_mtime.value
def added_variables(self, unresolved_variables: Set[str]) -> Dict[PluginOperation, Set[str]]:
return {
# PluginOperation.MODIFY_ENTRY_METADATA: {

View file

@ -1,7 +1,5 @@
from abc import ABC
from typing import Dict
from typing import Set
from typing import TypeVar
from typing import Dict, Set, TypeVar
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.utils.exceptions import ValidationException
@ -61,9 +59,9 @@ class ToggleableOptionsDictValidator(OptionsDictValidator):
_optional_keys = {"enable"}
def __init__(self, name, value):
assert (
"enable" in self._optional_keys
), f"{self.__class__.__name__} does not have enable as an optional field"
assert "enable" in self._optional_keys, (
f"{self.__class__.__name__} does not have enable as an optional field"
)
super().__init__(name, value)
self._enable = self._validate_key(

View file

@ -1,10 +1,4 @@
import copy
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Dict, List, Optional, Set
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
@ -13,203 +7,212 @@ from ytdl_sub.config.plugin.preset_plugins import PresetPlugins
from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES
from ytdl_sub.entries.script.variable_definitions import UNRESOLVED_VARIABLES, VARIABLES
from ytdl_sub.script.script import Script
from ytdl_sub.script.script import _is_function
from ytdl_sub.utils.scriptable import BASE_SCRIPT
from ytdl_sub.validators.string_formatter_validators import to_variable_dependency_format_string
from ytdl_sub.script.utils.name_validation import is_function
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.validators.string_formatter_validators import validate_formatters
# Entry variables to mock during validation
_DUMMY_ENTRY_VARIABLES: Dict[str, str] = {
name: to_variable_dependency_format_string(
# pylint: disable=protected-access
script=BASE_SCRIPT,
parsed_format_string=BASE_SCRIPT._variables[name],
# pylint: enable=protected-access
)
for name in BASE_SCRIPT.variable_names
}
class ResolutionLevel:
ORIGINAL = 0
FILL = 1
RESOLVE = 2
INTERNAL = 3
def _add_dummy_variables(variables: Iterable[str]) -> Dict[str, str]:
dummy_variables: Dict[str, str] = {}
for var in variables:
dummy_variables[var] = ""
dummy_variables[f"{var}_sanitized"] = ""
@classmethod
def name_of(cls, resolution_level: int) -> str:
"""
Name of the resolution level.
"""
if resolution_level == cls.ORIGINAL:
return "original"
if resolution_level == cls.FILL:
return "fill"
if resolution_level == cls.RESOLVE:
return "resolve"
if resolution_level == cls.INTERNAL:
return "internal"
raise ValueError("Invalid resolution level")
return dummy_variables
@classmethod
def level_number(cls, resolution_arg: str) -> int:
"""
Numeric resolution level
"""
if resolution_arg in ("0", "original"):
return 0
if resolution_arg in ("1", "fill"):
return 1
if resolution_arg in ("2", "resolve"):
return 2
if resolution_arg in ("3", "internal"):
return 3
raise ValueError("Invalid resolution level")
def _add_dummy_overrides(overrides: Overrides) -> Dict[str, str]:
# Have the dummy override variable contain all variable deps that it uses in the string
dummy_overrides: Dict[str, str] = {}
for override_name in _override_variables(overrides):
if _is_function(override_name):
continue
# pylint: disable=protected-access
dummy_overrides[override_name] = to_variable_dependency_format_string(
script=overrides.script, parsed_format_string=overrides.script._variables[override_name]
)
# pylint: enable=protected-access
return dummy_overrides
def _get_added_and_modified_variables(
plugins: PresetPlugins, downloader_options: MultiUrlValidator, output_options: OutputOptions
) -> Iterable[Tuple[OptionsValidator, Set[str], Set[str]]]:
"""
Iterates and returns the plugin options, added variables, modified variables
"""
options: List[OptionsValidator] = plugins.plugin_options
options.append(downloader_options)
options.append(output_options)
for plugin_options in options:
added_variables: Set[str] = set()
modified_variables: Set[str] = set()
for plugin_added_variables in plugin_options.added_variables(
unresolved_variables=set(),
).values():
added_variables |= set(plugin_added_variables)
for plugin_modified_variables in plugin_options.modified_variables().values():
modified_variables = plugin_modified_variables
yield plugin_options, added_variables, modified_variables
def _override_variables(overrides: Overrides) -> Set[str]:
return set(list(overrides.initial_variables().keys()))
@classmethod
def all(cls) -> List[int]:
"""
All possible resolution levels.
"""
return [cls.ORIGINAL, cls.FILL, cls.RESOLVE, cls.INTERNAL]
class VariableValidation:
def _get_resolve_partial_filter(self) -> Set[str]:
# Exclude sanitized variables from partial validation. This lessens the work
# and prevents double-evaluation, which can lead to bad behavior like double-prints.
return {
name
for name in self.script.variable_names
if name not in self.unresolved_variables and not name.endswith("_sanitized")
}
def _apply_resolution_level(self, mocks: Optional[Dict[str, str]]) -> None:
if self._resolution_level == ResolutionLevel.FILL:
self.unresolved_variables |= VARIABLES.variable_names(include_sanitized=True)
# Only partial resolve definitions that are already resolved
self.unresolved_variables |= {
name
for name in self.overrides.keys
if not is_function(name) and not self.script.definition_of(name).maybe_resolvable
}
elif self._resolution_level == ResolutionLevel.RESOLVE:
# Partial resolve everything, but not including internal variables
self.unresolved_variables |= VARIABLES.variable_names(include_sanitized=True)
elif self._resolution_level == ResolutionLevel.INTERNAL:
# Partial resolve everything including internal variables
pass
else:
raise ValueError("Invalid resolution level for validation")
if mocks is not None:
for mock_name in mocks.keys():
if mock_name in self.unresolved_variables:
self.unresolved_variables.remove(mock_name)
self.script.add(
variables=mocks,
unresolvable=self.unresolved_variables,
)
self.script = self.script.resolve_partial(
unresolvable=self.unresolved_variables,
output_filter=self._get_resolve_partial_filter(),
)
def __init__(
self,
overrides: Overrides,
downloader_options: MultiUrlValidator,
output_options: OutputOptions,
plugins: PresetPlugins,
resolution_level: int = ResolutionLevel.RESOLVE,
mocks: Optional[Dict[str, str]] = None,
):
self.overrides = overrides
self.downloader_options = downloader_options
self.output_options = output_options
self.plugins = plugins
self.script: Optional[Script] = None
self.resolved_variables: Set[str] = set()
self.unresolved_variables: Set[str] = set()
def initialize_preset_overrides(self, overrides: Overrides) -> "VariableValidation":
"""
Do some gymnastics to initialize the Overrides script.
"""
override_variables = set(list(overrides.initial_variables().keys()))
# Set resolved variables as all entry + override variables
# at this point to generate every possible added/modified variable
self.resolved_variables = set(_DUMMY_ENTRY_VARIABLES.keys()) | override_variables
plugin_variables: Set[str] = set()
for (
plugin_options,
added_variables,
modified_variables,
) in _get_added_and_modified_variables(
plugins=self.plugins,
downloader_options=self.downloader_options,
output_options=self.output_options,
):
for added_variable in added_variables:
if not overrides.ensure_added_plugin_variable_valid(added_variable=added_variable):
# pylint: disable=protected-access
raise plugin_options._validation_exception(
f"Cannot use the variable name {added_variable} because it exists as a"
" built-in ytdl-sub variable name."
)
# pylint: enable=protected-access
# Set unresolved as variables that are added but do not exist as
# entry/override variables since they are created at run-time
self.unresolved_variables |= added_variables | modified_variables
plugin_variables |= added_variables | modified_variables
# Then update resolved variables to reflect that
self.resolved_variables -= self.unresolved_variables
# Initialize overrides with unresolved variables + modified variables to throw an error.
# For modified variables, this is to prevent a resolve(update=True) to setting any
# dependencies until it has been explicitly added
overrides = overrides.initialize_script(unresolved_variables=self.unresolved_variables)
# copy the script and mock entry variables
self.script = copy.deepcopy(overrides.script)
self.script.add(
variables=_add_dummy_overrides(overrides=overrides)
| _add_dummy_variables(variables=plugin_variables)
| _DUMMY_ENTRY_VARIABLES
self.script: Script = self.overrides.script
self.unresolved_variables = (
self.plugins.get_all_variables(
additional_options=[self.output_options, self.downloader_options]
)
| UNRESOLVED_VARIABLES
)
self.unresolved_runtime_variables = self.plugins.get_all_variables(
additional_options=[self.output_options, self.downloader_options]
)
self._resolution_level = resolution_level
self._apply_resolution_level(mocks=mocks)
return self
def _update_script(self) -> None:
_ = self.script.resolve(unresolvable=self.unresolved_variables, update=True)
def _add_subscription_override_variables(self) -> None:
"""
Add dummy subscription variables for script validation
"""
self.resolved_variables |= REQUIRED_OVERRIDE_VARIABLE_NAMES
def _add_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> None:
def _add_runtime_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> None:
"""
Add dummy variables for script validation
"""
added_variables = options.added_variables(
unresolved_variables=self.unresolved_variables,
unresolved_variables=self.unresolved_runtime_variables,
).get(plugin_op, set())
modified_variables = options.modified_variables().get(plugin_op, set())
resolved_variables = added_variables | modified_variables
self.unresolved_runtime_variables -= added_variables | modified_variables
self.resolved_variables |= resolved_variables
self.unresolved_variables -= resolved_variables
def _output_override_variables(self) -> Dict:
output = {}
for name in self.overrides.keys:
value = self.script.definition_of(name)
if name in self.script.function_names:
# Keep custom functions as-is
output[name] = self.overrides.dict_with_format_strings[name]
elif resolved := value.maybe_resolvable:
output[name] = resolved.native
else:
output[name] = ScriptUtils.to_native_script(value)
def ensure_proper_usage(self) -> None:
return output
def ensure_proper_usage(self, partial_resolve_formatters: bool = False) -> Dict:
"""
Validate variables resolve as plugins are executed, and return
a mock script which contains actualized added variables from the plugins
"""
resolved_subscription: Dict = {}
self._add_variables(PluginOperation.DOWNLOADER, options=self.downloader_options)
self._add_subscription_override_variables()
self._add_runtime_variables(PluginOperation.DOWNLOADER, options=self.downloader_options)
# Always add output options first
self._add_variables(PluginOperation.MODIFY_ENTRY_METADATA, options=self.output_options)
self._add_runtime_variables(
PluginOperation.MODIFY_ENTRY_METADATA, options=self.output_options
)
# Metadata variables to be added
for plugin_options in PluginMapping.order_options_by(
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY_METADATA
):
self._add_variables(PluginOperation.MODIFY_ENTRY_METADATA, options=plugin_options)
self._add_runtime_variables(
PluginOperation.MODIFY_ENTRY_METADATA, options=plugin_options
)
for plugin_options in PluginMapping.order_options_by(
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY
):
self._add_variables(PluginOperation.MODIFY_ENTRY, options=plugin_options)
self._add_runtime_variables(PluginOperation.MODIFY_ENTRY, options=plugin_options)
# Validate that any formatter in the plugin options can resolve
validate_formatters(
resolved_subscription |= validate_formatters(
script=self.script,
unresolved_variables=self.unresolved_variables,
unresolved_runtime_variables=self.unresolved_runtime_variables,
validator=plugin_options,
partial_resolve_formatters=partial_resolve_formatters,
)
validate_formatters(
resolved_subscription |= validate_formatters(
script=self.script,
unresolved_variables=self.unresolved_variables,
unresolved_runtime_variables=self.unresolved_runtime_variables,
validator=self.output_options,
partial_resolve_formatters=partial_resolve_formatters,
)
assert not self.unresolved_variables
# TODO: make this a function
raw_download_output = validate_formatters(
script=self.script,
unresolved_variables=self.unresolved_variables,
unresolved_runtime_variables=self.unresolved_runtime_variables,
validator=self.downloader_options.urls,
partial_resolve_formatters=partial_resolve_formatters,
)
resolved_subscription["download"] = []
for url_output in raw_download_output["download"]:
if isinstance(url_output["url"], list):
url_output["url"] = [url for url in url_output["url"] if bool(url)]
if url_output["url"]:
resolved_subscription["download"].append(url_output)
resolved_subscription["overrides"] = self._output_override_variables()
return resolved_subscription

View file

@ -1,24 +1,24 @@
import copy
import json
from pathlib import Path
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Dict, Iterable, List, Optional
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.validators.options import OptionsDictValidator
from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import (
VARIABLE_SCRIPTS,
VARIABLES,
VariableDefinitions,
)
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import get_file_extension
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadMapping
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
from ytdl_sub.utils.file_handler import FileHandler, get_file_extension
from ytdl_sub.ytdl_additions.enhanced_download_archive import (
DownloadMapping,
EnhancedDownloadArchive,
)
v: VariableDefinitions = VARIABLES

View file

@ -1,16 +1,9 @@
import abc
from abc import ABC
from typing import Dict
from typing import Generic
from typing import Iterable
from typing import List
from typing import Optional
from typing import Type
from typing import final
from typing import Dict, Generic, Iterable, List, Optional, Type, final
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import BasePlugin
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin import BasePlugin, Plugin
from ytdl_sub.config.validators.options import OptionsValidatorT
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry

View file

@ -1,33 +1,29 @@
import contextlib
import os
from pathlib import Path
from typing import Dict
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
from yt_dlp.utils import RejectedVideoReached
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.source_plugin import SourcePluginExtension
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.downloaders.url.validators import UrlThumbnailListValidator
from ytdl_sub.downloaders.url.validators import UrlValidator
from ytdl_sub.downloaders.source_plugin import SourcePlugin, SourcePluginExtension
from ytdl_sub.downloaders.url.validators import (
MultiUrlValidator,
UrlThumbnailListValidator,
UrlValidator,
)
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.downloaders.ytdlp import YTDLP
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.entry_parent import EntryParent
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.thumbnail import ThumbnailTypes
from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
from ytdl_sub.utils.thumbnail import (
ThumbnailTypes,
download_and_convert_url_thumbnail,
try_convert_download_thumbnail,
)
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
v: VariableDefinitions = VARIABLES
@ -52,12 +48,12 @@ class UrlDownloaderBasePluginExtension(SourcePluginExtension[MultiUrlValidator])
if 0 <= input_url_idx < len(self.plugin_options.urls.list):
validator = self.plugin_options.urls.list[input_url_idx]
if self.overrides.apply_formatter(validator.url) == entry_input_url:
if entry_input_url in self.overrides.apply_formatter(validator.url, expected_type=list):
return validator
# Match the first validator based on the URL, if one exists
for validator in self.plugin_options.urls.list:
if self.overrides.apply_formatter(validator.url) == entry_input_url:
if entry_input_url in self.overrides.apply_formatter(validator.url, expected_type=list):
return validator
# Return the first validator if none exist
@ -97,7 +93,6 @@ class UrlDownloaderThumbnailPlugin(UrlDownloaderBasePluginExtension):
# If latest entry, always update the thumbnail on each entry
if thumbnail_id == ThumbnailTypes.LATEST_ENTRY:
# always save in dry-run even if it doesn't exist...
if self.is_dry_run or entry.is_thumbnail_downloaded():
self.save_file(
@ -257,6 +252,16 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
.to_dict()
)
def webpage_url(self, entry: Entry) -> str:
"""
Returns
-------
The webpage_url to use for the actual download
"""
url_idx = entry.get(v.ytdl_sub_input_url_index, int)
webpage_url_formatter = self.plugin_options.urls.list[url_idx].webpage_url
return self.overrides.apply_formatter(webpage_url_formatter, entry=entry)
def metadata_ytdl_options(self, ytdl_option_overrides: Dict) -> Dict:
"""
Returns
@ -358,7 +363,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
else entry.is_thumbnail_downloaded_via_ytdlp
),
url=entry.webpage_url,
url=self.webpage_url(entry=entry),
)
return Entry(
download_entry_dict,
@ -366,13 +371,13 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
)
def _iterate_child_entries(
self, entries: List[Entry], download_reversed: bool
self, entries: List[Entry], validator: UrlValidator
) -> Iterator[Entry]:
# Iterate a list of entries, and delete the entries after yielding
entries_to_iter: List[Optional[Entry]] = entries
indices = list(range(len(entries_to_iter)))
if download_reversed:
if self.overrides.apply_formatter(validator.download_reverse, expected_type=bool):
indices = reversed(indices)
for idx in indices:
@ -394,17 +399,13 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
entries_to_iter[idx] = None
def _iterate_parent_entry(
self, parent: EntryParent, download_reversed: bool
self, parent: EntryParent, validator: UrlValidator
) -> Iterator[Entry]:
yield from self._iterate_child_entries(
entries=parent.entry_children(), download_reversed=download_reversed
)
yield from self._iterate_child_entries(entries=parent.entry_children(), validator=validator)
# Recursion the parent's parent entries
for parent_child in reversed(parent.parent_children()):
yield from self._iterate_parent_entry(
parent=parent_child, download_reversed=download_reversed
)
yield from self._iterate_parent_entry(parent=parent_child, validator=validator)
def _download_url_metadata(
self, url: str, include_sibling_metadata: bool, ytdl_options_overrides: Dict
@ -438,7 +439,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
self,
parents: List[EntryParent],
orphans: List[Entry],
download_reversed: bool,
validator: UrlValidator,
) -> Iterator[Entry]:
"""
Downloads the leaf entries from EntryParent trees
@ -446,21 +447,17 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
# Delete info json files afterwards so other collection URLs do not use them
with self._separate_download_archives(clear_info_json_files=True):
for parent in parents:
yield from self._iterate_parent_entry(
parent=parent, download_reversed=download_reversed
)
yield from self._iterate_parent_entry(parent=parent, validator=validator)
yield from self._iterate_child_entries(
entries=orphans, download_reversed=download_reversed
)
yield from self._iterate_child_entries(entries=orphans, validator=validator)
def _download_metadata(self, url: str, validator: UrlValidator) -> Iterable[Entry]:
metadata_ytdl_options = self.metadata_ytdl_options(
ytdl_option_overrides=validator.ytdl_options.to_native_dict(self.overrides)
)
download_reversed = self.overrides.evaluate_boolean(validator.download_reverse)
include_sibling_metadata = self.overrides.evaluate_boolean(
validator.include_sibling_metadata
include_sibling_metadata = self.overrides.apply_formatter(
validator.include_sibling_metadata, expected_type=bool
)
parents, orphan_entries = self._download_url_metadata(
@ -469,16 +466,15 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
ytdl_options_overrides=metadata_ytdl_options,
)
# TODO: Encapsulate this logic into its own class
self._url_state = URLDownloadState(
entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries)
entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries),
)
download_logger.info("Beginning downloads for %s", url)
yield from self._iterate_entries(
parents=parents,
orphans=orphan_entries,
download_reversed=download_reversed,
validator=validator,
)
def download_metadata(self) -> Iterable[Entry]:
@ -486,19 +482,25 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
# download the bottom-most urls first since they are top-priority
for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))):
# URLs can be empty. If they are, then skip
if not (url := self.overrides.apply_formatter(url_validator.url)):
if not (urls := self.overrides.apply_formatter(url_validator.url, expected_type=list)):
continue
for entry in self._download_metadata(url=url, validator=url_validator):
entry.initialize_script(self.overrides).add(
{
v.ytdl_sub_input_url: url,
v.ytdl_sub_input_url_index: idx,
v.ytdl_sub_input_url_count: len(self.collection.urls.list),
}
)
for url in reversed(urls):
assert isinstance(url, str)
yield entry
if not url:
continue
for entry in self._download_metadata(url=url, validator=url_validator):
entry.initialize_script(self.overrides).add(
{
v.ytdl_sub_input_url: url,
v.ytdl_sub_input_url_index: idx,
v.ytdl_sub_input_url_count: len(self.collection.urls.list),
}
)
yield entry
def download(self, entry: Entry) -> Optional[Entry]:
"""

View file

@ -1,17 +1,16 @@
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
from typing import Any, Dict, List, Optional, Set
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.script.parser import parse
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import (
DictFormatterValidator,
OverridesBooleanFormatterValidator,
OverridesStringFormatterValidator,
StringFormatterValidator,
)
from ytdl_sub.validators.validators import ListValidator
@ -21,7 +20,7 @@ class UrlThumbnailValidator(StrictDictValidator):
def __init__(self, name, value):
super().__init__(name, value)
self._name = self._validate_key(key="name", validator=StringFormatterValidator)
self._thumb_name = self._validate_key(key="name", validator=StringFormatterValidator)
self._uid = self._validate_key(key="uid", validator=OverridesStringFormatterValidator)
@property
@ -29,7 +28,7 @@ class UrlThumbnailValidator(StrictDictValidator):
"""
File name for the thumbnail
"""
return self._name
return self._thumb_name
@property
def uid(self) -> OverridesStringFormatterValidator:
@ -43,6 +42,19 @@ class UrlThumbnailListValidator(ListValidator[UrlThumbnailValidator]):
_inner_list_type = UrlThumbnailValidator
class OverridesOneOrManyUrlValidator(OverridesStringFormatterValidator):
def post_process(self, resolved: Any) -> List[str]:
if isinstance(resolved, str):
return [resolved]
if isinstance(resolved, list):
for value in resolved:
if not isinstance(value, str):
raise self._validation_exception("Must be a string or an array of strings.")
return resolved
raise self._validation_exception("Must be a string or an array of strings.")
class UrlValidator(StrictDictValidator):
_required_keys = {"url"}
_optional_keys = {
@ -52,6 +64,7 @@ class UrlValidator(StrictDictValidator):
"download_reverse",
"ytdl_options",
"include_sibling_metadata",
"webpage_url",
}
@classmethod
@ -67,7 +80,7 @@ class UrlValidator(StrictDictValidator):
super().__init__(name, value)
# TODO: url validate using yt-dlp IE
self._url = self._validate_key(key="url", validator=OverridesStringFormatterValidator)
self._url = self._validate_key(key="url", validator=OverridesOneOrManyUrlValidator)
self._variables = self._validate_key_if_present(
key="variables", validator=DictFormatterValidator, default={}
)
@ -89,6 +102,9 @@ class UrlValidator(StrictDictValidator):
validator=OverridesBooleanFormatterValidator,
default="False",
)
self._webpage_url = self._validate_key(
key="webpage_url", validator=StringFormatterValidator, default="{webpage_url}"
)
@property
def url(self) -> OverridesStringFormatterValidator:
@ -180,6 +196,19 @@ class UrlValidator(StrictDictValidator):
"""
return self._include_sibling_metadata
@property
def webpage_url(self) -> StringFormatterValidator:
"""
Optional. After ytdl-sub performs the metadata download, it will inspect each
entry's .info.json file and perform the actual download from yt-dlp using
`webpage_url <config_reference/scripting/entry_variables:webpage_url>`. This
can be overwritten by supplying parameter with a modification to ``webpage_url`` in the
form of an override variable.
Defaults to ``{webpage_url}``.
"""
return self._webpage_url
class UrlStringOrDictValidator(UrlValidator):
"""

View file

@ -1,6 +1,5 @@
import copy
from typing import Dict
from typing import Optional
from typing import Dict, Optional
import mergedeep

View file

@ -5,15 +5,10 @@ import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Callable, Dict, List, Optional
import yt_dlp as ytdl
from yt_dlp.utils import ExistingVideoReached
from yt_dlp.utils import MaxDownloadsReached
from yt_dlp.utils import RejectedVideoReached
from yt_dlp.utils import ExistingVideoReached, MaxDownloadsReached, RejectedVideoReached
from ytdl_sub.thread.log_entries_downloaded_listener import LogEntriesDownloadedListener
from ytdl_sub.utils.exceptions import FileNotDownloadedException

View file

@ -1,18 +1,11 @@
# pylint: disable=protected-access
from abc import ABC
from pathlib import Path
from typing import Any
from typing import Dict
from typing import Optional
from typing import Type
from typing import TypeVar
from typing import final
from typing import Any, Dict, Optional, Type, TypeVar, final
from yt_dlp.utils import LazyList
from yt_dlp.utils import sanitize_filename
from yt_dlp.utils import LazyList, sanitize_filename
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
v: VariableDefinitions = VARIABLES

View file

@ -3,24 +3,15 @@ import copy
import json
import os
from pathlib import Path
from typing import Any
from typing import Dict
from typing import Optional
from typing import Type
from typing import TypeVar
from typing import final
from typing import Any, Dict, Optional, Type, TypeVar, final
from ytdl_sub.entries.base_entry import BaseEntry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_types import ArrayVariable
from ytdl_sub.entries.script.variable_types import StringVariable
from ytdl_sub.entries.script.variable_types import Variable
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.entries.script.variable_types import ArrayVariable, StringVariable, Variable
from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.utils.scriptable import Scriptable
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.audo_codec_validator import VIDEO_CODEC_EXTS
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS, VIDEO_CODEC_EXTS
v: VariableDefinitions = VARIABLES

View file

@ -1,16 +1,10 @@
import math
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Any, Dict, List, Optional, Set
from urllib.parse import urlparse
from ytdl_sub.entries.base_entry import BaseEntry
from ytdl_sub.entries.base_entry import BaseEntryT
from ytdl_sub.entries.base_entry import BaseEntry, BaseEntryT
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.entries.script.variable_types import MetadataVariable
v: VariableDefinitions = VARIABLES

View file

@ -5,10 +5,7 @@ from yt_dlp.utils import sanitize_filename
from ytdl_sub.script.functions import Functions
from ytdl_sub.script.types.map import Map
from ytdl_sub.script.types.resolvable import AnyArgument
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import ReturnableArgument
from ytdl_sub.script.types.resolvable import String
from ytdl_sub.script.types.resolvable import AnyArgument, Integer, ReturnableArgument, String
from ytdl_sub.script.utils.exceptions import RuntimeException
from ytdl_sub.utils.file_path import FilePathTruncater
@ -49,12 +46,12 @@ class CustomFunctions:
return String(FilePathTruncater.maybe_truncate_file_path(filepath.value))
@staticmethod
def sanitize(value: AnyArgument) -> String:
def sanitize(*value: AnyArgument) -> String:
"""
Sanitize a string using yt-dlp's ``sanitize_filename`` method to ensure it's safe to use
for file/directory names on any OS.
"""
return String(sanitize_filename(str(value)))
return String("".join(sanitize_filename(str(val)) for val in value))
@staticmethod
def sanitize_plex_episode(string: String) -> String:

View file

@ -1,7 +1,6 @@
from typing import Dict
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
v: VariableDefinitions = VARIABLES

View file

@ -1,21 +1,21 @@
from abc import ABC
from functools import cache
from functools import cached_property
from typing import Dict
from typing import Set
from functools import cache, cached_property
from typing import Dict, Optional, Set
from ytdl_sub.entries.script.custom_functions import CustomFunctions
from ytdl_sub.entries.script.variable_types import ArrayMetadataVariable
from ytdl_sub.entries.script.variable_types import IntegerMetadataVariable
from ytdl_sub.entries.script.variable_types import IntegerVariable
from ytdl_sub.entries.script.variable_types import MapMetadataVariable
from ytdl_sub.entries.script.variable_types import MapVariable
from ytdl_sub.entries.script.variable_types import MetadataVariable
from ytdl_sub.entries.script.variable_types import StringDateMetadataVariable
from ytdl_sub.entries.script.variable_types import StringDateVariable
from ytdl_sub.entries.script.variable_types import StringMetadataVariable
from ytdl_sub.entries.script.variable_types import StringVariable
from ytdl_sub.entries.script.variable_types import Variable
from ytdl_sub.entries.script.variable_types import (
ArrayMetadataVariable,
IntegerMetadataVariable,
IntegerVariable,
MapMetadataVariable,
MapVariable,
MetadataVariable,
StringDateMetadataVariable,
StringDateVariable,
StringMetadataVariable,
StringVariable,
Variable,
)
# This file contains mixins to a BaseEntry subclass. Ignore pylint's "no kwargs member" suggestion
# pylint: disable=no-member
@ -1135,6 +1135,16 @@ class VariableDefinitions(
]
}
@cache
def variable_names(self, include_sanitized: bool):
"""
Returns all variable names, and can include sanitized.
"""
var_names: Set[str] = self.scripts().keys()
if include_sanitized:
var_names |= {f"{name}_sanitized" for name in var_names}
return var_names
@cache
def injected_variables(self) -> Set[MetadataVariable]:
"""
@ -1162,6 +1172,7 @@ class VariableDefinitions(
"""
return {
self.uid,
self.extractor,
self.extractor_key,
self.epoch,
self.webpage_url,
@ -1204,6 +1215,14 @@ class VariableDefinitions(
VARIABLES.entry_metadata,
} | self.injected_variables()
def get(self, name: str) -> Optional[Variable]:
"""
Returns the variable attribute if it exists. None otherwise.
"""
if not hasattr(self, name):
return None
return getattr(self, name)
# Singletons to use externally
VARIABLES: VariableDefinitions = VariableDefinitions()

View file

@ -1,17 +1,10 @@
from abc import ABC
from abc import abstractmethod
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Dict
from typing import List
from typing import Optional
from typing import Type
from typing import TypeVar
from typing import Dict, List, Optional, Type, TypeVar
from ytdl_sub.script.types.array import Array
from ytdl_sub.script.types.map import Map
from ytdl_sub.script.types.resolvable import Boolean
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import String
from ytdl_sub.script.types.resolvable import Boolean, Integer, String
ENTRY_METADATA_VARIABLE_NAME = "entry_metadata"
PLAYLIST_METADATA_VARIABLE_NAME = "playlist_metadata"
@ -22,7 +15,6 @@ VariableT = TypeVar("VariableT", bound="Variable")
def _get(
cast: str,
metadata_variable_name: str,
metadata_key: str,
variable_name: Optional[str],
@ -47,7 +39,7 @@ def _get(
return as_type(
variable_name=variable_name or metadata_key,
metadata_key=metadata_key,
definition=f"{{ %legacy_bracket_safety(%{cast}({out})) }}",
definition=f"{{ {out} }}",
)
@ -182,7 +174,6 @@ class MapMetadataVariable(MetadataVariable, MapVariable):
Creates a map variable from entry metadata
"""
return _get(
"map",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,
@ -204,7 +195,6 @@ class ArrayMetadataVariable(MetadataVariable, ArrayVariable):
Creates an array variable from entry metadata
"""
return _get(
"array",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,
@ -226,7 +216,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
Creates a string variable from entry metadata
"""
return _get(
"string",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,
@ -245,7 +234,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
Creates a string variable from playlist metadata
"""
return _get(
"string",
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,
@ -264,7 +252,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
Creates a string variable from source metadata
"""
return _get(
"string",
metadata_variable_name=SOURCE_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,
@ -301,7 +288,6 @@ class IntegerMetadataVariable(MetadataVariable, IntegerVariable):
Creates an int variable from entry metadata
"""
return _get(
"int",
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,
@ -320,7 +306,6 @@ class IntegerMetadataVariable(MetadataVariable, IntegerVariable):
Creates an int variable from playlist metadata
"""
return _get(
"int",
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
metadata_key=metadata_key,
variable_name=variable_name,

View file

@ -1,19 +1,17 @@
from typing import Dict
from typing import Set
from typing import Dict, Set
from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS
from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS
from ytdl_sub.entries.script.variable_types import ArrayVariable
from ytdl_sub.entries.script.variable_types import BooleanVariable
from ytdl_sub.entries.script.variable_types import MapVariable
from ytdl_sub.entries.script.variable_types import StringVariable
from ytdl_sub.entries.script.variable_types import Variable
from ytdl_sub.entries.script.variable_types import (
ArrayVariable,
BooleanVariable,
MapVariable,
StringVariable,
Variable,
)
from ytdl_sub.script.functions import Functions
from ytdl_sub.script.utils.name_validation import is_valid_name
# TODO: use this
SUBSCRIPTION_ARRAY = "subscription_array"
class SubscriptionVariables:
@staticmethod
@ -163,7 +161,7 @@ class OverrideHelpers:
True if the override name itself is valid. False otherwise.
"""
if name.startswith("%"):
return is_valid_name(name=name[1:])
name = name[1:]
return is_valid_name(name=name)

View file

@ -1,7 +1,7 @@
import sys
from ytdl_sub.cli.parsers.main import parser
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.logger import Logger, LoggerLevels
def _main() -> int:
@ -10,6 +10,11 @@ def _main() -> int:
args, _ = parser.parse_known_args()
Logger.set_log_level(log_level_name=args.ytdl_sub_log_level)
# Suppress all logs during inspection since the output of the subcommand itself
# is all that is necessary
if args.subparser == "inspect":
Logger.set_log_level(log_level_name=LoggerLevels.QUIET.name)
# pylint: disable=import-outside-toplevel
import ytdl_sub.cli.entrypoint

View file

@ -1,21 +1,19 @@
import os.path
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
from typing import Any, Dict, Optional, Set
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.utils.exceptions import FileNotDownloadedException
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_TYPES_EXTENSION_MAPPING
from ytdl_sub.validators.audo_codec_validator import AudioTypeValidator
from ytdl_sub.validators.audo_codec_validator import (
AUDIO_CODEC_EXTS,
AUDIO_CODEC_TYPES_EXTENSION_MAPPING,
AudioTypeValidator,
)
from ytdl_sub.validators.validators import FloatValidator
v: VariableDefinitions = VARIABLES

View file

@ -1,26 +1,23 @@
import collections
import re
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Dict, List, Optional, Set
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.entry import ytdl_sub_chapters_from_comments
from ytdl_sub.entries.entry import ytdl_sub_split_by_chapters_parent_uid
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.entry import (
Entry,
ytdl_sub_chapters_from_comments,
ytdl_sub_split_by_chapters_parent_uid,
)
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.validators.regex_validator import RegexListValidator
from ytdl_sub.validators.string_select_validator import StringSelectValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.validators.validators import BoolValidator, ListValidator
v: VariableDefinitions = VARIABLES

View file

@ -1,7 +1,4 @@
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import List, Optional, Set, Tuple
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
@ -116,7 +113,7 @@ class DateRangePlugin(Plugin[DateRangeOptions]):
date_validator=self.plugin_options.after, overrides=self.overrides
)
after_filter = f"{date_type} >= {after_str}"
if self.overrides.evaluate_boolean(self.plugin_options.breaks):
if self.overrides.apply_formatter(self.plugin_options.breaks, expected_type=bool):
breaking_match_filters.append(after_filter)
else:
match_filters.append(after_filter)

View file

@ -1,5 +1,4 @@
from typing import List
from typing import Optional
from typing import List, Optional
import mediafile
@ -7,8 +6,7 @@ from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.file_handler import FileHandler, FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
@ -33,7 +31,7 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
@property
def _embed_thumbnail(self) -> bool:
return self.overrides.evaluate_boolean(self.plugin_options)
return self.overrides.apply_formatter(self.plugin_options, expected_type=bool)
@classmethod
def _embed_video_thumbnail(cls, entry: Entry) -> None:

View file

@ -1,22 +1,16 @@
import os
from subprocess import CalledProcessError
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
from typing import Any, Dict, Optional, Set
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.utils.exceptions import FileNotDownloadedException
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.utils.exceptions import FileNotDownloadedException, ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.file_handler import FileHandler, FileMetadata
from ytdl_sub.validators.audo_codec_validator import FileTypeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_select_validator import StringSelectValidator

View file

@ -1,5 +1,4 @@
from typing import Dict
from typing import Optional
from typing import Dict, Optional
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import Plugin
@ -7,13 +6,14 @@ from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
from ytdl_sub.validators.string_formatter_validators import BooleanFormatterValidator
from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("filter-exclude")
class FilterExcludeOptions(ListFormatterValidator, OptionsValidator):
class FilterExcludeOptions(ListValidator[BooleanFormatterValidator], OptionsValidator):
"""
Applies a conditional OR on any number of filters comprised of either variables or scripts.
If any filter evaluates to True, the entry will be excluded.
@ -29,6 +29,8 @@ class FilterExcludeOptions(ListFormatterValidator, OptionsValidator):
{ %contains( %lower(description), '#short' ) }
"""
_inner_list_type = BooleanFormatterValidator
class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
plugin_options_type = FilterExcludeOptions
@ -52,7 +54,9 @@ class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
return entry
for formatter in self.plugin_options.list:
should_exclude = self.overrides.evaluate_boolean(formatter=formatter, entry=entry)
should_exclude = self.overrides.apply_formatter(
formatter=formatter, entry=entry, expected_type=bool
)
if should_exclude:
logger.info(

View file

@ -1,5 +1,4 @@
from typing import Dict
from typing import Optional
from typing import Dict, Optional
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import Plugin
@ -7,14 +6,14 @@ from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
from ytdl_sub.validators.string_formatter_validators import BooleanFormatterValidator
from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("filter-include")
class FilterIncludeOptions(ListFormatterValidator, OptionsValidator):
class FilterIncludeOptions(ListValidator[BooleanFormatterValidator], OptionsValidator):
"""
Applies a conditional AND on any number of filters comprised of either variables or scripts.
If all filters evaluate to True, the entry will be included.
@ -38,6 +37,8 @@ class FilterIncludeOptions(ListFormatterValidator, OptionsValidator):
}
"""
_inner_list_type = BooleanFormatterValidator
class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
plugin_options_type = FilterIncludeOptions
@ -61,8 +62,8 @@ class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
return entry
for formatter in self.plugin_options.list:
should_exclude = ScriptUtils.bool_formatter_output(
self.overrides.apply_formatter(formatter=formatter, entry=entry)
should_exclude = self.overrides.apply_formatter(
formatter=formatter, entry=entry, expected_type=bool
)
if not should_exclude:
logger.info(

View file

@ -1,5 +1,4 @@
from typing import Dict
from typing import Optional
from typing import Dict, Optional
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator

View file

@ -1,7 +1,5 @@
import copy
from typing import Any
from typing import List
from typing import Tuple
from typing import Any, List, Tuple
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator

View file

@ -1,22 +1,21 @@
from collections import defaultdict
from datetime import datetime
from typing import Any
from typing import Dict
from typing import List
from typing import Any, Dict, List
import mediafile
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import (
ListFormatterValidator,
StringFormatterValidator,
)
v: VariableDefinitions = VARIABLES
@ -147,8 +146,7 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
else:
if len(tag_value) > 1:
logger.warning(
"Music tag '%s' does not support lists. "
"Only setting the first element",
"Music tag '%s' does not support lists. Only setting the first element",
tag_name,
)
setattr(audio_file, tag_name, tag_value[0])

View file

@ -2,24 +2,25 @@ import os
from abc import ABC
from collections import defaultdict
from pathlib import Path
from typing import Any
from typing import Dict
from typing import List
from typing import Any, Dict, List
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.xml import XmlElement
from ytdl_sub.utils.xml import to_max_3_byte_utf8_dict
from ytdl_sub.utils.xml import to_max_3_byte_utf8_string
from ytdl_sub.utils.xml import to_xml
from ytdl_sub.utils.file_handler import FileHandler, FileMetadata
from ytdl_sub.utils.xml import (
XmlElement,
to_max_3_byte_utf8_dict,
to_max_3_byte_utf8_string,
to_xml,
)
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.validators.nfo_validators import NfoTagsValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import (
DictFormatterValidator,
OverridesBooleanFormatterValidator,
StringFormatterValidator,
)
class SharedNfoTagsOptions(ToggleableOptionsDictValidator):
@ -140,7 +141,7 @@ class SharedNfoTagsPlugin(Plugin[SharedNfoTagsOptions], ABC):
if not nfo_tags:
return
if self.overrides.evaluate_boolean(self.plugin_options.kodi_safe):
if self.overrides.apply_formatter(self.plugin_options.kodi_safe, expected_type=bool):
nfo_root = to_max_3_byte_utf8_string(nfo_root)
nfo_tags = {
to_max_3_byte_utf8_string(key): [

View file

@ -1,8 +1,6 @@
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.nfo_tags import NfoTagsValidator
from ytdl_sub.plugins.nfo_tags import SharedNfoTagsOptions
from ytdl_sub.plugins.nfo_tags import SharedNfoTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsValidator, SharedNfoTagsOptions, SharedNfoTagsPlugin
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive

View file

@ -1,23 +1,14 @@
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Any, Dict, List, Optional, Set, Tuple
from ytdl_sub.config.plugin.plugin import SplitPlugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.entry import ytdl_sub_split_by_chapters_parent_uid
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.chapters import Timestamp
from ytdl_sub.entries.entry import Entry, ytdl_sub_split_by_chapters_parent_uid
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.utils.chapters import Chapters, Timestamp
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.file_handler import FileHandler, FileMetadata
from ytdl_sub.validators.string_select_validator import StringSelectValidator
v: VariableDefinitions = VARIABLES

View file

@ -1,12 +1,10 @@
from typing import List
from typing import Optional
from typing import List, Optional
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.file_handler import FileHandler, FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
@ -31,7 +29,7 @@ class SquareThumbnailPlugin(Plugin[SquareThumbnailOptions]):
@property
def _square_thumbnail(self) -> bool:
return self.overrides.evaluate_boolean(self.plugin_options)
return self.overrides.apply_formatter(self.plugin_options, expected_type=bool)
@classmethod
def _convert_to_square_thumbnail(cls, entry: Entry) -> None:

View file

@ -1,7 +1,5 @@
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.nfo_tags import NfoTagsValidator
from ytdl_sub.plugins.nfo_tags import SharedNfoTagsOptions
from ytdl_sub.plugins.nfo_tags import SharedNfoTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsValidator, SharedNfoTagsOptions, SharedNfoTagsPlugin
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator

View file

@ -1,25 +1,21 @@
import os
from pathlib import Path
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Dict, List, Optional, Set, Tuple
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.entries.script.variable_definitions import VARIABLES, VariableDefinitions
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.utils.file_handler import FileHandler, FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.subtitles import SUBTITLE_EXTENSIONS
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_select_validator import StringSelectValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import StringListValidator
from ytdl_sub.validators.validators import BoolValidator, StringListValidator
v: VariableDefinitions = VARIABLES
@ -57,6 +53,7 @@ class SubtitleOptions(ToggleableOptionsDictValidator):
"subtitles_type",
"embed_subtitles",
"languages",
"languages_required",
"allow_auto_generated_subtitles",
}
@ -76,6 +73,9 @@ class SubtitleOptions(ToggleableOptionsDictValidator):
self._languages = self._validate_key_if_present(
key="languages", validator=StringListValidator, default=["en"]
).list
self._languages_required = self._validate_key_if_present(
key="languages_required", validator=StringListValidator, default=[]
).list
self._allow_auto_generated_subtitles = self._validate_key_if_present(
key="allow_auto_generated_subtitles", validator=BoolValidator, default=False
).value
@ -121,6 +121,16 @@ class SubtitleOptions(ToggleableOptionsDictValidator):
"""
return [lang.value for lang in self._languages]
@property
def languages_required(self) -> Optional[List[str]]:
"""
:expected type: Optional[List[String]]
:description:
Language code(s) that are required to be present for downloads to continue. If missing,
ytdl-sub will throw an error. NOTE: currently this only checks file-based subtitles.
"""
return [lang.value for lang in self._languages_required]
@property
def allow_auto_generated_subtitles(self) -> Optional[bool]:
"""
@ -211,7 +221,21 @@ class SubtitlesPlugin(Plugin[SubtitleOptions]):
if self.plugin_options.embed_subtitles:
file_metadata = FileMetadata(f"Embedded subtitles with lang(s) {', '.join(langs)}")
if self.plugin_options.subtitles_name:
download_subtitle_lang_file_names: List[Tuple[str, str]] = []
for lang in langs:
download_subtitle_file_name = entry.base_filename(
ext=f"{lang}.{self.plugin_options.subtitles_type}"
)
if os.path.isfile(Path(self.working_directory) / download_subtitle_file_name):
download_subtitle_lang_file_names.append((lang, download_subtitle_file_name))
elif lang in self.plugin_options.languages_required:
raise UserThrownRuntimeError(
f"Required the subtitle lang {lang}, but the file could not be found for "
f"the entry {entry.title}."
)
for lang, file_name in download_subtitle_lang_file_names:
output_subtitle_file_name = self.overrides.apply_formatter(
formatter=self.plugin_options.subtitles_name,
entry=entry,
@ -219,9 +243,7 @@ class SubtitlesPlugin(Plugin[SubtitleOptions]):
)
self.save_file(
file_name=entry.base_filename(
ext=f"{lang}.{self.plugin_options.subtitles_type}"
),
file_name=file_name,
output_file_name=output_subtitle_file_name,
entry=entry,
)

View file

@ -1,7 +1,7 @@
import random
import time
from typing import Dict
from typing import Optional
from abc import ABC
from typing import Dict, Optional, Type, TypeVar
from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import Plugin
@ -10,68 +10,137 @@ from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import FloatValidator
from ytdl_sub.validators.string_formatter_validators import (
FloatFormatterValidator,
OverridesFloatFormatterValidator,
)
from ytdl_sub.validators.validators import ProbabilityValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("throttle-protection")
FloatValidatorT = TypeVar("FloatValidatorT", bound=FloatFormatterValidator)
class RandomizedRangeValidator(StrictDictValidator):
class _RandomizedRangeValidator(StrictDictValidator, ABC):
"""
Validator to specify a float range between [min, max)
Base class for range validation, to support both entry and static overrides.
"""
_float_validator: Type[FloatValidatorT]
_required_keys = {"max"}
_optional_keys = {"min"}
def __init__(self, name, value):
super().__init__(name, value)
self._max = self._validate_key(key="max", validator=FloatValidator).value
self._max = self._validate_key(key="max", validator=self._float_validator)
self._min = self._validate_key_if_present(
key="min", validator=FloatValidator, default=0.0
).value
key="min", validator=self._float_validator, default=0.0
)
if self._min < 0:
raise self._validation_exception("min must be greater than zero")
def _randomized_float(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
actualized_min = overrides.apply_formatter(self._min, entry=entry, expected_type=float)
actualized_max = overrides.apply_formatter(self._max, entry=entry, expected_type=float)
if self._max < self._min:
if actualized_min < 0:
raise self._validation_exception(
f"max ({self._max}) must be greater than or equal to min ({self._min})"
f"min must be greater than zero, received {actualized_min}"
)
if actualized_max < actualized_min:
raise self._validation_exception(
f"max ({actualized_max}) must be greater than or equal to min ({actualized_min})"
)
def min_value(self) -> float:
"""
Returns
-------
Minimum value
"""
return self._min
return random.uniform(actualized_min, actualized_max)
def max_value(self) -> float:
"""
Returns
-------
Maximum value
"""
return self._max
def randomized_float(self) -> float:
"""
Returns
-------
A random float within the range
"""
return random.uniform(self._min, self._max)
def randomized_int(self) -> int:
def _randomized_int(self, overrides: Overrides, entry: Optional[Entry] = None) -> int:
"""
Returns
-------
A random float within the range, then cast to an integer (floored)
"""
return int(self.randomized_float())
return int(self._randomized_float(overrides, entry=entry))
def _max_value(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
"""
Returns
-------
Max possible value
"""
actualized_max = overrides.apply_formatter(self._max, entry=entry, expected_type=float)
if actualized_max < 0:
raise self._validation_exception(
f"max must be greater than zero, received {actualized_max}"
)
return actualized_max
class RandomizedRangeValidator(_RandomizedRangeValidator):
"""
Validator to specify a float range between [min, max) with both
override and entry variable support.
"""
_float_validator = FloatFormatterValidator
def randomized_float(self, overrides: Overrides, entry: Entry) -> float:
"""
Returns
-------
A random float within the range
"""
return self._randomized_float(overrides=overrides, entry=entry)
def randomized_int(self, overrides: Overrides, entry: Entry) -> int:
"""
Returns
-------
A random float within the range, then cast to an integer (floored)
"""
return self._randomized_int(overrides=overrides, entry=entry)
def max_value(self, overrides: Overrides, entry: Entry) -> float:
"""
Returns
-------
Max possible value
"""
return self._max_value(overrides=overrides, entry=entry)
class RandomizedRangeOverridesValidator(_RandomizedRangeValidator):
"""
Validator to specify a float range between [min, max) with
static variable support.
"""
_float_validator = OverridesFloatFormatterValidator
def randomized_float(self, overrides: Overrides) -> float:
"""
Returns
-------
A random float within the range
"""
return self._randomized_float(overrides=overrides)
def randomized_int(self, overrides: Overrides) -> int:
"""
Returns
-------
A random float within the range, then cast to an integer (floored)
"""
return self._randomized_int(overrides=overrides)
def max_value(self, overrides: Overrides) -> float:
"""
Returns
-------
Max possible value
"""
return self._max_value(overrides=overrides)
class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
@ -80,6 +149,9 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
range-based values, a random number will be chosen within the range to avoid sleeps looking
scripted.
Range min and max values support static override variables within their definitions.
``sleep_per_download_s`` supports both static and override variables.
:Usage:
.. code-block:: yaml
@ -115,16 +187,16 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
super().__init__(name, value)
self._sleep_per_request_s = self._validate_key_if_present(
key="sleep_per_request_s", validator=RandomizedRangeValidator
key="sleep_per_request_s", validator=RandomizedRangeOverridesValidator
)
self._sleep_per_download_s = self._validate_key_if_present(
key="sleep_per_download_s", validator=RandomizedRangeValidator
)
self._sleep_per_subscription_s = self._validate_key_if_present(
key="sleep_per_subscription_s", validator=RandomizedRangeValidator
key="sleep_per_subscription_s", validator=RandomizedRangeOverridesValidator
)
self._max_downloads_per_subscription = self._validate_key_if_present(
key="max_downloads_per_subscription", validator=RandomizedRangeValidator
key="max_downloads_per_subscription", validator=RandomizedRangeOverridesValidator
)
self._subscription_download_probability = self._validate_key_if_present(
key="subscription_download_probability", validator=ProbabilityValidator
@ -202,15 +274,25 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
self._subscription_download_counter: int = 0
self._subscription_max_downloads: Optional[int] = None
# Compute this during post-processing using entry metadata.
# Apply the sleep post-completion.
self._entry_sleep_time: Optional[float] = None
# If subscriptions have a max download limit, set it here for the first subscription
if self.plugin_options.max_downloads_per_subscription:
self._subscription_max_downloads = (
self.plugin_options.max_downloads_per_subscription.randomized_int()
self.plugin_options.max_downloads_per_subscription.randomized_int(
overrides=self.overrides
)
)
def ytdl_options(self) -> Optional[Dict]:
if self.plugin_options.sleep_per_request_s is not None:
return {"sleep_interval_requests": self.plugin_options.sleep_per_request_s.max_value()}
return {
"sleep_interval_requests": self.plugin_options.sleep_per_request_s.max_value(
overrides=self.overrides
)
}
return {}
def initialize_subscription(self) -> bool:
@ -254,12 +336,19 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
self._subscription_download_counter += 1
if self.plugin_options.sleep_per_download_s:
sleep_time = self.plugin_options.sleep_per_download_s.randomized_float()
logger.info("Sleeping between downloads for %0.2f seconds", sleep_time)
self.perform_sleep(sleep_time)
self._entry_sleep_time = self.plugin_options.sleep_per_download_s.randomized_float(
overrides=self.overrides, entry=entry
)
return None
def post_completion_entry(self, file_metadata: FileMetadata) -> None:
if self._entry_sleep_time:
# pylint: disable=logging-fstring-interpolation)
# needed to test logs in unit test
logger.info(f"Sleeping between downloads for {self._entry_sleep_time:.2f} seconds")
self.perform_sleep(self._entry_sleep_time)
def post_process_subscription(self):
# Reset counter to 0 for the next subscription
self._subscription_download_counter = 0
@ -267,10 +356,14 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
# If present, reset max downloads for the next subscription
if self.plugin_options.max_downloads_per_subscription:
self._subscription_max_downloads = (
self.plugin_options.max_downloads_per_subscription.randomized_int
self.plugin_options.max_downloads_per_subscription.randomized_int(
overrides=self.overrides
)
)
if self.plugin_options.sleep_per_subscription_s:
sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float()
sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float(
overrides=self.overrides
)
logger.info("Sleeping between subscriptions for %0.2f seconds", sleep_time)
self.perform_sleep(sleep_time)

View file

@ -34,7 +34,7 @@ class VideoTagsPlugin(Plugin[VideoTagsOptions]):
Tags the entry's audio file using values defined in the metadata options
"""
tags_to_write: Dict[str, str] = {}
for tag_name, tag_formatter in self.plugin_options.dict.items():
for tag_name, tag_formatter in sorted(self.plugin_options.dict.items()):
tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)
tags_to_write[tag_name] = tag_value

View file

@ -1,8 +1,6 @@
import os
import pathlib
from typing import Any
from typing import Dict
from typing import Set
from typing import Any, Dict, Set
import mergedeep

View file

@ -11,8 +11,7 @@ presets:
# Set the default date_range to 2 months
overrides:
date_range: "2months" # keep for legacy-reasons
only_recent_date_range: "{date_range}"
only_recent_date_range: "2months"
#############################################################################
# Only Recent

View file

@ -1,9 +1,8 @@
presets:
#############################################################################
# Include Keywords
# Include or exclude media with any of the listed keywords in their titles
# Keywords will check a lower-cased title or description
# Filter Duration
# Include or exclude media based on its play time duration
Filter Duration:
overrides:
filter_duration_min_s: 0

View file

@ -43,9 +43,20 @@ presets:
"Resolution assert is enabled, will fail on low-quality video downloads and presume throttle. Disable using the override variable `enable_resolution_assert: False`",
"Resolution assert is disabled. Use at your own risk!"
),
enable_resolution_assert
enable_resolution_assert,
-1
)
}
# list variable that contains partial titles to ignore
resolution_assert_ignore_titles: "{ [] }"
resolution_assert_is_ignored: >-
{
%print_if_true(
%concat(title, " has a match in resolution_assert_ignore_titles, skipping resolution assert."),
%contains_any(title, resolution_assert_ignore_titles)
)
}
resolution_readable: "{width}x{height}"
# height is not a guaranteed populated variable, do not assert if it's missing (which defaults to 0)
resolution_assert: >-
@ -53,7 +64,8 @@ presets:
%if(
%and(
enable_resolution_assert,
%ne( height, 0 )
%ne( height, 0 ),
%not(resolution_assert_is_ignored)
),
%assert(
%gte( height, resolution_assert_height_gte ),

View file

@ -14,7 +14,7 @@ presets:
#
_multi_url:
download:
- url: "{url}"
- url: "{ %array_at(urls, 0) }"
playlist_thumbnails:
- name: "{avatar_uncropped_thumbnail_file_name}"
uid: "avatar_uncropped"
@ -26,411 +26,135 @@ presets:
- name: "{banner_uncropped_thumbnail_file_name}"
uid: "banner_uncropped"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url2}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url3}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url4}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url5}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url6}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url7}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url8}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url9}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url10}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url11}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url12}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url13}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url14}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url15}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url16}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url17}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url18}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url19}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url20}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url21}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url22}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url23}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url24}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url25}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url26}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url27}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url28}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url29}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url30}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url31}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url32}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url33}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url34}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url35}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url36}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url37}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url38}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url39}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url40}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url41}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url42}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url43}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url44}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url45}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url46}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url47}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url48}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url49}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url50}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url51}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url52}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url53}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url54}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url55}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url56}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url57}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url58}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url59}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url60}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url61}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url62}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url63}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url64}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url65}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url66}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url67}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url68}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url69}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url70}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url71}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url72}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url73}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url74}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url75}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url76}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url77}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url78}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url79}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url80}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url81}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url82}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url83}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url84}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url85}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url86}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url87}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url88}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url89}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url90}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url91}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url92}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url93}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url94}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url95}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url96}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url97}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url98}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url99}"
include_sibling_metadata: "{include_sibling_metadata}"
- url: "{url100}"
webpage_url: "{modified_webpage_url}"
- url: "{ %array_slice(urls, 1) }"
include_sibling_metadata: "{include_sibling_metadata}"
webpage_url: "{modified_webpage_url}"
overrides:
avatar_uncropped_thumbnail_file_name: ""
banner_uncropped_thumbnail_file_name: ""
include_sibling_metadata: False
modified_webpage_url: "{webpage_url}"
subscription_array: >-
{
[
url, url2, url3, url4, url5, url6, url7, url8, url9, url10,
url11, url12, url13, url14, url15, url16, url17, url18, url19, url20,
url21, url22, url23, url24, url25, url26, url27, url28, url29, url30,
url31, url32, url33, url34, url35, url36, url37, url38, url39, url40,
url41, url42, url43, url44, url45, url46, url47, url48, url49, url50,
url51, url52, url53, url54, url55, url56, url57, url58, url59, url60,
url61, url62, url63, url64, url65, url66, url67, url68, url69, url70,
url71, url72, url73, url74, url75, url76, url77, url78, url79, url80,
url81, url82, url83, url84, url85, url86, url87, url88, url89, url90,
url91, url92, url93, url94, url95, url96, url97, url98, url99, url100
]
}
urls: "{subscription_array}"
subscription_array: "{ [] }"
subscription_value: ""
subscription_value_2: ""
subscription_value_3: ""
subscription_value_4: ""
subscription_value_5: ""
subscription_value_6: ""
subscription_value_7: ""
subscription_value_8: ""
subscription_value_9: ""
subscription_value_10: ""
subscription_value_11: ""
subscription_value_12: ""
subscription_value_13: ""
subscription_value_14: ""
subscription_value_15: ""
subscription_value_16: ""
subscription_value_17: ""
subscription_value_18: ""
subscription_value_19: ""
subscription_value_20: ""
subscription_value_21: ""
subscription_value_22: ""
subscription_value_23: ""
subscription_value_24: ""
subscription_value_25: ""
subscription_value_26: ""
subscription_value_27: ""
subscription_value_28: ""
subscription_value_29: ""
subscription_value_30: ""
subscription_value_31: ""
subscription_value_32: ""
subscription_value_33: ""
subscription_value_34: ""
subscription_value_35: ""
subscription_value_36: ""
subscription_value_37: ""
subscription_value_38: ""
subscription_value_39: ""
subscription_value_40: ""
subscription_value_41: ""
subscription_value_42: ""
subscription_value_43: ""
subscription_value_44: ""
subscription_value_45: ""
subscription_value_46: ""
subscription_value_47: ""
subscription_value_48: ""
subscription_value_49: ""
subscription_value_50: ""
subscription_value_51: ""
subscription_value_52: ""
subscription_value_53: ""
subscription_value_54: ""
subscription_value_55: ""
subscription_value_56: ""
subscription_value_57: ""
subscription_value_58: ""
subscription_value_59: ""
subscription_value_60: ""
subscription_value_61: ""
subscription_value_62: ""
subscription_value_63: ""
subscription_value_64: ""
subscription_value_65: ""
subscription_value_66: ""
subscription_value_67: ""
subscription_value_68: ""
subscription_value_69: ""
subscription_value_70: ""
subscription_value_71: ""
subscription_value_72: ""
subscription_value_73: ""
subscription_value_74: ""
subscription_value_75: ""
subscription_value_76: ""
subscription_value_77: ""
subscription_value_78: ""
subscription_value_79: ""
subscription_value_80: ""
subscription_value_81: ""
subscription_value_82: ""
subscription_value_83: ""
subscription_value_84: ""
subscription_value_85: ""
subscription_value_86: ""
subscription_value_87: ""
subscription_value_88: ""
subscription_value_89: ""
subscription_value_90: ""
subscription_value_91: ""
subscription_value_92: ""
subscription_value_93: ""
subscription_value_94: ""
subscription_value_95: ""
subscription_value_96: ""
subscription_value_97: ""
subscription_value_98: ""
subscription_value_99: ""
subscription_value_100: ""
url: "{subscription_value}"
url2: "{subscription_value_2}"
url3: "{subscription_value_3}"
url4: "{subscription_value_4}"
url5: "{subscription_value_5}"
url6: "{subscription_value_6}"
url7: "{subscription_value_7}"
url8: "{subscription_value_8}"
url9: "{subscription_value_9}"
url10: "{subscription_value_10}"
url11: "{subscription_value_11}"
url12: "{subscription_value_12}"
url13: "{subscription_value_13}"
url14: "{subscription_value_14}"
url15: "{subscription_value_15}"
url16: "{subscription_value_16}"
url17: "{subscription_value_17}"
url18: "{subscription_value_18}"
url19: "{subscription_value_19}"
url20: "{subscription_value_20}"
url21: "{subscription_value_21}"
url22: "{subscription_value_22}"
url23: "{subscription_value_23}"
url24: "{subscription_value_24}"
url25: "{subscription_value_25}"
url26: "{subscription_value_26}"
url27: "{subscription_value_27}"
url28: "{subscription_value_28}"
url29: "{subscription_value_29}"
url30: "{subscription_value_30}"
url31: "{subscription_value_31}"
url32: "{subscription_value_32}"
url33: "{subscription_value_33}"
url34: "{subscription_value_34}"
url35: "{subscription_value_35}"
url36: "{subscription_value_36}"
url37: "{subscription_value_37}"
url38: "{subscription_value_38}"
url39: "{subscription_value_39}"
url40: "{subscription_value_40}"
url41: "{subscription_value_41}"
url42: "{subscription_value_42}"
url43: "{subscription_value_43}"
url44: "{subscription_value_44}"
url45: "{subscription_value_45}"
url46: "{subscription_value_46}"
url47: "{subscription_value_47}"
url48: "{subscription_value_48}"
url49: "{subscription_value_49}"
url50: "{subscription_value_50}"
url51: "{subscription_value_51}"
url52: "{subscription_value_52}"
url53: "{subscription_value_53}"
url54: "{subscription_value_54}"
url55: "{subscription_value_55}"
url56: "{subscription_value_56}"
url57: "{subscription_value_57}"
url58: "{subscription_value_58}"
url59: "{subscription_value_59}"
url60: "{subscription_value_60}"
url61: "{subscription_value_61}"
url62: "{subscription_value_62}"
url63: "{subscription_value_63}"
url64: "{subscription_value_64}"
url65: "{subscription_value_65}"
url66: "{subscription_value_66}"
url67: "{subscription_value_67}"
url68: "{subscription_value_68}"
url69: "{subscription_value_69}"
url70: "{subscription_value_70}"
url71: "{subscription_value_71}"
url72: "{subscription_value_72}"
url73: "{subscription_value_73}"
url74: "{subscription_value_74}"
url75: "{subscription_value_75}"
url76: "{subscription_value_76}"
url77: "{subscription_value_77}"
url78: "{subscription_value_78}"
url79: "{subscription_value_79}"
url80: "{subscription_value_80}"
url81: "{subscription_value_81}"
url82: "{subscription_value_82}"
url83: "{subscription_value_83}"
url84: "{subscription_value_84}"
url85: "{subscription_value_85}"
url86: "{subscription_value_86}"
url87: "{subscription_value_87}"
url88: "{subscription_value_88}"
url89: "{subscription_value_89}"
url90: "{subscription_value_90}"
url91: "{subscription_value_91}"
url92: "{subscription_value_92}"
url93: "{subscription_value_93}"
url94: "{subscription_value_94}"
url95: "{subscription_value_95}"
url96: "{subscription_value_96}"
url97: "{subscription_value_97}"
url98: "{subscription_value_98}"
url99: "{subscription_value_99}"
url100: "{subscription_value_100}"
url2: ""
url3: ""
url4: ""
url5: ""
url6: ""
url7: ""
url8: ""
url9: ""
url10: ""
url11: ""
url12: ""
url13: ""
url14: ""
url15: ""
url16: ""
url17: ""
url18: ""
url19: ""
url20: ""
url21: ""
url22: ""
url23: ""
url24: ""
url25: ""
url26: ""
url27: ""
url28: ""
url29: ""
url30: ""
url31: ""
url32: ""
url33: ""
url34: ""
url35: ""
url36: ""
url37: ""
url38: ""
url39: ""
url40: ""
url41: ""
url42: ""
url43: ""
url44: ""
url45: ""
url46: ""
url47: ""
url48: ""
url49: ""
url50: ""
url51: ""
url52: ""
url53: ""
url54: ""
url55: ""
url56: ""
url57: ""
url58: ""
url59: ""
url60: ""
url61: ""
url62: ""
url63: ""
url64: ""
url65: ""
url66: ""
url67: ""
url68: ""
url69: ""
url70: ""
url71: ""
url72: ""
url73: ""
url74: ""
url75: ""
url76: ""
url77: ""
url78: ""
url79: ""
url80: ""
url81: ""
url82: ""
url83: ""
url84: ""
url85: ""
url86: ""
url87: ""
url88: ""
url89: ""
url90: ""
url91: ""
url92: ""
url93: ""
url94: ""
url95: ""
url96: ""
url97: ""
url98: ""
url99: ""
url100: ""
# multi-url with bilateral scraping built into it via
@ -441,406 +165,11 @@ presets:
- "_url_bilateral_overrides"
download:
- url: "{%bilateral_url(url) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url2) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url3) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url4) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url5) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url6) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url7) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url8) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url9) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url10) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url11) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url12) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url13) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url14) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url15) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url16) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url17) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url18) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url19) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url20) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url21) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url22) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url23) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url24) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url25) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url26) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url27) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url28) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url29) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url30) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url31) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url32) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url33) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url34) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url35) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url36) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url37) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url38) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url39) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url40) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url41) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url42) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url43) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url44) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url45) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url46) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url47) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url48) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url49) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url50) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url51) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url52) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url53) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url54) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url55) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url56) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url57) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url58) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url59) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url60) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url61) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url62) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url63) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url64) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url65) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url66) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url67) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url68) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url69) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url70) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url71) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url72) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url73) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url74) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url75) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url76) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url77) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url78) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url79) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url80) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url81) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url82) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url83) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url84) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url85) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url86) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url87) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url88) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url89) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url90) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url91) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url92) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url93) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url94) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url95) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url96) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url97) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url98) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url99) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{%bilateral_url(url100) }"
- url: "{ %array_apply(urls, %bilateral_url) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
webpage_url: "{modified_webpage_url}"
_multi_url_bilateral:
preset:

View file

@ -108,103 +108,4 @@ presets:
}
subscription_map: "{ {} }"
url: "{ %get_url_i(1) }"
url2: "{ %get_url_i(2) }"
url3: "{ %get_url_i(3) }"
url4: "{ %get_url_i(4) }"
url5: "{ %get_url_i(5) }"
url6: "{ %get_url_i(6) }"
url7: "{ %get_url_i(7) }"
url8: "{ %get_url_i(8) }"
url9: "{ %get_url_i(9) }"
url10: "{ %get_url_i(10) }"
url11: "{ %get_url_i(11) }"
url12: "{ %get_url_i(12) }"
url13: "{ %get_url_i(13) }"
url14: "{ %get_url_i(14) }"
url15: "{ %get_url_i(15) }"
url16: "{ %get_url_i(16) }"
url17: "{ %get_url_i(17) }"
url18: "{ %get_url_i(18) }"
url19: "{ %get_url_i(19) }"
url20: "{ %get_url_i(20) }"
url21: "{ %get_url_i(21) }"
url22: "{ %get_url_i(22) }"
url23: "{ %get_url_i(23) }"
url24: "{ %get_url_i(24) }"
url25: "{ %get_url_i(25) }"
url26: "{ %get_url_i(26) }"
url27: "{ %get_url_i(27) }"
url28: "{ %get_url_i(28) }"
url29: "{ %get_url_i(29) }"
url30: "{ %get_url_i(30) }"
url31: "{ %get_url_i(31) }"
url32: "{ %get_url_i(32) }"
url33: "{ %get_url_i(33) }"
url34: "{ %get_url_i(34) }"
url35: "{ %get_url_i(35) }"
url36: "{ %get_url_i(36) }"
url37: "{ %get_url_i(37) }"
url38: "{ %get_url_i(38) }"
url39: "{ %get_url_i(39) }"
url40: "{ %get_url_i(40) }"
url41: "{ %get_url_i(41) }"
url42: "{ %get_url_i(42) }"
url43: "{ %get_url_i(43) }"
url44: "{ %get_url_i(44) }"
url45: "{ %get_url_i(45) }"
url46: "{ %get_url_i(46) }"
url47: "{ %get_url_i(47) }"
url48: "{ %get_url_i(48) }"
url49: "{ %get_url_i(49) }"
url50: "{ %get_url_i(50) }"
url51: "{ %get_url_i(51) }"
url52: "{ %get_url_i(52) }"
url53: "{ %get_url_i(53) }"
url54: "{ %get_url_i(54) }"
url55: "{ %get_url_i(55) }"
url56: "{ %get_url_i(56) }"
url57: "{ %get_url_i(57) }"
url58: "{ %get_url_i(58) }"
url59: "{ %get_url_i(59) }"
url60: "{ %get_url_i(60) }"
url61: "{ %get_url_i(61) }"
url62: "{ %get_url_i(62) }"
url63: "{ %get_url_i(63) }"
url64: "{ %get_url_i(64) }"
url65: "{ %get_url_i(65) }"
url66: "{ %get_url_i(66) }"
url67: "{ %get_url_i(67) }"
url68: "{ %get_url_i(68) }"
url69: "{ %get_url_i(69) }"
url70: "{ %get_url_i(70) }"
url71: "{ %get_url_i(71) }"
url72: "{ %get_url_i(72) }"
url73: "{ %get_url_i(73) }"
url74: "{ %get_url_i(74) }"
url75: "{ %get_url_i(75) }"
url76: "{ %get_url_i(76) }"
url77: "{ %get_url_i(77) }"
url78: "{ %get_url_i(78) }"
url79: "{ %get_url_i(79) }"
url80: "{ %get_url_i(80) }"
url81: "{ %get_url_i(81) }"
url82: "{ %get_url_i(82) }"
url83: "{ %get_url_i(83) }"
url84: "{ %get_url_i(84) }"
url85: "{ %get_url_i(85) }"
url86: "{ %get_url_i(86) }"
url87: "{ %get_url_i(87) }"
url88: "{ %get_url_i(88) }"
url89: "{ %get_url_i(89) }"
url90: "{ %get_url_i(90) }"
url91: "{ %get_url_i(91) }"
url92: "{ %get_url_i(92) }"
url93: "{ %get_url_i(93) }"
url94: "{ %get_url_i(94) }"
url95: "{ %get_url_i(95) }"
url96: "{ %get_url_i(96) }"
url97: "{ %get_url_i(97) }"
url98: "{ %get_url_i(98) }"
url99: "{ %get_url_i(99) }"
url100: "{ %get_url_i(100) }"
urls: "{ %array_apply(%range(100, 1), %get_url_i) }"

View file

@ -1,5 +1,4 @@
from ytdl_sub.prebuilt_presets import PrebuiltPresets
from ytdl_sub.prebuilt_presets import get_prebuilt_preset_package_name
from ytdl_sub.prebuilt_presets import PrebuiltPresets, get_prebuilt_preset_package_name
PREBUILT_PRESET_PACKAGE_NAME = get_prebuilt_preset_package_name(__file__)

View file

@ -8,7 +8,7 @@ presets:
urls:
# The first URL will be all the artist's tracks.
# Treat these as singles - an album with a single track
- url: "{url}/tracks"
- url: "{url}"
include_sibling_metadata: False
variables:
sc_track_album: "{title}"

View file

@ -1,5 +1,4 @@
from ytdl_sub.prebuilt_presets import PrebuiltPresets
from ytdl_sub.prebuilt_presets import get_prebuilt_preset_package_name
from ytdl_sub.prebuilt_presets import PrebuiltPresets, get_prebuilt_preset_package_name
PREBUILT_PRESET_PACKAGE_NAME = get_prebuilt_preset_package_name(__file__)

View file

@ -1,5 +1,4 @@
from ytdl_sub.prebuilt_presets import PrebuiltPresets
from ytdl_sub.prebuilt_presets import get_prebuilt_preset_package_name
from ytdl_sub.prebuilt_presets import PrebuiltPresets, get_prebuilt_preset_package_name
PREBUILT_PRESET_PACKAGE_NAME = get_prebuilt_preset_package_name(__file__)

View file

@ -124,7 +124,7 @@ presets:
- "_tv_show_collection_asserts"
download:
- url: "{collection_season_1_url}"
- url: "{ %array_at( %get_season_urls(collection_season_1_url), 0) }"
variables:
collection_season_number: "1"
collection_season_name: "{collection_season_1_name}"
@ -142,318 +142,493 @@ presets:
uid: "avatar_uncropped"
- name: "{tv_show_fanart_file_name}"
uid: "banner_uncropped"
- url: "{ %array_slice( %get_season_urls(collection_season_1_url), 1) }"
variables:
collection_season_number: "1"
collection_season_name: "{collection_season_1_name}"
- url: "{collection_season_2_url}"
- url: "{ %array_at( %get_season_urls(collection_season_2_url), 0) }"
variables:
collection_season_number: "2"
collection_season_name: "{collection_season_2_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_2_url), 1) }"
variables:
collection_season_number: "2"
collection_season_name: "{collection_season_2_name}"
- url: "{collection_season_3_url}"
- url: "{ %array_at( %get_season_urls(collection_season_3_url), 0) }"
variables:
collection_season_number: "3"
collection_season_name: "{collection_season_3_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_3_url), 1) }"
variables:
collection_season_number: "3"
collection_season_name: "{collection_season_3_name}"
- url: "{collection_season_4_url}"
- url: "{ %array_at( %get_season_urls(collection_season_4_url), 0) }"
variables:
collection_season_number: "4"
collection_season_name: "{collection_season_4_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_4_url), 1) }"
variables:
collection_season_number: "4"
collection_season_name: "{collection_season_4_name}"
- url: "{collection_season_5_url}"
- url: "{ %array_at( %get_season_urls(collection_season_5_url), 0) }"
variables:
collection_season_number: "5"
collection_season_name: "{collection_season_5_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_5_url), 1) }"
variables:
collection_season_number: "5"
collection_season_name: "{collection_season_5_name}"
- url: "{collection_season_6_url}"
- url: "{ %array_at( %get_season_urls(collection_season_6_url), 0) }"
variables:
collection_season_number: "6"
collection_season_name: "{collection_season_6_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_6_url), 1) }"
variables:
collection_season_number: "6"
collection_season_name: "{collection_season_6_name}"
- url: "{collection_season_7_url}"
- url: "{ %array_at( %get_season_urls(collection_season_7_url), 0) }"
variables:
collection_season_number: "7"
collection_season_name: "{collection_season_7_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_7_url), 1) }"
variables:
collection_season_number: "7"
collection_season_name: "{collection_season_7_name}"
- url: "{collection_season_8_url}"
- url: "{ %array_at( %get_season_urls(collection_season_8_url), 0) }"
variables:
collection_season_number: "8"
collection_season_name: "{collection_season_8_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_8_url), 1) }"
variables:
collection_season_number: "8"
collection_season_name: "{collection_season_8_name}"
- url: "{collection_season_9_url}"
- url: "{ %array_at( %get_season_urls(collection_season_9_url), 0) }"
variables:
collection_season_number: "9"
collection_season_name: "{collection_season_9_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_9_url), 1) }"
variables:
collection_season_number: "9"
collection_season_name: "{collection_season_9_name}"
- url: "{collection_season_10_url}"
- url: "{ %array_at( %get_season_urls(collection_season_10_url), 0) }"
variables:
collection_season_number: "10"
collection_season_name: "{collection_season_10_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_10_url), 1) }"
variables:
collection_season_number: "10"
collection_season_name: "{collection_season_10_name}"
- url: "{collection_season_11_url}"
- url: "{ %array_at( %get_season_urls(collection_season_11_url), 0) }"
variables:
collection_season_number: "11"
collection_season_name: "{collection_season_11_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_11_url), 1) }"
variables:
collection_season_number: "11"
collection_season_name: "{collection_season_11_name}"
- url: "{collection_season_12_url}"
- url: "{ %array_at( %get_season_urls(collection_season_12_url), 0) }"
variables:
collection_season_number: "12"
collection_season_name: "{collection_season_12_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_12_url), 1) }"
variables:
collection_season_number: "12"
collection_season_name: "{collection_season_12_name}"
- url: "{collection_season_13_url}"
- url: "{ %array_at( %get_season_urls(collection_season_13_url), 0) }"
variables:
collection_season_number: "13"
collection_season_name: "{collection_season_13_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_13_url), 1) }"
variables:
collection_season_number: "13"
collection_season_name: "{collection_season_13_name}"
- url: "{collection_season_14_url}"
- url: "{ %array_at( %get_season_urls(collection_season_14_url), 0) }"
variables:
collection_season_number: "14"
collection_season_name: "{collection_season_14_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_14_url), 1) }"
variables:
collection_season_number: "14"
collection_season_name: "{collection_season_14_name}"
- url: "{collection_season_15_url}"
- url: "{ %array_at( %get_season_urls(collection_season_15_url), 0) }"
variables:
collection_season_number: "15"
collection_season_name: "{collection_season_15_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_15_url), 1) }"
variables:
collection_season_number: "15"
collection_season_name: "{collection_season_15_name}"
- url: "{collection_season_16_url}"
- url: "{ %array_at( %get_season_urls(collection_season_16_url), 0) }"
variables:
collection_season_number: "16"
collection_season_name: "{collection_season_16_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_16_url), 1) }"
variables:
collection_season_number: "16"
collection_season_name: "{collection_season_16_name}"
- url: "{collection_season_17_url}"
- url: "{ %array_at( %get_season_urls(collection_season_17_url), 0) }"
variables:
collection_season_number: "17"
collection_season_name: "{collection_season_17_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_17_url), 1) }"
variables:
collection_season_number: "17"
collection_season_name: "{collection_season_17_name}"
- url: "{collection_season_18_url}"
- url: "{ %array_at( %get_season_urls(collection_season_18_url), 0) }"
variables:
collection_season_number: "18"
collection_season_name: "{collection_season_18_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_18_url), 1) }"
variables:
collection_season_number: "18"
collection_season_name: "{collection_season_18_name}"
- url: "{collection_season_19_url}"
- url: "{ %array_at( %get_season_urls(collection_season_19_url), 0) }"
variables:
collection_season_number: "19"
collection_season_name: "{collection_season_19_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_19_url), 1) }"
variables:
collection_season_number: "19"
collection_season_name: "{collection_season_19_name}"
- url: "{collection_season_20_url}"
- url: "{ %array_at( %get_season_urls(collection_season_20_url), 0) }"
variables:
collection_season_number: "20"
collection_season_name: "{collection_season_20_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_20_url), 1) }"
variables:
collection_season_number: "20"
collection_season_name: "{collection_season_20_name}"
- url: "{collection_season_21_url}"
- url: "{ %array_at( %get_season_urls(collection_season_21_url), 0) }"
variables:
collection_season_number: "21"
collection_season_name: "{collection_season_21_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_21_url), 1) }"
variables:
collection_season_number: "21"
collection_season_name: "{collection_season_21_name}"
- url: "{collection_season_22_url}"
- url: "{ %array_at( %get_season_urls(collection_season_22_url), 0) }"
variables:
collection_season_number: "22"
collection_season_name: "{collection_season_22_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_22_url), 1) }"
variables:
collection_season_number: "22"
collection_season_name: "{collection_season_22_name}"
- url: "{collection_season_23_url}"
- url: "{ %array_at( %get_season_urls(collection_season_23_url), 0) }"
variables:
collection_season_number: "23"
collection_season_name: "{collection_season_23_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_23_url), 1) }"
variables:
collection_season_number: "23"
collection_season_name: "{collection_season_23_name}"
- url: "{collection_season_24_url}"
- url: "{ %array_at( %get_season_urls(collection_season_24_url), 0) }"
variables:
collection_season_number: "24"
collection_season_name: "{collection_season_24_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_24_url), 1) }"
variables:
collection_season_number: "24"
collection_season_name: "{collection_season_24_name}"
- url: "{collection_season_25_url}"
- url: "{ %array_at( %get_season_urls(collection_season_25_url), 0) }"
variables:
collection_season_number: "25"
collection_season_name: "{collection_season_25_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_25_url), 1) }"
variables:
collection_season_number: "25"
collection_season_name: "{collection_season_25_name}"
- url: "{collection_season_26_url}"
- url: "{ %array_at( %get_season_urls(collection_season_26_url), 0) }"
variables:
collection_season_number: "26"
collection_season_name: "{collection_season_26_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_26_url), 1) }"
variables:
collection_season_number: "26"
collection_season_name: "{collection_season_26_name}"
- url: "{collection_season_27_url}"
- url: "{ %array_at( %get_season_urls(collection_season_27_url), 0) }"
variables:
collection_season_number: "27"
collection_season_name: "{collection_season_27_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_27_url), 1) }"
variables:
collection_season_number: "27"
collection_season_name: "{collection_season_27_name}"
- url: "{collection_season_28_url}"
- url: "{ %array_at( %get_season_urls(collection_season_28_url), 0) }"
variables:
collection_season_number: "28"
collection_season_name: "{collection_season_28_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_28_url), 1) }"
variables:
collection_season_number: "28"
collection_season_name: "{collection_season_28_name}"
- url: "{collection_season_29_url}"
- url: "{ %array_at( %get_season_urls(collection_season_29_url), 0) }"
variables:
collection_season_number: "29"
collection_season_name: "{collection_season_29_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_29_url), 1) }"
variables:
collection_season_number: "29"
collection_season_name: "{collection_season_29_name}"
- url: "{collection_season_30_url}"
- url: "{ %array_at( %get_season_urls(collection_season_30_url), 0) }"
variables:
collection_season_number: "30"
collection_season_name: "{collection_season_30_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_30_url), 1) }"
variables:
collection_season_number: "30"
collection_season_name: "{collection_season_30_name}"
- url: "{collection_season_31_url}"
- url: "{ %array_at( %get_season_urls(collection_season_31_url), 0) }"
variables:
collection_season_number: "31"
collection_season_name: "{collection_season_31_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_31_url), 1) }"
variables:
collection_season_number: "31"
collection_season_name: "{collection_season_31_name}"
- url: "{collection_season_32_url}"
- url: "{ %array_at( %get_season_urls(collection_season_32_url), 0) }"
variables:
collection_season_number: "32"
collection_season_name: "{collection_season_32_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_32_url), 1) }"
variables:
collection_season_number: "32"
collection_season_name: "{collection_season_32_name}"
- url: "{collection_season_33_url}"
- url: "{ %array_at( %get_season_urls(collection_season_33_url), 0) }"
variables:
collection_season_number: "33"
collection_season_name: "{collection_season_33_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_33_url), 1) }"
variables:
collection_season_number: "33"
collection_season_name: "{collection_season_33_name}"
- url: "{collection_season_34_url}"
- url: "{ %array_at( %get_season_urls(collection_season_34_url), 0) }"
variables:
collection_season_number: "34"
collection_season_name: "{collection_season_34_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_34_url), 1) }"
variables:
collection_season_number: "34"
collection_season_name: "{collection_season_34_name}"
- url: "{collection_season_35_url}"
- url: "{ %array_at( %get_season_urls(collection_season_35_url), 0) }"
variables:
collection_season_number: "35"
collection_season_name: "{collection_season_35_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_35_url), 1) }"
variables:
collection_season_number: "35"
collection_season_name: "{collection_season_35_name}"
- url: "{collection_season_36_url}"
- url: "{ %array_at( %get_season_urls(collection_season_36_url), 0) }"
variables:
collection_season_number: "36"
collection_season_name: "{collection_season_36_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_36_url), 1) }"
variables:
collection_season_number: "36"
collection_season_name: "{collection_season_36_name}"
- url: "{collection_season_37_url}"
- url: "{ %array_at( %get_season_urls(collection_season_37_url), 0) }"
variables:
collection_season_number: "37"
collection_season_name: "{collection_season_37_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_37_url), 1) }"
variables:
collection_season_number: "37"
collection_season_name: "{collection_season_37_name}"
- url: "{collection_season_38_url}"
- url: "{ %array_at( %get_season_urls(collection_season_38_url), 0) }"
variables:
collection_season_number: "38"
collection_season_name: "{collection_season_38_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_38_url), 1) }"
variables:
collection_season_number: "38"
collection_season_name: "{collection_season_38_name}"
- url: "{collection_season_39_url}"
- url: "{ %array_at( %get_season_urls(collection_season_39_url), 0) }"
variables:
collection_season_number: "39"
collection_season_name: "{collection_season_39_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_39_url), 1) }"
variables:
collection_season_number: "39"
collection_season_name: "{collection_season_39_name}"
- url: "{collection_season_40_url}"
- url: "{ %array_at( %get_season_urls(collection_season_40_url), 0) }"
variables:
collection_season_number: "40"
collection_season_name: "{collection_season_40_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_40_url), 1) }"
variables:
collection_season_number: "40"
collection_season_name: "{collection_season_40_name}"
# Place season 0 at end
- url: "{ %array_at( %get_season_urls(collection_season_0_url), 0) }"
variables:
collection_season_number: "0"
collection_season_name: "{collection_season_0_name}"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{ %array_slice( %get_season_urls(collection_season_0_url), 1) }"
variables:
collection_season_number: "0"
collection_season_name: "{collection_season_0_name}"
output_directory_nfo_tags:
tags:
@ -625,6 +800,7 @@ presets:
collection_season_38_name: "{s38_name}"
collection_season_39_name: "{s39_name}"
collection_season_40_name: "{s40_name}"
collection_season_0_name: "{s00_name}"
# Legacy url variable
collection_season_1_url: "{s01_url}"
@ -667,6 +843,7 @@ presets:
collection_season_38_url: "{s38_url}"
collection_season_39_url: "{s39_url}"
collection_season_40_url: "{s40_url}"
collection_season_0_url: "{s00_url}"
s01_name: ""
s02_name: ""
@ -708,6 +885,7 @@ presets:
s38_name: ""
s39_name: ""
s40_name: ""
s00_name: ""
s01_url: ""
s02_url: ""
@ -749,13 +927,22 @@ presets:
s38_url: ""
s39_url: ""
s40_url: ""
s00_url: ""
"%get_season_urls": >-
{ %if( %is_array( $0 ), $0, [ $0 ] ) }
# $0 - season url variable
# $1 - get the i'th url from the array
"%get_season_url": >-
{ %array_at( %get_season_urls($0), $1, "" ) }
_tv_show_collection_bilateral:
preset:
- "_url_bilateral_overrides"
download:
- url: "{ %bilateral_url(collection_season_1_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_1_url), %bilateral_url) }"
variables:
collection_season_number: "1"
collection_season_name: "{collection_season_1_name}"
@ -763,7 +950,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_2_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_2_url), %bilateral_url) }"
variables:
collection_season_number: "2"
collection_season_name: "{collection_season_2_name}"
@ -771,7 +958,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_3_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_3_url), %bilateral_url) }"
variables:
collection_season_number: "3"
collection_season_name: "{collection_season_3_name}"
@ -779,7 +966,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_4_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_4_url), %bilateral_url) }"
variables:
collection_season_number: "4"
collection_season_name: "{collection_season_4_name}"
@ -787,7 +974,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_5_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_5_url), %bilateral_url) }"
variables:
collection_season_number: "5"
collection_season_name: "{collection_season_5_name}"
@ -795,7 +982,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_6_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_6_url), %bilateral_url) }"
variables:
collection_season_number: "6"
collection_season_name: "{collection_season_6_name}"
@ -803,7 +990,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_7_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_7_url), %bilateral_url) }"
variables:
collection_season_number: "7"
collection_season_name: "{collection_season_7_name}"
@ -811,7 +998,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_8_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_8_url), %bilateral_url) }"
variables:
collection_season_number: "8"
collection_season_name: "{collection_season_8_name}"
@ -819,7 +1006,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_9_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_9_url), %bilateral_url) }"
variables:
collection_season_number: "9"
collection_season_name: "{collection_season_9_name}"
@ -827,7 +1014,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_10_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_10_url), %bilateral_url) }"
variables:
collection_season_number: "10"
collection_season_name: "{collection_season_10_name}"
@ -835,7 +1022,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_11_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_11_url), %bilateral_url) }"
variables:
collection_season_number: "11"
collection_season_name: "{collection_season_11_name}"
@ -843,7 +1030,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_12_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_12_url), %bilateral_url) }"
variables:
collection_season_number: "12"
collection_season_name: "{collection_season_12_name}"
@ -851,7 +1038,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_13_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_13_url), %bilateral_url) }"
variables:
collection_season_number: "13"
collection_season_name: "{collection_season_13_name}"
@ -859,7 +1046,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_14_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_14_url), %bilateral_url) }"
variables:
collection_season_number: "14"
collection_season_name: "{collection_season_14_name}"
@ -867,7 +1054,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_15_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_15_url), %bilateral_url) }"
variables:
collection_season_number: "15"
collection_season_name: "{collection_season_15_name}"
@ -875,7 +1062,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_16_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_16_url), %bilateral_url) }"
variables:
collection_season_number: "16"
collection_season_name: "{collection_season_16_name}"
@ -883,7 +1070,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_17_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_17_url), %bilateral_url) }"
variables:
collection_season_number: "17"
collection_season_name: "{collection_season_17_name}"
@ -891,7 +1078,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_18_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_18_url), %bilateral_url) }"
variables:
collection_season_number: "18"
collection_season_name: "{collection_season_18_name}"
@ -899,7 +1086,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_19_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_19_url), %bilateral_url) }"
variables:
collection_season_number: "19"
collection_season_name: "{collection_season_19_name}"
@ -907,7 +1094,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_20_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_20_url), %bilateral_url) }"
variables:
collection_season_number: "20"
collection_season_name: "{collection_season_20_name}"
@ -915,7 +1102,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_21_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_21_url), %bilateral_url) }"
variables:
collection_season_number: "21"
collection_season_name: "{collection_season_21_name}"
@ -923,7 +1110,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_22_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_22_url), %bilateral_url) }"
variables:
collection_season_number: "22"
collection_season_name: "{collection_season_22_name}"
@ -931,7 +1118,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_23_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_23_url), %bilateral_url) }"
variables:
collection_season_number: "23"
collection_season_name: "{collection_season_23_name}"
@ -939,7 +1126,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_24_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_24_url), %bilateral_url) }"
variables:
collection_season_number: "24"
collection_season_name: "{collection_season_24_name}"
@ -947,7 +1134,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_25_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_25_url), %bilateral_url) }"
variables:
collection_season_number: "25"
collection_season_name: "{collection_season_25_name}"
@ -955,7 +1142,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_26_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_26_url), %bilateral_url) }"
variables:
collection_season_number: "26"
collection_season_name: "{collection_season_26_name}"
@ -963,7 +1150,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_27_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_27_url), %bilateral_url) }"
variables:
collection_season_number: "27"
collection_season_name: "{collection_season_27_name}"
@ -971,7 +1158,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_28_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_28_url), %bilateral_url) }"
variables:
collection_season_number: "28"
collection_season_name: "{collection_season_28_name}"
@ -979,7 +1166,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_29_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_29_url), %bilateral_url) }"
variables:
collection_season_number: "29"
collection_season_name: "{collection_season_29_name}"
@ -987,7 +1174,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_30_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_30_url), %bilateral_url) }"
variables:
collection_season_number: "30"
collection_season_name: "{collection_season_30_name}"
@ -995,7 +1182,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_31_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_31_url), %bilateral_url) }"
variables:
collection_season_number: "31"
collection_season_name: "{collection_season_31_name}"
@ -1003,7 +1190,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_32_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_32_url), %bilateral_url) }"
variables:
collection_season_number: "32"
collection_season_name: "{collection_season_32_name}"
@ -1011,7 +1198,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_33_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_33_url), %bilateral_url) }"
variables:
collection_season_number: "33"
collection_season_name: "{collection_season_33_name}"
@ -1019,7 +1206,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_34_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_34_url), %bilateral_url) }"
variables:
collection_season_number: "34"
collection_season_name: "{collection_season_34_name}"
@ -1027,7 +1214,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_35_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_35_url), %bilateral_url) }"
variables:
collection_season_number: "35"
collection_season_name: "{collection_season_35_name}"
@ -1035,7 +1222,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_36_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_36_url), %bilateral_url) }"
variables:
collection_season_number: "36"
collection_season_name: "{collection_season_36_name}"
@ -1043,7 +1230,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_37_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_37_url), %bilateral_url) }"
variables:
collection_season_number: "37"
collection_season_name: "{collection_season_37_name}"
@ -1051,7 +1238,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_38_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_38_url), %bilateral_url) }"
variables:
collection_season_number: "38"
collection_season_name: "{collection_season_38_name}"
@ -1059,7 +1246,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_39_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_39_url), %bilateral_url) }"
variables:
collection_season_number: "39"
collection_season_name: "{collection_season_39_name}"
@ -1067,7 +1254,7 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(collection_season_40_url) }"
- url: "{ %array_apply( %get_season_urls(collection_season_40_url), %bilateral_url) }"
variables:
collection_season_number: "40"
collection_season_name: "{collection_season_40_name}"
@ -1075,6 +1262,15 @@ presets:
ytdl_options:
playlist_items: "-1:0:-1"
# Season 0 at end (to download first)
- url: "{ %array_apply( %get_season_urls(collection_season_0_url), %bilateral_url) }"
variables:
collection_season_number: "0"
collection_season_name: "{collection_season_0_name}"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
_tv_show_collection_asserts:
overrides:
url: ""

View file

@ -1,5 +1,4 @@
from typing import Callable
from typing import Dict
from typing import Callable, Dict
from ytdl_sub.script.functions.array_functions import ArrayFunctions
from ytdl_sub.script.functions.boolean_functions import BooleanFunctions

Some files were not shown because too many files have changed in this diff Show more