Merge branch 'master' into j/ruff-linter

This commit is contained in:
Jesse Bannon 2026-03-09 10:21:36 -07:00
commit 249f421295
113 changed files with 6551 additions and 2119 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

@ -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

@ -46,8 +46,10 @@ RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
phantomjs --version && \
cd -; \
fi && \
# 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 && \

View file

@ -65,8 +65,10 @@ RUN mkdir -p /config && \
# 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 && \
# Install ytdl-sub, ensure it is installed properly
python3 -m pip install --no-cache-dir --break-system-packages 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 && \

View file

@ -68,8 +68,10 @@ RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
# 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 && \
# Install ytdl-sub, ensure it is installed properly
python3 -m pip install --no-cache-dir --break-system-packages 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 && \

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

@ -17,25 +17,25 @@ 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]" --break-system-packages --root-user-action=ignore
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]" --root-user-action=ignore
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 --root-user-action=ignore
python3 -m pip install --force-reinstall "yt-dlp[default] @ https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz" --root-user-action=ignore
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
@ -47,6 +47,8 @@ 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\" >> \"$LOGS_TO_STDOUT\" 2>&1" >> "$CRON_WRAPPER_SCRIPT"

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

@ -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

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,7 +938,6 @@ 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]]
@ -987,7 +945,6 @@ It will set the respective language to the correct subtitle file.
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``
:expected type: Optional[EntryFormatter]
@ -997,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
@ -1045,14 +1000,12 @@ Range min and max values support static override variables within their definiti
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]
@ -1060,7 +1013,6 @@ Range min and max values support static override variables within their definiti
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]
@ -1070,14 +1022,12 @@ Range min and max values support static override variables within their definiti
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]
@ -1086,7 +1036,6 @@ Range min and max values support static override variables within their definiti
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``

View file

@ -31,7 +31,7 @@ supply a cookies file path.
# Directly set plugin options:
ytdl_options:
cookiefile: "/config/cookie.txt"
cookiefile: "/config/ytdl-sub-configs/cookie.txt"
Layout
------

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

@ -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,17 +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
----------
Once you have a significant quantity of subscriptions or have use cases not served using
:doc:`YAML keys and the special characters <./subscriptions>`, it's time to start
:doc:`defining your own custom presets <./first_config>`.
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

@ -3,13 +3,33 @@ 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:

View file

@ -134,7 +134,7 @@ 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.

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
---------------------------

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

@ -87,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
@ -105,3 +104,11 @@ 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]

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.10.14",
"yt-dlp[default]==2026.3.3",
"colorama~=0.4",
"mergedeep~=1.3",
"mediafile~=0.12",
@ -48,12 +48,12 @@ test = [
]
lint = [
"black==24.10.0",
"isort==6.1.0",
"pylint==3.3.8",
"isort==7.0.0",
"pylint==4.0.1",
"ruff==0.14.0",
]
docs = [
"sphinx>=7,<9",
"sphinx>=7,<10",
"sphinx-rtd-theme>=2,<4",
"sphinx-book-theme~=1.0",
"sphinx-copybutton~=0.5",

View file

@ -1,5 +1,6 @@
import gc
import os
import random
import sys
from datetime import datetime
from pathlib import Path
@ -12,6 +13,7 @@ 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.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
@ -74,6 +76,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 +93,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 +116,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(
@ -206,6 +215,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)
@ -248,6 +261,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 +277,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

@ -0,0 +1,64 @@
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

@ -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,7 @@ 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")

View file

@ -12,6 +12,8 @@ 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.prebuilt_presets import PREBUILT_PRESETS
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import FFmpegFileValidator
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
@ -22,6 +24,10 @@ from ytdl_sub.validators.validators import StringValidator
class ExperimentalValidator(StrictDictValidator):
"""
Experimental flags reside under the ``experimental`` key.
"""
_optional_keys = {"enable_update_with_info_json"}
_allow_extra_keys = True
@ -43,6 +49,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 +78,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 +104,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 +197,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 +216,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 +225,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 +277,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:
"""

View file

@ -1,9 +1,10 @@
from typing import Any
from typing import Dict
from typing import Iterable
from typing import Optional
from typing import Set
import mergedeep
from typing import Type
from typing import TypeVar
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
@ -11,17 +12,21 @@ from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARI
from ytdl_sub.entries.variables.override_variables import OverrideHelpers
from ytdl_sub.script.parser import parse
from ytdl_sub.script.script import Script
from ytdl_sub.script.types.function import BuiltInFunction
from ytdl_sub.script.types.resolvable import Resolvable
from ytdl_sub.script.types.resolvable import 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.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
ExpectedT = TypeVar("ExpectedT")
class Overrides(UnstructuredDictFormatterValidator, Scriptable):
"""
@ -88,6 +93,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 +138,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 +187,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 +210,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 +221,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 +233,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

@ -48,7 +48,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]]:

View file

@ -1,5 +1,7 @@
from typing import Iterable
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Type
@ -44,3 +46,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

@ -2,6 +2,7 @@ import copy
from typing import Any
from typing import Dict
from typing import List
from typing import Set
from mergedeep import mergedeep
@ -11,7 +12,6 @@ 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.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
@ -55,6 +55,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 +178,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 +229,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 +261,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

@ -8,6 +8,9 @@ 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.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
@ -57,12 +60,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 +122,7 @@ class OutputOptions(OptionsDictValidator):
"keep_max_files",
"download_archive_standardized_date",
"keep_files_date_eval",
"preserve_mtime",
}
@classmethod
@ -170,6 +186,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 +329,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,10 +1,6 @@
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 ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
@ -13,203 +9,188 @@ 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
from ytdl_sub.entries.script.variable_definitions import 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
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) -> 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")
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,
):
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
return self
self._apply_resolution_level()
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 ensure_proper_usage(self) -> None:
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)
# TODO: make function
resolved_subscription["overrides"] = {}
for name in self.overrides.keys:
value = self.script.definition_of(name)
if name in self.script.function_names:
# Keep custom functions as-is
resolved_subscription["overrides"][name] = self.overrides.dict_with_format_strings[
name
]
elif resolved := value.maybe_resolvable:
resolved_subscription["overrides"][name] = resolved.native
else:
resolved_subscription["overrides"][name] = ScriptUtils.to_native_script(value)
assert not self.unresolved_runtime_variables
return resolved_subscription

View file

@ -52,12 +52,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
@ -382,7 +382,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
entries_to_iter: List[Optional[Entry]] = entries
indices = list(range(len(entries_to_iter)))
if self.overrides.evaluate_boolean(validator.download_reverse):
if self.overrides.apply_formatter(validator.download_reverse, expected_type=bool):
indices = reversed(indices)
for idx in indices:
@ -461,8 +461,8 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
ytdl_option_overrides=validator.ytdl_options.to_native_dict(self.overrides)
)
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(
@ -487,19 +487,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,5 +1,6 @@
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
@ -21,7 +22,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 +30,7 @@ class UrlThumbnailValidator(StrictDictValidator):
"""
File name for the thumbnail
"""
return self._name
return self._thumb_name
@property
def uid(self) -> OverridesStringFormatterValidator:
@ -43,6 +44,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 = {
@ -68,7 +82,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={}
)

View file

@ -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]:
"""

View file

@ -22,7 +22,6 @@ VariableT = TypeVar("VariableT", bound="Variable")
def _get(
cast: str,
metadata_variable_name: str,
metadata_key: str,
variable_name: Optional[str],
@ -47,7 +46,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 +181,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 +202,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 +223,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 +241,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 +259,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 +295,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 +313,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

@ -11,9 +11,6 @@ from ytdl_sub.entries.script.variable_types import 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 +160,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

@ -116,7 +116,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

@ -33,7 +33,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

@ -7,13 +7,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 +30,8 @@ class FilterExcludeOptions(ListFormatterValidator, OptionsValidator):
{ %contains( %lower(description), '#short' ) }
"""
_inner_list_type = BooleanFormatterValidator
class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
plugin_options_type = FilterExcludeOptions
@ -52,7 +55,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

@ -7,14 +7,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 +38,8 @@ class FilterIncludeOptions(ListFormatterValidator, OptionsValidator):
}
"""
_inner_list_type = BooleanFormatterValidator
class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
plugin_options_type = FilterIncludeOptions
@ -61,8 +63,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

@ -140,7 +140,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

@ -31,7 +31,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

@ -42,8 +42,8 @@ class _RandomizedRangeValidator(StrictDictValidator, ABC):
)
def _randomized_float(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
actualized_min = float(overrides.apply_formatter(self._min, entry=entry))
actualized_max = float(overrides.apply_formatter(self._max, entry=entry))
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 actualized_min < 0:
raise self._validation_exception(
@ -70,7 +70,7 @@ class _RandomizedRangeValidator(StrictDictValidator, ABC):
-------
Max possible value
"""
actualized_max = float(overrides.apply_formatter(self._max, entry=entry))
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}"

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

@ -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

File diff suppressed because it is too large Load diff

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

@ -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

@ -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,7 @@
import math
from typing import Optional
from ytdl_sub.script.types.array import Array
from ytdl_sub.script.types.resolvable import AnyArgument
from ytdl_sub.script.types.resolvable import Float
from ytdl_sub.script.types.resolvable import Integer
@ -96,3 +98,18 @@ class NumericFunctions:
Returns min of all values.
"""
return _to_numeric(min(val.value for val in values))
@staticmethod
def range(
end: Integer, start: Optional[Integer] = None, step: Optional[Integer] = None
) -> Array:
"""
:description:
Returns the desired range of Integers in the form of an Array.
"""
if start is None:
start = Integer(0)
if step is None:
step = Integer(1)
return Array(value=[Integer(idx) for idx in range(start.value, end.value, step.value)])

View file

@ -31,6 +31,8 @@ from ytdl_sub.script.utils.exceptions import InvalidSyntaxException
from ytdl_sub.script.utils.exceptions import InvalidVariableName
from ytdl_sub.script.utils.exceptions import UserException
from ytdl_sub.script.utils.exceptions import VariableDoesNotExist
from ytdl_sub.script.utils.name_validation import is_function
from ytdl_sub.script.utils.name_validation import to_function_name
from ytdl_sub.script.utils.name_validation import validate_variable_name
# pylint: disable=invalid-name
@ -144,6 +146,9 @@ class _Parser:
):
self._text = text
self._name = name
if name and is_function(name):
self._name = to_function_name(name)
self._custom_function_names = custom_function_names
self._variable_names = variable_names
self._pos = 0
@ -605,7 +610,7 @@ def parse(
name=name,
custom_function_names=custom_function_names,
variable_names=variable_names,
).ast
).ast.maybe_resolvable_casted()
# pylint: enable=invalid-name

View file

@ -1,4 +1,5 @@
# pylint: disable=missing-raises-doc
from collections import defaultdict
from typing import Dict
from typing import List
from typing import Optional
@ -7,40 +8,28 @@ from typing import Set
from ytdl_sub.script.functions import Functions
from ytdl_sub.script.parser import parse
from ytdl_sub.script.script_output import ScriptOutput
from ytdl_sub.script.types.resolvable import Argument
from ytdl_sub.script.types.resolvable import BuiltInFunctionType
from ytdl_sub.script.types.resolvable import Lambda
from ytdl_sub.script.types.resolvable import Resolvable
from ytdl_sub.script.types.syntax_tree import ResolvedSyntaxTree
from ytdl_sub.script.types.syntax_tree import SyntaxTree
from ytdl_sub.script.types.variable import FunctionArgument
from ytdl_sub.script.types.variable import Variable
from ytdl_sub.script.types.variable_dependency import VariableDependency
from ytdl_sub.script.utils.exceptions import UNREACHABLE
from ytdl_sub.script.utils.exceptions import CycleDetected
from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments
from ytdl_sub.script.utils.exceptions import InvalidCustomFunctionArguments
from ytdl_sub.script.utils.exceptions import RuntimeException
from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
from ytdl_sub.script.utils.name_validation import is_function
from ytdl_sub.script.utils.name_validation import to_function_definition_name
from ytdl_sub.script.utils.name_validation import to_function_name
from ytdl_sub.script.utils.name_validation import validate_variable_name
from ytdl_sub.script.utils.type_checking import FunctionSpec
def _is_function(override_name: str):
return override_name.startswith("%")
def _function_name(function_key: str) -> str:
"""
Drop the % in %custom_function
"""
return function_key[1:]
def _to_function_definition_name(function_key: str) -> str:
"""
Add % in %custom_function
"""
return f"%{function_key}"
class Script:
"""
Takes a dictionary of both
@ -49,43 +38,71 @@ class Script:
``{ %custom_function: syntax }``
"""
def _ensure_no_cycle(
def _throw_cycle_error(
self, name: str, dep: str, deps: List[str], definitions: Dict[str, SyntaxTree]
):
if dep not in definitions:
return # does not exist, will throw downstream in parser
type_name, pre = (
("custom functions", "%") if definitions is self._functions else ("variables", "")
)
cycle_deps = [name] + deps + [dep]
cycle_deps_str = " -> ".join([f"{pre}{name}" for name in cycle_deps])
if name in deps + [dep]:
type_name, pre = (
("custom functions", "%") if definitions is self._functions else ("variables", "")
)
cycle_deps = [name] + deps + [dep]
cycle_deps_str = " -> ".join([f"{pre}{name}" for name in cycle_deps])
raise CycleDetected(f"Cycle detected within these {type_name}: {cycle_deps_str}")
raise CycleDetected(f"Cycle detected within these {type_name}: {cycle_deps_str}")
def _traverse_variable_dependencies(
self,
variable_name: str,
variable_dependency: SyntaxTree,
deps: List[str],
ensured: Dict[str, Set[str]],
) -> None:
for dep in variable_dependency.variables:
self._ensure_no_cycle(
name=variable_name, dep=dep.name, deps=deps, definitions=self._variables
)
if variable_name == dep.name:
self._throw_cycle_error(
name=variable_name, dep=dep.name, deps=deps, definitions=self._variables
)
if dep.name in ensured[variable_name]:
continue
self._traverse_variable_dependencies(
variable_name=variable_name,
variable_dependency=self._variables[dep.name],
deps=deps + [dep.name],
ensured=ensured,
)
ensured[variable_name].add(dep.name)
for custom_func in variable_dependency.custom_function_dependencies(
custom_function_definitions=self._functions
):
for dep in self._functions[custom_func.name].variables:
if variable_name == dep.name:
self._throw_cycle_error(
name=variable_name,
dep=dep.name,
deps=deps + [custom_func.definition_name()],
definitions=self._variables,
)
if dep.name in ensured[variable_name]:
continue
self._traverse_variable_dependencies(
variable_name=variable_name,
variable_dependency=self._variables[dep.name],
deps=deps + [custom_func.definition_name(), dep.name],
ensured=ensured,
)
ensured[variable_name].add(dep.name)
def _ensure_no_variable_cycles(self, variables: Dict[str, SyntaxTree]):
ensured: Dict[str, Set[str]] = defaultdict(set)
for variable_name, variable_definition in variables.items():
self._traverse_variable_dependencies(
variable_name=variable_name,
variable_dependency=variable_definition,
deps=[],
ensured=ensured,
)
def _traverse_custom_function_dependencies(
@ -95,9 +112,10 @@ class Script:
deps: List[str],
) -> None:
for dep in custom_function_dependency.custom_functions:
self._ensure_no_cycle(
name=custom_function_name, dep=dep.name, deps=deps, definitions=self._functions
)
if custom_function_name == dep.name:
self._throw_cycle_error(
name=custom_function_name, dep=dep.name, deps=deps, definitions=self._functions
)
self._traverse_custom_function_dependencies(
custom_function_name=custom_function_name,
custom_function_dependency=self._functions[dep.name],
@ -240,23 +258,23 @@ class Script:
def __init__(self, script: Dict[str, str]):
function_names: Set[str] = {
_function_name(name) for name in script.keys() if _is_function(name)
to_function_name(name) for name in script.keys() if is_function(name)
}
variable_names: Set[str] = {
validate_variable_name(name) for name in script.keys() if not _is_function(name)
validate_variable_name(name) for name in script.keys() if not is_function(name)
}
self._functions: Dict[str, SyntaxTree] = {
# custom_function_name must be passed to properly type custom function
# arguments uniquely if they're nested (i.e. $0 to $custom_func___0)
_function_name(function_key): parse(
to_function_name(function_key): parse(
text=function_value,
name=_function_name(function_key),
name=to_function_name(function_key),
custom_function_names=function_names,
variable_names=variable_names,
)
for function_key, function_value in script.items()
if _is_function(function_key)
if is_function(function_key)
}
self._variables: Dict[str, SyntaxTree] = {
@ -267,13 +285,13 @@ class Script:
variable_names=variable_names,
)
for variable_key, variable_value in script.items()
if not _is_function(variable_key)
if not is_function(variable_key)
}
self._validate()
def _update_internally(self, resolved_variables: Dict[str, Resolvable]) -> None:
for variable_name, resolved in resolved_variables.items():
self._variables[variable_name] = SyntaxTree(ast=[resolved])
self._variables[variable_name] = ResolvedSyntaxTree(ast=[resolved])
def _recursive_get_unresolved_output_filter_variables(
self, current_var: SyntaxTree, subset_to_resolve: Set[str], unresolvable: Set[Variable]
@ -302,14 +320,21 @@ class Script:
unresolvable=unresolvable,
)
for lambda_func in current_var.lambdas:
if lambda_func.value in self._functions:
subset_to_resolve |= self._recursive_get_unresolved_output_filter_variables(
current_var=self._functions[lambda_func.value],
subset_to_resolve=subset_to_resolve,
unresolvable=unresolvable,
)
return subset_to_resolve
def _get_unresolved_output_filter(
self,
unresolved: Dict[Variable, SyntaxTree],
output_filter: Set[str],
unresolvable: Set[Variable],
) -> Dict[Variable, SyntaxTree]:
) -> Set[str]:
"""
When an output filter is applied, only a subset of variables that the filter
depends on need to be resolved.
@ -330,7 +355,7 @@ class Script:
unresolvable=unresolvable,
)
return {var: syntax for var, syntax in unresolved.items() if var.name in subset_to_resolve}
return subset_to_resolve
def _resolve(
self,
@ -366,18 +391,21 @@ class Script:
unresolvable: Set[Variable] = {Variable(name) for name in (unresolvable or {})}
unresolved_filter = set(resolved.keys()).union(unresolvable)
unresolved: Dict[Variable, SyntaxTree] = {
Variable(name): ast
for name, ast in self._variables.items()
if Variable(name) not in unresolved_filter
}
if output_filter:
unresolved = self._get_unresolved_output_filter(
unresolved=unresolved,
output_filter=output_filter,
unresolvable=unresolvable,
)
unresolved = {
Variable(name): self._variables[name]
for name in self._get_unresolved_output_filter(
output_filter=output_filter,
unresolvable=unresolvable,
)
}
else:
unresolved = {
Variable(name): ast
for name, ast in self._variables.items()
if Variable(name) not in unresolved_filter
}
while unresolved:
unresolved_count: int = len(unresolved)
@ -398,8 +426,8 @@ class Script:
# Otherwise, if it has dependencies that are all resolved, then
# resolve the definition
elif not definition.is_subset_of(
variables=resolved.keys(), custom_function_definitions=self._functions
elif definition.is_subset_of(
variables=resolved, custom_function_definitions=self._functions
):
resolved[variable] = unresolved[variable].resolve(
resolved_variables=resolved,
@ -482,12 +510,12 @@ class Script:
added_variables_to_validate: Set[str] = set()
functions_to_add = {
_function_name(name): definition
to_function_name(name): definition
for name, definition in variables.items()
if _is_function(name)
if is_function(name)
}
variables_to_add = {
name: definition for name, definition in variables.items() if not _is_function(name)
name: definition for name, definition in variables.items() if not is_function(name)
}
custom_function_names = set(self._functions.keys()) | functions_to_add.keys()
@ -517,11 +545,52 @@ class Script:
return self
def add_parsed(self, variables: Dict[str, SyntaxTree]) -> "Script":
"""
Adds already parsed, new variables to the script.
Parameters
----------
variables
Mapping containing variable name to definition.
Returns
-------
Script
self
"""
added_variables_to_validate: Set[str] = set()
functions_to_add = {
to_function_name(name): definition
for name, definition in variables.items()
if is_function(name)
}
variables_to_add = {
name: definition for name, definition in variables.items() if not is_function(name)
}
for definitions in [functions_to_add, variables_to_add]:
for name, parsed in definitions.items():
if parsed.maybe_resolvable is None:
added_variables_to_validate.add(name)
if name in functions_to_add:
self._functions[name] = parsed
else:
self._variables[name] = parsed
if added_variables_to_validate:
self._validate(added_variables=added_variables_to_validate)
return self
def resolve_once(
self,
variable_definitions: Dict[str, str],
resolved: Optional[Dict[str, Resolvable]] = None,
unresolvable: Optional[Set[str]] = None,
update: bool = False,
) -> Dict[str, Resolvable]:
"""
Given a new set of variable definitions, resolve them using the Script, but do not
@ -536,6 +605,8 @@ class Script:
unresolvable
Optional. Unresolvable variables that will be ignored in resolution, including all
variables with a dependency to them.
update
Whether to update the script's state with resolved variables. Defaults to False.
Returns
-------
@ -548,11 +619,51 @@ class Script:
pre_resolved=resolved,
unresolvable=unresolvable,
output_filter=set(list(variable_definitions.keys())),
update=update,
).output
finally:
for name in variable_definitions.keys():
if name in self._variables:
del self._variables[name]
self._variables.pop(name, None)
def resolve_once_parsed(
self,
variable_definitions: Dict[str, SyntaxTree],
resolved: Optional[Dict[str, Resolvable]] = None,
unresolvable: Optional[Set[str]] = None,
update: bool = False,
) -> Dict[str, Resolvable]:
"""
Given a new set of variable definitions, resolve them using the Script, but do not
add them to the Script itself.
Parameters
----------
variable_definitions
Variables to resolve, but not store in the Script
resolved
Optional. Pre-resolved variables that should be used instead of what is in the script.
unresolvable
Optional. Unresolvable variables that will be ignored in resolution, including all
variables with a dependency to them.
update
Whether to update the script's state with resolved variables. Defaults to False.
Returns
-------
Dict[str, Resolvable]
Dict containing the variable names to their resolved values.
"""
try:
self.add_parsed(variable_definitions)
return self._resolve(
pre_resolved=resolved,
unresolvable=unresolvable,
output_filter=set(list(variable_definitions.keys())),
update=update,
).output
finally:
for name in variable_definitions.keys():
self._variables.pop(name, None)
def get(self, variable_name: str) -> Resolvable:
"""
@ -581,6 +692,18 @@ class Script:
raise RuntimeException(f"Tried to get unresolved variable {variable_name}")
def definition_of(self, name: str) -> SyntaxTree:
"""
Returns
-------
The definition of the variable or function.
"""
if name.startswith("%") and name[1:] in self._functions:
return self._functions[name[1:]]
if name in self._variables:
return self._variables[name]
raise RuntimeException(f"Tried to get non-existent definition with name {name}")
@property
def variable_names(self) -> Set[str]:
"""
@ -599,4 +722,121 @@ class Script:
Set[str]
Names of all functions within the Script.
"""
return set(_to_function_definition_name(name) for name in self._functions.keys())
return set(to_function_definition_name(name) for name in self._functions.keys())
def _resolve_partial_loop(
self,
output_filter: Optional[Set[str]],
resolved: Dict[Variable, Resolvable],
unresolved: Dict[Variable, Argument],
unresolvable: Optional[Set[str]],
):
to_partially_resolve: Set[Variable] = (
{Variable(name) for name in output_filter} if output_filter else set(unresolved.keys())
)
partially_resolved = True
while partially_resolved:
partially_resolved = False
for variable in list(to_partially_resolve):
definition = unresolved[variable]
maybe_resolved = definition
if isinstance(definition, Variable) and definition.name not in unresolvable:
if definition in resolved:
maybe_resolved = resolved[definition]
elif definition in unresolved:
maybe_resolved = unresolved[definition]
else:
raise UNREACHABLE
elif isinstance(definition, VariableDependency):
maybe_resolved = definition.partial_resolve(
resolved_variables=resolved,
unresolved_variables=unresolved,
custom_functions=self._functions,
)
if isinstance(maybe_resolved, Resolvable):
resolved[variable] = maybe_resolved
del unresolved[variable]
to_partially_resolve.remove(variable)
partially_resolved = True
else:
unresolved[variable] = maybe_resolved
# If the definition changed, then the script changed
# which means we can iterate again
partially_resolved |= definition != maybe_resolved
def _resolve_partial(
self,
unresolvable: Optional[Set[str]] = None,
output_filter: Optional[Set[str]] = None,
) -> Dict[str, SyntaxTree]:
"""
Returns
-------
New (deep-copied) script that resolves inner variables as much
as possible.
"""
unresolvable: Set[str] = unresolvable or {}
resolved: Dict[Variable, Resolvable] = {}
unresolved: Dict[Variable, Argument] = {
Variable(name): definition
for name, definition in self._variables.items()
if name not in unresolvable
}
self._resolve_partial_loop(
output_filter=output_filter,
resolved=resolved,
unresolved=unresolved,
unresolvable=unresolvable,
)
if output_filter:
out: Dict[str, SyntaxTree] = {}
for name in output_filter:
variable_name = Variable(name)
if variable_name in resolved:
out[name] = ResolvedSyntaxTree(ast=[resolved[variable_name]])
else:
out[name] = SyntaxTree(ast=[unresolved[variable_name]])
return out
return {
var.name: ResolvedSyntaxTree(ast=[definition]) for var, definition in resolved.items()
} | {var.name: SyntaxTree(ast=[definition]) for var, definition in unresolved.items()}
def resolve_partial(
self,
unresolvable: Optional[Set[str]] = None,
output_filter: Optional[Set[str]] = None,
) -> "Script":
"""
Updates the internal script to resolve as much as possible.
"""
out = self._resolve_partial(unresolvable=unresolvable, output_filter=output_filter)
for var_name, definition in out.items():
self._variables[var_name] = definition
return self
def resolve_partial_once(
self, variable_definitions: Dict[str, SyntaxTree], unresolvable: Optional[Set[str]] = None
) -> Dict[str, SyntaxTree]:
"""
Partially resolves the input variable definitions as much as possible.
"""
try:
self.add_parsed(variable_definitions)
return self._resolve_partial(
unresolvable=unresolvable,
output_filter=set(list(variable_definitions.keys())),
)
finally:
for name in variable_definitions.keys():
self._variables.pop(name, None)

View file

@ -28,7 +28,7 @@ class UnresolvedArray(_Array, VariableDependency, FutureResolvable):
value: List[Argument]
@property
def _iterable_arguments(self) -> List[Argument]:
def iterable_arguments(self) -> List[Argument]:
return self.value
def resolve(
@ -47,6 +47,27 @@ class UnresolvedArray(_Array, VariableDependency, FutureResolvable):
]
)
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, VariableDependency],
) -> Argument | Resolvable:
maybe_resolvable_values, is_resolvable = VariableDependency.try_partial_resolve(
args=self.value,
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
out = UnresolvedArray(value=maybe_resolvable_values)
if is_resolvable:
return out.resolve(
resolved_variables=resolved_variables, custom_functions=custom_functions
)
return out
def future_resolvable_type(self) -> Type[Resolvable]:
return Array

View file

@ -1,10 +1,10 @@
import copy
import functools
from abc import ABC
from dataclasses import dataclass
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Type
from typing import Union
@ -12,9 +12,11 @@ from ytdl_sub.script.functions import Functions
from ytdl_sub.script.types.array import Array
from ytdl_sub.script.types.array import UnresolvedArray
from ytdl_sub.script.types.resolvable import Argument
from ytdl_sub.script.types.resolvable import Boolean
from ytdl_sub.script.types.resolvable import BuiltInFunctionType
from ytdl_sub.script.types.resolvable import FunctionType
from ytdl_sub.script.types.resolvable import FutureResolvable
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import Lambda
from ytdl_sub.script.types.resolvable import NamedCustomFunction
from ytdl_sub.script.types.resolvable import Resolvable
@ -36,7 +38,7 @@ from ytdl_sub.script.utils.type_checking import is_union
@dataclass(frozen=True)
class Function(FunctionType, VariableDependency, ABC):
@property
def _iterable_arguments(self) -> List[Argument]:
def iterable_arguments(self) -> List[Argument]:
return self.args
@ -58,27 +60,79 @@ class CustomFunction(Function, NamedCustomFunction):
# Should be validated in the Script
raise UNREACHABLE
resolved_variables_with_args = copy.deepcopy(resolved_variables)
function_args: List[FunctionArgument] = []
for i, arg in enumerate(resolved_args):
function_arg = FunctionArgument.from_idx(idx=i, custom_function_name=self.name)
if function_arg in resolved_variables_with_args:
if function_arg in resolved_variables:
# function args should always be unique since they are only defined once
# in the custom function as %custom_function_name___idx
# and returned as a set from each custom function.
raise UNREACHABLE
resolved_variables_with_args[function_arg] = arg
resolved_variables[function_arg] = arg
function_args.append(function_arg)
return custom_functions[self.name].resolve(
resolved_variables=resolved_variables_with_args,
out = custom_functions[self.name].resolve(
resolved_variables=resolved_variables,
custom_functions=custom_functions,
)
for function_arg in function_args:
del resolved_variables[function_arg]
return out
# Implies the custom function does not exist. This should have
# been checked in the parser with
raise UNREACHABLE
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, VariableDependency],
) -> Argument | Resolvable:
maybe_resolvable_args, _ = VariableDependency.try_partial_resolve(
args=self.args,
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
for i in range(len(self.args)):
function_arg = FunctionArgument.from_idx(idx=i, custom_function_name=self.name)
function_value = maybe_resolvable_args[i]
if isinstance(function_value, Resolvable):
resolved_variables[function_arg] = function_value
else:
unresolved_variables[function_arg] = function_value
assert len(custom_functions[self.name].iterable_arguments) == 1
custom_function_definition = custom_functions[self.name].iterable_arguments[0]
maybe_resolvable_custom_function, is_resolvable = VariableDependency.try_partial_resolve(
args=[custom_function_definition],
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
for i in range(len(self.args)):
function_arg = FunctionArgument.from_idx(idx=i, custom_function_name=self.name)
if isinstance(maybe_resolvable_args[i], Resolvable):
del resolved_variables[function_arg]
else:
del unresolved_variables[function_arg]
if is_resolvable:
return maybe_resolvable_custom_function[0]
# Did not resolve custom function arguments, do not proceed
return CustomFunction(name=self.name, args=maybe_resolvable_args)
class BuiltInFunction(Function, BuiltInFunctionType):
def validate_args(self) -> "BuiltInFunction":
@ -307,5 +361,126 @@ class BuiltInFunction(Function, BuiltInFunctionType):
f"Runtime error occurred when executing the function %{self.name}: {str(exc)}"
) from exc
def _partial_resolve_conditional(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, "VariableDependency"],
):
"""
If the conditional partially resolvable enough to warrant evaluation,
perform it here.
"""
if self.name == "if":
maybe_resolvable_arg, is_resolvable = VariableDependency.try_partial_resolve(
args=[self.args[0]],
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
if is_resolvable:
boolean_output = maybe_resolvable_arg[0]
assert isinstance(boolean_output, Boolean)
return self.args[1] if boolean_output.native else self.args[2]
if self.name == "elif":
for idx in range(0, len(self.args), 2):
maybe_resolvable_arg, is_resolvable = VariableDependency.try_partial_resolve(
args=[self.args[idx]],
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
if is_resolvable:
boolean_output = maybe_resolvable_arg[0]
assert isinstance(boolean_output, Boolean)
if boolean_output.native:
return self.args[idx + 1]
else:
break
if self.name == "assert_then":
maybe_resolvable_arg, is_resolvable = VariableDependency.try_partial_resolve(
args=[self.args[0]],
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
if is_resolvable:
boolean_output = maybe_resolvable_arg[0]
assert isinstance(boolean_output, Boolean)
if boolean_output.native:
return self.args[1]
return self
def _try_optimized_partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, "VariableDependency"],
) -> Optional[Argument]:
"""
If a function has enough (but not all) resolved parameters to warrant a return,
perform it here.
"""
if self.name == "array_at":
if (
isinstance(self.args[0], UnresolvedArray)
and isinstance(self.args[1], Integer)
and len(self.args[0].value) >= self.args[1].value
):
maybe_resolvable_values, _ = VariableDependency.try_partial_resolve(
args=[self.args[0].value[self.args[1].value]],
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
return maybe_resolvable_values[0]
return None
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, VariableDependency],
) -> Argument | Resolvable:
conditional_return_args = self.function_spec.conditional_arg_indices(
num_input_args=len(self.args)
)
if conditional_return_args:
return self._partial_resolve_conditional(
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
if partial_resolved := self._try_optimized_partial_resolve(
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
):
return partial_resolved
maybe_resolvable_values, is_resolvable = VariableDependency.try_partial_resolve(
args=self.args,
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
out = BuiltInFunction(name=self.name, args=maybe_resolvable_values)
if is_resolvable:
return out.resolve(
resolved_variables=resolved_variables,
custom_functions=custom_functions,
)
return out
def __hash__(self):
return hash((self.name, *self.args))

View file

@ -31,7 +31,7 @@ class UnresolvedMap(_Map, VariableDependency, FutureResolvable):
value: Dict[Argument, Argument]
@property
def _iterable_arguments(self) -> List[Argument]:
def iterable_arguments(self) -> List[Argument]:
return list(itertools.chain(*self.value.items()))
def resolve(
@ -55,6 +55,35 @@ class UnresolvedMap(_Map, VariableDependency, FutureResolvable):
return Map(output)
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, VariableDependency],
) -> Argument | Resolvable:
maybe_resolvable_keys, is_keys_resolvable = VariableDependency.try_partial_resolve(
args=self.value.keys(),
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
maybe_resolvable_values, is_values_resolvable = VariableDependency.try_partial_resolve(
args=self.value.values(),
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
out = UnresolvedMap(value=dict(zip(maybe_resolvable_keys, maybe_resolvable_values)))
if is_keys_resolvable and is_values_resolvable:
return out.resolve(
resolved_variables=resolved_variables,
custom_functions=custom_functions,
)
return out
def future_resolvable_type(self) -> Type[Resolvable]:
return Map

View file

@ -193,6 +193,14 @@ class NamedCustomFunction(NamedArgument, ABC):
class ParsedCustomFunction(NamedCustomFunction):
num_input_args: int
def definition_name(self) -> str:
"""
Returns
-------
The function definition name, including the %
"""
return f"%{self.name}"
@dataclass(frozen=True)
class FunctionType(NamedArgument, ABC):

View file

@ -3,6 +3,7 @@ from typing import Dict
from typing import List
from typing import Optional
from ytdl_sub.script.types.function import BuiltInFunction
from ytdl_sub.script.types.resolvable import Argument
from ytdl_sub.script.types.resolvable import Resolvable
from ytdl_sub.script.types.resolvable import String
@ -15,7 +16,7 @@ class SyntaxTree(VariableDependency):
ast: List[Argument]
@property
def _iterable_arguments(self) -> List[Argument]:
def iterable_arguments(self) -> List[Argument]:
return self.ast
def resolve(
@ -40,6 +41,30 @@ class SyntaxTree(VariableDependency):
# Otherwise, to concat multiple resolved outputs, we must concat as strings
return String("".join([str(res) for res in resolved]))
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, VariableDependency],
) -> Argument | Resolvable:
# Ensure this does not get returned as a SyntaxTree since nesting them is not supported.
maybe_resolvable_values, _ = VariableDependency.try_partial_resolve(
args=self.ast,
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
# If no arguments, must be empty string
if len(maybe_resolvable_values) == 0:
return String(value="")
# Mimic the above resolve behavior
if len(maybe_resolvable_values) > 1:
return BuiltInFunction(name="concat", args=maybe_resolvable_values)
return maybe_resolvable_values[0]
@property
def maybe_resolvable(self) -> Optional[Resolvable]:
"""
@ -47,6 +72,40 @@ class SyntaxTree(VariableDependency):
-------
A resolvable if the AST contains a single type that is resolvable. None otherwise.
"""
if len(self.ast) == 1 and isinstance(self.ast[0], Resolvable):
return self.ast[0]
return None
def maybe_resolvable_casted(self) -> "SyntaxTree":
"""
Returns
-------
Optimized SyntaxTree if its deemed resolvable
"""
if len(self.ast) == 1 and isinstance(self.ast[0], Resolvable):
return ResolvedSyntaxTree(self.ast)
return self
@dataclass(frozen=True)
class ResolvedSyntaxTree(SyntaxTree):
"""
SyntaxTree with optimized helper functions if it's known to be resolved.
"""
def resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
custom_functions: Dict[str, VariableDependency],
) -> Resolvable:
return self.ast[0]
@property
def maybe_resolvable(self) -> Optional[Resolvable]:
return self.ast[0]
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, VariableDependency],
) -> Argument | Resolvable:
return self.ast[0]

View file

@ -5,6 +5,7 @@ from typing import Dict
from typing import Iterable
from typing import List
from typing import Set
from typing import Tuple
from typing import Type
from typing import TypeVar
from typing import final
@ -27,7 +28,7 @@ TypeT = TypeVar("TypeT")
class VariableDependency(ABC):
@property
@abstractmethod
def _iterable_arguments(self) -> List[Argument]:
def iterable_arguments(self) -> List[Argument]:
"""
Returns
-------
@ -38,7 +39,7 @@ class VariableDependency(ABC):
self, ttype: Type[TypeT], subclass: bool = False, instance: bool = True
) -> List[TypeT]:
output: List[TypeT] = []
for arg in self._iterable_arguments:
for arg in self.iterable_arguments:
if subclass and issubclass(type(arg), ttype):
output.append(arg)
elif instance and isinstance(arg, ttype):
@ -104,7 +105,7 @@ class VariableDependency(ABC):
All CustomFunctions that this depends on.
"""
output: Set[ParsedCustomFunction] = set()
for arg in self._iterable_arguments:
for arg in self.iterable_arguments:
if isinstance(arg, NamedCustomFunction):
if not isinstance(arg, FunctionType):
# A NamedCustomFunction should also always be a FunctionType
@ -138,6 +139,28 @@ class VariableDependency(ABC):
Resolved value
"""
@abstractmethod
def partial_resolve(
self,
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, "VariableDependency"],
) -> Argument | Resolvable:
"""
Parameters
----------
resolved_variables
Lookup of variables that have been resolved
unresolved_variables
Lookup of variables that have not been resolved
custom_functions
Lookup of any custom functions that have been parsed
Returns
-------
Either a fully resolved value or partially resolved value of the same type.
"""
@classmethod
def _resolve_argument_type(
cls,
@ -159,10 +182,35 @@ class VariableDependency(ABC):
raise UNREACHABLE
@final
def custom_function_dependencies(
self, custom_function_definitions: Dict[str, "VariableDependency"]
) -> Set[ParsedCustomFunction]:
"""
Parameters
----------
custom_function_definitions
Definition of all currently existing custom functions. Needed to check whether
a lambda function's input function is custom or not.
Returns
-------
All custom function dependencies
"""
custom_functions = self.custom_functions
for lambda_func in self.lambdas:
if lambda_func.value in custom_function_definitions:
custom_functions.add(
ParsedCustomFunction(
name=lambda_func.value, num_input_args=lambda_func.num_input_args()
)
)
return custom_functions
@final
def is_subset_of(
self,
variables: Iterable[Variable],
variables: Dict[Variable, Resolvable],
custom_function_definitions: Dict[str, "VariableDependency"],
) -> bool:
"""
@ -170,13 +218,14 @@ class VariableDependency(ABC):
-------
True if it contains all input variables as a dependency. False otherwise.
"""
for custom_function in self.custom_functions:
if custom_function_definitions[custom_function.name].is_subset_of(
# If there are lambdas, see if they are custom functions. If so, check them
for custom_function in self.custom_function_dependencies(custom_function_definitions):
if not custom_function_definitions[custom_function.name].is_subset_of(
variables=variables, custom_function_definitions=custom_function_definitions
):
return True
return False
return not self.variables.issubset(variables)
return all(var in variables for var in self.variables)
@final
def contains(
@ -189,9 +238,53 @@ class VariableDependency(ABC):
-------
True if it contains any of the input variables. False otherwise.
"""
for custom_function in self.custom_functions:
# If there are lambdas, see if they are custom functions. If so, check them
for custom_function in self.custom_function_dependencies(custom_function_definitions):
if custom_function_definitions[custom_function.name].contains(
variables=variables, custom_function_definitions=custom_function_definitions
):
return True
return len(self.variables.intersection(variables)) > 0
@classmethod
def try_partial_resolve(
cls,
args: Iterable[Argument],
resolved_variables: Dict[Variable, Resolvable],
unresolved_variables: Dict[Variable, Argument],
custom_functions: Dict[str, "VariableDependency"],
) -> Tuple[List[Argument], bool]:
"""
Attempts to resolve a list of arguments. Returns a tuple of them post partially resolved,
and a boolean indicating whether all of them are fully resolved.
"""
maybe_resolvable_args: List[Argument] = []
is_resolvable = True
for arg in args:
maybe_resolvable_args.append(arg)
if isinstance(arg, Lambda) and arg.value in custom_functions:
if not custom_functions[arg.value].is_subset_of(
variables=resolved_variables,
custom_function_definitions=custom_functions,
):
is_resolvable = False
elif isinstance(arg, VariableDependency):
maybe_resolvable_args[-1] = arg.partial_resolve(
resolved_variables=resolved_variables,
unresolved_variables=unresolved_variables,
custom_functions=custom_functions,
)
if not isinstance(maybe_resolvable_args[-1], Resolvable):
is_resolvable = False
elif isinstance(arg, Variable):
if arg in resolved_variables:
maybe_resolvable_args[-1] = resolved_variables[arg]
else:
is_resolvable = False
# Could be un unresolvable
if arg in unresolved_variables:
maybe_resolvable_args[-1] = unresolved_variables[arg]
return maybe_resolvable_args, is_resolvable

View file

@ -58,3 +58,24 @@ def validate_custom_function_name(custom_function_name: str) -> None:
f"Custom function name '%{custom_function_name}' is invalid:"
" The name is used by a built-in function and cannot be overwritten."
)
def is_function(override_name: str):
"""
Whether the definition is a function or not.
"""
return override_name.startswith("%")
def to_function_name(function_key: str) -> str:
"""
Drop the % in %custom_function
"""
return function_key[1:]
def to_function_definition_name(function_key: str) -> str:
"""
Add % in %custom_function
"""
return f"%{function_key}"

View file

@ -2,6 +2,7 @@
import inspect
from dataclasses import dataclass
from inspect import FullArgSpec
from types import NoneType
from typing import Callable
from typing import List
from typing import Optional
@ -50,7 +51,7 @@ def get_optional_type(optional_type: Type) -> Type[NamedType]:
-------
Type within the Optional[Type]
"""
return [arg for arg in optional_type.__args__ if arg != type(None)][0]
return [arg for arg in optional_type.__args__ if not isinstance(arg, NoneType)][0]
def _is_union_compatible(

View file

@ -8,10 +8,15 @@ from ytdl_sub.config.plugin.preset_plugins import PresetPlugins
from ytdl_sub.config.preset import Preset
from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.config.validators.variable_validation import ResolutionLevel
from ytdl_sub.config.validators.variable_validation import VariableValidation
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.entries.variables.override_variables import SubscriptionVariables
from ytdl_sub.utils.exceptions import SubscriptionPermissionError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.yaml import dump_yaml
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("subscription")
@ -74,6 +79,14 @@ class BaseSubscription(ABC):
}
)
# Validate after adding the subscription name
_ = VariableValidation(
overrides=self.overrides,
downloader_options=self.downloader_options,
output_options=self.output_options,
plugins=self.plugins,
).ensure_proper_usage()
self._enhanced_download_archive: Optional[EnhancedDownloadArchive] = (
_initialize_download_archive(
output_options=self.output_options,
@ -86,14 +99,20 @@ class BaseSubscription(ABC):
# Add post-archive variables
self.overrides.add(
{
SubscriptionVariables.subscription_has_download_archive(): f"""{{
%bool({self.download_archive.num_entries > 0})
}}""",
SubscriptionVariables.subscription_has_download_archive(): (
f"{{%bool({self.download_archive.num_entries > 0})}}"
),
}
)
self._exception: Optional[Exception] = None
if not FileHandler.is_path_writable(self.output_directory):
raise SubscriptionPermissionError(
"ytdl-sub does not have write permissions to the output directory: "
f"{self.output_directory}"
)
@property
def download_archive(self) -> EnhancedDownloadArchive:
"""
@ -236,4 +255,22 @@ class BaseSubscription(ABC):
-------
Subscription in yaml format
"""
return self._preset_options.yaml
return self._preset_options.yaml(subscription_only=False)
def resolved_yaml(self, resolution_level: int = ResolutionLevel.RESOLVE) -> str:
"""
Returns
-------
Human-readable, condensed YAML definition of the subscription.
"""
if resolution_level == ResolutionLevel.ORIGINAL:
return self._preset_options.yaml(subscription_only=True)
out = VariableValidation(
overrides=self.overrides,
downloader_options=self.downloader_options,
output_options=self.output_options,
plugins=self.plugins,
resolution_level=resolution_level,
).ensure_proper_usage(partial_resolve_formatters=True)
return dump_yaml(out)

View file

@ -73,6 +73,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
file_metadata=entry_metadata,
output_file_name=output_file_name,
entry=entry,
preserve_mtime=self.output_options.preserve_mtime,
)
# Always pretend to include the thumbnail in a dry-run
@ -87,6 +88,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
output_file_name=output_thumbnail_name,
entry=entry,
copy_file=True,
preserve_mtime=self.output_options.preserve_mtime,
)
elif not entry.is_thumbnail_downloaded():
logger.warning(
@ -106,6 +108,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
file_name=entry.get_download_info_json_name(),
output_file_name=output_info_json_name,
entry=entry,
preserve_mtime=self.output_options.preserve_mtime,
)
def _delete_working_directory(self, is_error: bool = False) -> None:
@ -152,8 +155,8 @@ class SubscriptionDownload(BaseSubscription, ABC):
keep_max_files: Optional[int] = None
if self.output_options.keep_max_files:
# validated it can be cast to int within the validator
keep_max_files = int(
self.overrides.apply_formatter(self.output_options.keep_max_files)
keep_max_files = self.overrides.apply_formatter(
self.output_options.keep_max_files, expected_type=int
)
if date_range_to_keep or self.output_options.keep_max_files is not None:

View file

@ -94,8 +94,8 @@ class SubscriptionYTDLOptions:
self._enhanced_download_archive.working_ytdl_file_path
)
if self._preset.output_options.keep_max_files:
keep_max_files = int(
self._overrides.apply_formatter(self._preset.output_options.keep_max_files)
keep_max_files = self._overrides.apply_formatter(
self._preset.output_options.keep_max_files, expected_type=int
)
if keep_max_files > 0:
# yt-dlp has a weird bug with max_downloads=1, set to 2 for safe measure

View file

@ -40,3 +40,7 @@ class FileNotDownloadedException(ValueError):
class ExperimentalFeatureNotEnabled(ValidationException):
"""Feature is not enabled for usage"""
class SubscriptionPermissionError(ValidationException):
"""Early-caught permission error"""

View file

@ -201,7 +201,11 @@ def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -
"-dn", # ignore data streams
]
for key, value in key_values.items():
ffmpeg_args.extend(["-metadata", f"{key}={value}"])
# Some special characters (utf-16 maybe?) have null-byte
# which results in ffmpeg error, remove them outright
value_null_byte_removed = value.replace("\0", "")
ffmpeg_args.extend(["-metadata", f"{key}={value_null_byte_removed}"])
ffmpeg_args.extend(["-codec", "copy", "-bitexact", tmp_file_path])
FFMPEG.run(ffmpeg_args)

View file

@ -379,6 +379,36 @@ class FileHandler:
"""
return self._file_handler_transaction_log
@classmethod
def is_path_writable(cls, src_file_path: Union[str, Path]) -> bool:
"""
Check whether a path is writable. If it does not exist, try to find the base directory
and check permissions on that.
"""
path = os.path.abspath(src_file_path)
while not os.path.exists(path):
new_path = os.path.dirname(path)
if new_path == path: # reached root
break
path = new_path
return os.access(path, os.W_OK)
@classmethod
def is_file_existent(cls, file_path: Union[str, Path]) -> bool:
"""
Check whether a file exists.
"""
return os.path.isfile(file_path)
@classmethod
def is_file_readable(cls, file_path: Union[str, Path]) -> bool:
"""
Check whether a file exists and is readable.
"""
return cls.is_file_existent(file_path) and os.access(file_path, os.R_OK)
@classmethod
def copy(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]):
"""
@ -430,6 +460,25 @@ class FileHandler:
if os.path.isfile(file_path):
os.remove(file_path)
@classmethod
def set_mtime(cls, file_path: Union[str, Path], mtime: float):
"""
Set the modification time of a file
Parameters
----------
file_path
Path to the file to modify
mtime
Modification time as a Unix timestamp
"""
try:
# Set both access time and modification time
os.utime(file_path, (mtime, mtime))
except OSError:
# If file operation fails, silently continue
pass
def move_file_to_output_directory(
self,
file_name: str,

View file

@ -2,12 +2,14 @@ import json
import re
from typing import Any
from typing import Dict
from typing import Optional
from ytdl_sub.script.parser import parse
from ytdl_sub.script.script import _is_function
from ytdl_sub.script.types.array import Array
from ytdl_sub.script.types.array import UnresolvedArray
from ytdl_sub.script.types.function import BuiltInFunction
from ytdl_sub.script.types.function import Function
from ytdl_sub.script.types.map import Map
from ytdl_sub.script.types.map import UnresolvedMap
from ytdl_sub.script.types.resolvable import Argument
from ytdl_sub.script.types.resolvable import Boolean
@ -15,8 +17,10 @@ from ytdl_sub.script.types.resolvable import Float
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import Lambda
from ytdl_sub.script.types.resolvable import String
from ytdl_sub.script.types.syntax_tree import SyntaxTree
from ytdl_sub.script.types.variable import Variable
from ytdl_sub.script.utils.exceptions import UNREACHABLE
from ytdl_sub.script.utils.name_validation import is_function
# pylint: disable=too-many-return-statements
@ -30,10 +34,26 @@ class ScriptUtils:
sanitized_variables = {
f"{name}_sanitized": f"{{%sanitize({name})}}"
for name in variables.keys()
if not _is_function(name)
if not is_function(name)
}
return dict(variables, **sanitized_variables)
@classmethod
def add_sanitized_parsed_variables(
cls, variables: Dict[str, SyntaxTree]
) -> Dict[str, SyntaxTree]:
"""
Helper to add sanitized variables to a Script
"""
sanitized_variables = {
f"{name}_sanitized": SyntaxTree(
ast=[BuiltInFunction(name="sanitize", args=[Variable(name)])]
)
for name in variables.keys()
if not is_function(name)
}
return variables | sanitized_variables
@classmethod
def to_script(cls, value: Any, sort_keys: bool = True) -> str:
"""
@ -67,9 +87,8 @@ class ScriptUtils:
ast = parse(text=value).ast
if len(ast) == 1:
return ast[0]
return BuiltInFunction(
name="concat", args=[BuiltInFunction(name="string", args=[arg]) for arg in ast]
)
return BuiltInFunction(name="concat", args=ast)
if isinstance(value, bool):
return Boolean(value)
if isinstance(value, int):
@ -88,6 +107,19 @@ class ScriptUtils:
raise UNREACHABLE
@classmethod
def _get_quote_char(cls, arg: str) -> str:
contains_single_quote = "'" in arg
contains_double_quote = '"' in arg
if not contains_single_quote and not contains_double_quote:
return '"'
if not contains_single_quote and contains_double_quote:
return "'"
if contains_single_quote and not contains_double_quote:
return '"'
return "'''"
@classmethod
def _to_script_code(cls, arg: Argument, top_level: bool = False) -> str:
if not top_level and isinstance(arg, (Integer, Boolean, Float)):
@ -96,7 +128,10 @@ class ScriptUtils:
if isinstance(arg, String):
if arg.native == "":
return "" if top_level else "''"
return arg.native if top_level else f"'''{arg.native}'''"
quote = cls._get_quote_char(arg.native)
return arg.native if top_level else f"{quote}{arg.native}{quote}"
if isinstance(arg, Integer):
out = f"%int({arg.native})"
@ -104,9 +139,9 @@ class ScriptUtils:
out = f"%bool({arg.native})"
elif isinstance(arg, Float):
out = f"%float({arg.native})"
elif isinstance(arg, UnresolvedArray):
elif isinstance(arg, (Array, UnresolvedArray)):
out = f"[ {', '.join(cls._to_script_code(val) for val in arg.value)} ]"
elif isinstance(arg, UnresolvedMap):
elif isinstance(arg, (Map, UnresolvedMap)):
kv_list = (
f"{cls._to_script_code(key)}: {cls._to_script_code(val)}"
for key, val in arg.value.items()
@ -122,11 +157,43 @@ class ScriptUtils:
raise UNREACHABLE
return f"{{ {out} }}" if top_level else out
@classmethod
def _is_top_level_string(cls, tree: SyntaxTree) -> Optional[str]:
if not (
len(tree.ast) == 1
and isinstance(tree.ast[0], BuiltInFunction)
and tree.ast[0].name == "concat"
):
return None
output = ""
for arg in tree.ast[0].args:
if isinstance(arg, BuiltInFunction) and arg.name == "string" and len(arg.args) == 1:
output += cls._to_script_code(arg.args[0], top_level=True)
else:
output += cls._to_script_code(arg, top_level=True)
return output
@classmethod
def _syntax_tree_to_native_script(cls, tree: SyntaxTree) -> str:
if (output := cls._is_top_level_string(tree)) is not None:
return output
output = ""
for arg in tree.ast:
output += cls._to_script_code(arg, top_level=True)
return output
@classmethod
def to_native_script(cls, value: Any) -> str:
"""
Converts any JSON-compatible value into equivalent script syntax
"""
if isinstance(value, SyntaxTree):
return cls._syntax_tree_to_native_script(value)
return cls._to_script_code(cls._to_script_argument(value), top_level=True)
@classmethod

View file

@ -1,10 +1,10 @@
from datetime import datetime
from typing import Any
from typing import Dict
from typing import Set
from typing import Union
from typing import final
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.script.parser import parse
from ytdl_sub.script.script import Script
from ytdl_sub.script.types.syntax_tree import SyntaxTree
@ -19,6 +19,8 @@ from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import StringValidator
from ytdl_sub.validators.validators import Validator
# pylint: disable=protected-access
class StringFormatterValidator(StringValidator):
"""
@ -51,7 +53,10 @@ class StringFormatterValidator(StringValidator):
def __init__(self, name, value: str):
super().__init__(name=name, value=value)
try:
_ = parse(str(value))
self._parsed = parse(
text=str(value),
name=self.leaf_name,
)
except UserException as exc:
raise self._validation_exception(exc) from exc
@ -65,27 +70,37 @@ class StringFormatterValidator(StringValidator):
"""
return self._value
def post_process(self, resolved: str) -> str:
@property
@final
def parsed(self) -> SyntaxTree:
"""
Returns
-------
Apply any post processing to the resolved value
The parsed format string.
"""
return resolved
return self._parsed
def post_process(self, resolved: Any) -> Any:
"""
Returns
-------
Apply any post processing to the resolved value. Defaults to casting it to string.
"""
return str(resolved)
class FloatFormatterValidator(StringFormatterValidator):
_expected_value_type_name = "float"
def post_process(self, resolved: str) -> str:
def post_process(self, resolved: str) -> float:
try:
float(resolved)
out = float(resolved)
except Exception as exc:
raise self._validation_exception(
f"Expected a float, but received '{resolved}'"
) from exc
return resolved
return out
class StandardizedDateValidator(StringFormatterValidator):
@ -102,6 +117,13 @@ class StandardizedDateValidator(StringFormatterValidator):
return resolved
class BooleanFormatterValidator(StringFormatterValidator):
_expected_value_type_name = "boolean"
def post_process(self, resolved: Any) -> bool:
return ScriptUtils.bool_formatter_output(output=str(resolved))
# pylint: disable=line-too-long
class OverridesStringFormatterValidator(StringFormatterValidator):
"""
@ -121,15 +143,14 @@ class OverridesStringFormatterValidator(StringFormatterValidator):
class OverridesIntegerFormatterValidator(OverridesStringFormatterValidator):
_expected_value_type_name = "integer"
def post_process(self, resolved: str) -> str:
def post_process(self, resolved: str) -> int:
try:
int(resolved)
out = int(resolved)
except Exception as exc:
raise self._validation_exception(
f"Expected an integer, but received '{resolved}'"
) from exc
return resolved
return out
class OverridesFloatFormatterValidator(FloatFormatterValidator, OverridesStringFormatterValidator):
@ -138,9 +159,14 @@ class OverridesFloatFormatterValidator(FloatFormatterValidator, OverridesStringF
"""
class OverridesBooleanFormatterValidator(OverridesStringFormatterValidator):
class OverridesBooleanFormatterValidator(
BooleanFormatterValidator, OverridesStringFormatterValidator
):
_expected_value_type_name = "boolean"
def post_process(self, resolved: Any) -> bool:
return ScriptUtils.bool_formatter_output(output=str(resolved))
class ListFormatterValidator(ListValidator[StringFormatterValidator]):
_inner_list_type = StringFormatterValidator
@ -158,18 +184,24 @@ class DictFormatterValidator(LiteralDictValidator):
super().__init__(name, value)
for key in self._keys:
self._value[key] = self._validate_key(key=key, validator=self._key_validator)
# Gets stored in __validator_dict
_ = self._validate_key(key=key, validator=self._key_validator)
@property
def dict(self) -> Dict[str, StringFormatterValidator]:
"""Returns dict with string formatter values"""
return self._value
return self._validator_dict
@property
def dict_with_format_strings(self) -> Dict[str, str]:
"""Returns dict with the format strings themselves"""
"""Returns dict with the format strings themselves."""
return {key: string_formatter.format_string for key, string_formatter in self.dict.items()}
@property
def dict_with_parsed_format_strings(self) -> Dict[str, SyntaxTree]:
"""Returns dict with the parsed format strings."""
return {key: string_formatter.parsed for key, string_formatter in self.dict.items()}
class OverridesDictFormatterValidator(DictFormatterValidator):
"""
@ -180,7 +212,22 @@ class OverridesDictFormatterValidator(DictFormatterValidator):
_key_validator = OverridesStringFormatterValidator
class AnyFormatterValidator(StringFormatterValidator):
"""
Applies no post-processing.
"""
def post_process(self, resolved: Any) -> Any:
return resolved
class AnyOverridesFormatterValidator(AnyFormatterValidator, OverridesStringFormatterValidator):
pass
class UnstructuredDictFormatterValidator(DictFormatterValidator):
_key_validator = AnyFormatterValidator
def __init__(self, name, value):
# Convert the unstructured-ness into a script
if isinstance(value, dict):
@ -189,40 +236,30 @@ class UnstructuredDictFormatterValidator(DictFormatterValidator):
class UnstructuredOverridesDictFormatterValidator(UnstructuredDictFormatterValidator):
_key_validator = OverridesStringFormatterValidator
def to_variable_dependency_format_string(script: Script, parsed_format_string: SyntaxTree) -> str:
"""
Create a dummy format string that contains all variable deps as a string.
"""
dummy_format_string = ""
for var in parsed_format_string.variables:
dummy_format_string += f"{{ {var.name} }}"
# pylint: disable=protected-access
for variable_dependency in script._variables[var.name].variables:
dummy_format_string += f"{{ {variable_dependency.name} }}"
# pylint: enable=protected-access
return dummy_format_string
_key_validator = AnyOverridesFormatterValidator
def _validate_formatter(
mock_script: Script,
unresolved_variables: Set[str],
unresolved_runtime_variables: Set[str],
formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator],
) -> None:
is_static_formatter = False
unresolvable = unresolved_variables
if isinstance(formatter_validator, OverridesStringFormatterValidator):
is_static_formatter = True
unresolvable = unresolved_variables.union({VARIABLES.entry_metadata.variable_name})
partial_resolve_entry_formatters: bool,
) -> Any:
parsed = formatter_validator.parsed
if resolved := parsed.maybe_resolvable:
return formatter_validator.post_process(resolved.native)
is_static_formatter = isinstance(formatter_validator, OverridesStringFormatterValidator)
parsed = parse(
text=formatter_validator.format_string,
)
variable_names = {var.name for var in parsed.variables}
custom_function_names = {f"%{func.name}" for func in parsed.custom_functions}
# Add lambda functions to custom function names, if it's custom
for lambda_func in parsed.lambdas:
if lambda_func in mock_script.function_names:
custom_function_names.add(lambda_func.value)
if not variable_names.issubset(mock_script.variable_names):
raise StringFormattingVariableNotFoundException(
"contains the following variables that do not exist: "
@ -233,20 +270,32 @@ def _validate_formatter(
"contains the following custom functions that do not exist: "
f"{', '.join(sorted(custom_function_names - mock_script.function_names))}"
)
if unresolved := variable_names.intersection(unresolvable):
if unresolved := variable_names.intersection(unresolved_runtime_variables):
raise StringFormattingVariableNotFoundException(
"contains the following variables that are unresolved when executing this "
f"formatter: {', '.join(sorted(unresolved))}"
)
if partial_resolve_entry_formatters and not is_static_formatter:
parsed = mock_script.resolve_partial_once(
variable_definitions={"tmp_var": formatter_validator.parsed},
unresolvable=unresolved_variables,
)["tmp_var"]
try:
mock_script.resolve_once(
{
"tmp_var": to_variable_dependency_format_string(
script=mock_script, parsed_format_string=parsed
)
},
unresolvable=unresolvable,
)
if is_static_formatter:
return formatter_validator.post_process(
mock_script.resolve_once_parsed(
{"tmp_var": formatter_validator.parsed},
unresolvable=unresolved_variables,
update=True,
)["tmp_var"].native
)
if maybe_resolved := parsed.maybe_resolvable:
return formatter_validator.post_process(maybe_resolved)
return ScriptUtils.to_native_script(parsed)
except RuntimeException as exc:
if isinstance(exc, ScriptVariableNotResolved) and is_static_formatter:
raise StringFormattingVariableNotFoundException(
@ -259,40 +308,59 @@ def _validate_formatter(
def validate_formatters(
script: Script,
unresolved_variables: Set[str],
unresolved_runtime_variables: Set[str],
validator: Validator,
) -> None:
partial_resolve_formatters: bool,
) -> Dict:
"""
Ensure all OverridesStringFormatterValidator's only contain variables from the overrides
and resolve.
"""
resolved_dict: Dict = {}
if isinstance(validator, DictValidator):
# pylint: disable=protected-access
resolved_dict[validator.leaf_name] = {}
# Usage of protected variables in other validators is fine. The reason to keep
# them protected is for readability when using them in subscriptions.
for validator_value in validator._validator_dict.values():
validate_formatters(
resolved_dict[validator.leaf_name] |= validate_formatters(
script=script,
unresolved_variables=unresolved_variables,
unresolved_runtime_variables=unresolved_runtime_variables,
validator=validator_value,
partial_resolve_formatters=partial_resolve_formatters,
)
# pylint: enable=protected-access
elif isinstance(validator, ListValidator):
resolved_dict[validator.leaf_name] = []
for list_value in validator.list:
validate_formatters(
list_output = validate_formatters(
script=script,
unresolved_variables=unresolved_variables,
unresolved_runtime_variables=unresolved_runtime_variables,
validator=list_value,
partial_resolve_formatters=partial_resolve_formatters,
)
assert len(list_output) == 1
resolved_dict[validator.leaf_name].append(list(list_output.values())[0])
elif isinstance(validator, (StringFormatterValidator, OverridesStringFormatterValidator)):
_validate_formatter(
resolved_dict[validator.leaf_name] = _validate_formatter(
mock_script=script,
unresolved_variables=unresolved_variables,
unresolved_runtime_variables=unresolved_runtime_variables,
formatter_validator=validator,
partial_resolve_entry_formatters=partial_resolve_formatters,
)
elif isinstance(validator, (DictFormatterValidator, OverridesDictFormatterValidator)):
resolved_dict[validator.leaf_name] = {}
for validator_value in validator.dict.values():
_validate_formatter(
resolved_dict[validator.leaf_name] |= _validate_formatter(
mock_script=script,
unresolved_variables=unresolved_variables,
unresolved_runtime_variables=unresolved_runtime_variables,
formatter_validator=validator_value,
partial_resolve_entry_formatters=partial_resolve_formatters,
)
else:
resolved_dict[validator.leaf_name] = validator._value
return resolved_dict

View file

@ -95,21 +95,11 @@ class Validator(ABC):
@final
@property
def _root_name(self) -> str:
def leaf_name(self) -> str:
"""
Returns
-------
"first" from the first.element.of.the.name
"""
return self._name.split(".")[0]
@final
@property
def _leaf_name(self) -> str:
"""
Returns
-------
"first" from the first.element.of.the.name
"name" from the first.element.of.the.name
"""
return self._name.split(".")[-1]
@ -274,7 +264,7 @@ class DictValidator(Validator):
value=self._dict.get(key, default),
)
self.__validator_dict[validator_name] = validator_instance
self.__validator_dict[key] = validator_instance
return validator_instance
@final

View file

@ -1,6 +1,7 @@
import copy
import json
import os.path
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@ -642,6 +643,7 @@ class EnhancedDownloadArchive:
output_file_name: Optional[str] = None,
entry: Optional[Entry] = None,
copy_file: bool = False,
preserve_mtime: bool = False,
):
"""
Saves a file from the working directory to the output directory and record it in the
@ -660,6 +662,8 @@ class EnhancedDownloadArchive:
Optional. Entry that this file belongs to
copy_file
Optional. If True, copy the file. Move otherwise
preserve_mtime
Optional. If True and entry has upload_date, set file mtime to upload date
"""
if output_file_name is None:
output_file_name = file_name
@ -674,6 +678,22 @@ class EnhancedDownloadArchive:
copy_file=copy_file,
)
# Set mtime if preserve_mtime is enabled and we have an entry with upload_date
if preserve_mtime and entry and not self._file_handler.dry_run:
upload_date = entry.get(v.ytdl_sub_keep_files_date_eval, str)
if upload_date:
try:
# Convert YYYY-mm-dd to timestamp
upload_datetime = datetime.strptime(upload_date, "%Y-%m-%d")
upload_timestamp = time.mktime(upload_datetime.timetuple())
# Set mtime on the output file
output_file_path = Path(self._file_handler.output_directory) / output_file_name
FileHandler.set_mtime(output_file_path, upload_timestamp)
except (ValueError, OSError):
# If date parsing or file operation fails, silently continue
pass
# Determine if it's the entry file by seeing if the file_name to move matches the entry
# download file name
is_entry_file = entry and entry.get_download_file_name() == file_name

View file

@ -13,6 +13,7 @@ from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from typing import Union
from unittest.mock import patch
import pytest
@ -116,7 +117,8 @@ def assert_logs(
yield
for call_args in patched_debug.call_args_list:
occurrences += int(expected_message in call_args.args[0])
full_print = call_args.args[0] % call_args.args[1:]
occurrences += int(expected_message in full_print)
if expected_occurrences is not None:
assert (
@ -165,19 +167,18 @@ def preset_dict_to_dl_args(preset_dict: Dict) -> str:
@pytest.fixture
def preset_dict_to_subscription_yaml_generator() -> Callable:
def subscription_yaml_file_generator() -> Callable:
@contextlib.contextmanager
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict):
subscription_dict = {subscription_name: preset_dict}
def _subscription_yaml_file_generator(yaml_dict: Dict):
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(json.dumps(subscription_dict).encode("utf-8"))
tmp_file.write(json.dumps(yaml_dict).encode("utf-8"))
try:
yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
return _preset_dict_to_subscription_yaml_generator
return _subscription_yaml_file_generator
###################################################################################################
@ -224,8 +225,14 @@ def _load_config(config_path: Path, working_directory: str) -> ConfigFile:
@pytest.fixture()
def music_video_subscription_path() -> Path:
return Path("examples/music_video_subscriptions.yaml")
def music_video_subscription_path(
output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/music_video_subscriptions.yaml")
yaml["__preset__"]["overrides"]["music_video_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture()
@ -239,13 +246,25 @@ def tv_show_config(working_directory, tv_show_config_path) -> ConfigFile:
@pytest.fixture()
def tv_show_subscriptions_path() -> Path:
return Path("examples/tv_show_subscriptions.yaml")
def tv_show_subscriptions_path(
output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/tv_show_subscriptions.yaml")
yaml["__preset__"]["overrides"]["tv_show_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture()
def advanced_tv_show_subscriptions_path() -> Path:
return Path("examples/advanced/tv_show_subscriptions.yaml")
def advanced_tv_show_subscriptions_path(
output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/advanced/tv_show_subscriptions.yaml")
yaml["__preset__"] = {"overrides": {"tv_show_directory": output_directory}}
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture()
@ -265,8 +284,25 @@ def default_config_path(default_config) -> str:
@pytest.fixture()
def music_subscriptions_path() -> Path:
return Path("examples/music_subscriptions.yaml")
def music_subscriptions_path(
output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("examples/music_subscriptions.yaml")
yaml["__preset__"]["overrides"]["music_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
@pytest.fixture()
def docker_default_subscription_path(
output_directory: str, subscription_yaml_file_generator: Callable
) -> Path:
yaml = load_yaml("docker/root/defaults/subscriptions.yaml")
yaml["__preset__"]["overrides"]["tv_show_directory"] = output_directory
with subscription_yaml_file_generator(yaml) as mock_filename:
yield mock_filename
def mock_run_from_cli(args: str) -> List[Subscription]:

View file

@ -9,6 +9,7 @@ import pytest
from ytdl_sub.cli.entrypoint import _download_subscriptions_from_yaml_files
from ytdl_sub.cli.entrypoint import main
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled
@ -59,6 +60,7 @@ def test_subscription_logs_write_to_file(
subscription_override_dict={},
update_with_info_json=False,
dry_run=dry_run,
shuffle=False,
)
except ValueError:
assert not mock_success_output
@ -120,3 +122,40 @@ def test_update_with_info_json_requires_experimental_flag(
pytest.raises(ExperimentalFeatureNotEnabled),
):
_ = main()
def test_subscription_shuffle(
default_config: ConfigFile,
mock_subscription_download_factory: Callable,
music_video_subscription_path: Path,
):
subscription_paths = [str(music_video_subscription_path)]
with (
patch.object(
Subscription,
"download",
new=mock_subscription_download_factory(mock_success_output=True),
),
):
out1 = _download_subscriptions_from_yaml_files(
config=default_config,
subscription_paths=subscription_paths,
subscription_matches=[],
subscription_override_dict={},
update_with_info_json=False,
dry_run=True,
shuffle=True,
)
out2 = _download_subscriptions_from_yaml_files(
config=default_config,
subscription_paths=subscription_paths,
subscription_matches=[],
subscription_override_dict={},
update_with_info_json=False,
dry_run=True,
shuffle=True,
)
assert [sub.name for sub in out1] != [sub.name for sub in out2]

View file

@ -236,24 +236,14 @@ class TestOutputOptions:
):
output_options_subscription_dict["output_options"]["keep_files_date_eval"] = "nope"
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=output_options_subscription_dict,
)
expected_error_msg = (
"Validation error in subscription_test.output_options.keep_files_date_eval: "
"Expected a standardized date in the form of YYYY-MM-DD, but received 'nope'"
)
with (
mock_download_collection_entries(
is_youtube_channel=False,
num_urls=1,
is_extracted_audio=False,
is_dry_run=True,
),
pytest.raises(ValidationException, match=re.escape(expected_error_msg)),
):
subscription.download(dry_run=True)
with pytest.raises(ValidationException, match=re.escape(expected_error_msg)):
_ = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=output_options_subscription_dict,
)

View file

@ -71,7 +71,7 @@ class TestThrottleProtectionPlugin:
),
assert_logs(
logger=throttle_protection_logger,
expected_message="Sleeping between subscriptions for %0.2f seconds",
expected_message="Sleeping between subscriptions for 0.02 seconds",
log_level="info",
expected_occurrences=1,
),
@ -139,7 +139,7 @@ class TestThrottleProtectionPlugin:
),
assert_logs(
logger=throttle_protection_logger,
expected_message="Reached subscription max downloads of %d",
expected_message="Reached subscription max downloads of 0 for throttle protection",
log_level="info",
expected_occurrences=1,
),

View file

@ -1,18 +1,37 @@
import json
import os
import shutil
from pathlib import Path
from typing import Dict
DISABLE_YOUTUBE_TESTS: bool = True
REGENERATE_FIXTURES: bool = False
RESOURCE_PATH: Path = Path("tests") / "resources"
_FILE_FIXTURE_PATH: Path = RESOURCE_PATH / "file_fixtures"
_EXPECTED_JSON_PATH: Path = RESOURCE_PATH / "expected_json"
def file_fixture_path(fixture_name: str) -> Path:
return _FILE_FIXTURE_PATH / fixture_name
def expected_json(input_json: Dict, json_name: str) -> Dict:
expected_json_path = _EXPECTED_JSON_PATH / json_name
os.makedirs(os.path.dirname(expected_json_path), exist_ok=True)
if REGENERATE_FIXTURES:
with open(expected_json_path, "w", encoding="utf-8") as json_file:
json.dump(input_json, json_file, sort_keys=True, indent=2)
return input_json
with open(expected_json_path, "r", encoding="utf-8") as json_file:
expected_json = json.load(json_file)
return expected_json
def copy_file_fixture(fixture_name: str, output_file_path: Path) -> None:
os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
shutil.copy(file_fixture_path(fixture_name), output_file_path)

View file

@ -2,18 +2,18 @@
".ytdl-sub-subscription_test-download-archive.json": "19cf39d57914ba9cbd1e57ba6f1e0683",
"JMC/Mock Entry 20-1.info.json": "INFO_JSON",
"JMC/Mock Entry 20-1.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 20-1.mkv": "eb9a8ff61701ab673c2f06147fe29a4e",
"JMC/Mock Entry 20-1.mkv": "cbc8c05ea5cf4deefd735b93af9c0259",
"JMC/Mock Entry 20-1.nfo": "fefcf0b3e4f4ff80ad636584d50dadec",
"JMC/Mock Entry 20-2.info.json": "INFO_JSON",
"JMC/Mock Entry 20-2.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 20-2.mkv": "65d047562c61a068e8a92bd8df8801fa",
"JMC/Mock Entry 20-2.mkv": "85cc274346abc0c71ab7702b0d61abde",
"JMC/Mock Entry 20-2.nfo": "025c0b631da5ff5470382b38fce78d2d",
"JMC/Mock Entry 20-3.info.json": "INFO_JSON",
"JMC/Mock Entry 20-3.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 20-3.mkv": "98582526fcb3bea10ab7b3df7a5bc65d",
"JMC/Mock Entry 20-3.mkv": "c5a9ce268475c3b8fa9d4d0e3c0c25ae",
"JMC/Mock Entry 20-3.nfo": "618b0ff948d9de2e10cf1da8c0dd6615",
"JMC/Mock Entry 21-1.info.json": "INFO_JSON",
"JMC/Mock Entry 21-1.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"JMC/Mock Entry 21-1.mkv": "3fe368f6d4bd9662a24abca9215b4d26",
"JMC/Mock Entry 21-1.mkv": "ebdd8026e72625c56dd2aa8a2e19e814",
"JMC/Mock Entry 21-1.nfo": "e5c715749efc1603a6e2f59244d87aba"
}

View file

@ -0,0 +1,242 @@
{
"audio_extract": {
"codec": "best",
"enable": true
},
"download": [
{
"download_reverse": true,
"include_sibling_metadata": true,
"playlist_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
],
"variables": {},
"webpage_url": "{ modified_webpage_url }",
"ytdl_options": {}
}
],
"format": "ba[ext=webm]/ba",
"music_tags": {
"album": [
"{ track_album }"
],
"albumartist": [
"Lester Young"
],
"albumartists": [
"Lester Young"
],
"artist": [
"Lester Young"
],
"artists": [
"Lester Young"
],
"date": [
"{ track_date }"
],
"genres": [
"Jazz"
],
"original_date": [
"{ track_original_date }"
],
"title": [
"{ track_title }"
],
"track": [
"{ track_number }"
],
"tracktotal": [
"{ track_total }"
],
"year": [
"{ track_year }"
]
},
"output_options": {
"download_archive_name": ".ytdl-sub-Lester Young-download-archive.json",
"file_name": "{ track_full_path }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpzald2h7x",
"preserve_mtime": false,
"thumbnail_name": "{ album_cover_path }"
},
"overrides": {
"album_cover_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/folder.{ thumbnail_ext }",
"album_dir": "[{ playlist_max_upload_year }] { %sanitize( playlist_title ) }",
"artist_dir": "Lester Young",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{ webpage_url }",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpzald2h7x",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"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_print": true,
"resolution_readable": "{ width }x{ height }",
"subscription_array": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
],
"subscription_indent_1": "Jazz",
"subscription_value": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"subscription_value_1": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"track_album": "{ playlist_title }",
"track_album_artist": "Lester Young",
"track_artist": "Lester Young",
"track_date": "{ upload_date_standardized }",
"track_file_name": "{ playlist_index_padded } - { %sanitize( title ) }.{ ext }",
"track_full_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/{ %concat( playlist_index_padded, \" - \", %sanitize( title ), \".\", ext ) }",
"track_genre": "Jazz",
"track_genre_default": "Unset",
"track_number": "{ playlist_index }",
"track_number_padded": "{ playlist_index_padded }",
"track_original_date": "{ upload_date_standardized }",
"track_title": "{ title }",
"track_total": "{ playlist_count }",
"track_year": "{ playlist_max_upload_year }",
"url": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
}
}

View file

@ -0,0 +1,242 @@
{
"audio_extract": {
"codec": "best",
"enable": true
},
"download": [
{
"download_reverse": true,
"include_sibling_metadata": true,
"playlist_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
],
"variables": {},
"webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"ytdl_options": {}
}
],
"format": "ba[ext=webm]/ba",
"music_tags": {
"album": [
"{ %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }"
],
"albumartist": [
"Lester Young"
],
"albumartists": [
"Lester Young"
],
"artist": [
"Lester Young"
],
"artists": [
"Lester Young"
],
"date": [
"{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }"
],
"genres": [
"Jazz"
],
"original_date": [
"{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }"
],
"title": [
"{ %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) }"
],
"track": [
"{ %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ) }"
],
"tracktotal": [
"{ %map_get_non_empty( entry_metadata, \"playlist_count\", 1 ) }"
],
"year": [
"{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }"
]
},
"output_options": {
"download_archive_name": ".ytdl-sub-Lester Young-download-archive.json",
"file_name": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/{ %concat( %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ), \" - \", %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ), \".\", ext ) }",
"keep_files_date_eval": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpdu1vad67",
"preserve_mtime": false,
"thumbnail_name": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/folder.jpg"
},
"overrides": {
"album_cover_path": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/folder.jpg",
"album_dir": "[{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }] { %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }",
"artist_dir": "Lester Young",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpdu1vad67",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [ ] }",
"resolution_assert_is_ignored": "{ %print_if_true( %concat( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ), \" has a match in resolution_assert_ignore_titles, skipping resolution assert.\" ), %contains_any( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ), [ ] ) ) }",
"resolution_assert_print": true,
"resolution_readable": "{ width }x{ height }",
"subscription_array": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
],
"subscription_indent_1": "Jazz",
"subscription_value": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"subscription_value_1": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"track_album": "{ %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"track_album_artist": "Lester Young",
"track_artist": "Lester Young",
"track_date": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"track_file_name": "{ %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ) } - { %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }.{ ext }",
"track_full_path": "Lester Young/{ %concat( \"[\", %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ), \"] \", %sanitize( %map_get_non_empty( entry_metadata, \"playlist_title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }/{ %concat( %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ), \" - \", %sanitize( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ), \".\", ext ) }",
"track_genre": "Jazz",
"track_genre_default": "Unset",
"track_number": "{ %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ) }",
"track_number_padded": "{ %pad_zero( %map_get_non_empty( entry_metadata, \"playlist_index\", 1 ), 2 ) }",
"track_original_date": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"track_title": "{ %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) }",
"track_total": "{ %map_get_non_empty( entry_metadata, \"playlist_count\", 1 ) }",
"track_year": "{ %int( %map_get( %to_date_metadata( %array_reduce( %if_passthrough( %extract_field_from_siblings( \"upload_date\" ), [ upload_date ] ), %max ) ), \"year\" ) ) }",
"url": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
}
}

View file

@ -0,0 +1,227 @@
{
"audio_extract": {
"codec": "best"
},
"download": [
{
"include_sibling_metadata": "{include_sibling_metadata}",
"playlist_thumbnails": [
{
"name": "{avatar_uncropped_thumbnail_file_name}",
"uid": "avatar_uncropped"
},
{
"name": "{banner_uncropped_thumbnail_file_name}",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "{avatar_uncropped_thumbnail_file_name}",
"uid": "avatar_uncropped"
},
{
"name": "{banner_uncropped_thumbnail_file_name}",
"uid": "banner_uncropped"
}
],
"url": "{ %array_at(urls, 0) }",
"webpage_url": "{modified_webpage_url}"
},
{
"include_sibling_metadata": "{include_sibling_metadata}",
"url": "{ %array_slice(urls, 1) }",
"webpage_url": "{modified_webpage_url}"
}
],
"format": "ba[ext=webm]/ba",
"music_tags": {
"album": "{track_album}",
"albumartist": "{track_album_artist}",
"albumartists": [
"{track_album_artist}"
],
"artist": "{track_artist}",
"artists": [
"{track_artist}"
],
"date": "{track_date}",
"genres": [
"{track_genre}"
],
"original_date": "{track_original_date}",
"title": "{track_title}",
"track": "{track_number}",
"tracktotal": "{track_total}",
"year": "{track_year}"
},
"output_options": {
"file_name": "{track_full_path}",
"maintain_download_archive": true,
"output_directory": "{music_directory}",
"thumbnail_name": "{album_cover_path}"
},
"overrides": {
"album_cover_path": "{artist_dir}/{album_dir}/folder.{thumbnail_ext}",
"album_dir": "[{track_year}] {track_album_sanitized}",
"artist_dir": "{track_artist_sanitized}",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{webpage_url}",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpk6coazyn",
"resolution_assert": "{\n %if(\n %and(\n enable_resolution_assert,\n %ne( height, 0 ),\n %not(resolution_assert_is_ignored)\n ),\n %assert(\n %gte( height, resolution_assert_height_gte ),\n %concat(\n \"Entry \",\n title,\n \" downloaded at a low resolution (\",\n resolution_readable,\n \"), you've probably been throttled. \",\n \"Stopping further downloads, wait a few hours and try again. \",\n \"Disable using the override variable `enable_resolution_assert: False`.\"\n )\n ),\n \"false is no-op\"\n )\n}",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [] }",
"resolution_assert_is_ignored": "{\n %print_if_true(\n %concat(title, \" has a match in resolution_assert_ignore_titles, skipping resolution assert.\"),\n %contains_any(title, resolution_assert_ignore_titles)\n )\n}",
"resolution_assert_print": "{\n %print(\n %if(\n enable_resolution_assert,\n \"Resolution assert is enabled, will fail on low-quality video downloads and presume throttle. Disable using the override variable `enable_resolution_assert: False`\",\n \"Resolution assert is disabled. Use at your own risk!\"\n ),\n enable_resolution_assert,\n -1\n )\n}",
"resolution_readable": "{width}x{height}",
"subscription_array": "{%from_json('''[\"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists\"]''')}",
"subscription_indent_1": "Jazz",
"subscription_value": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"subscription_value_1": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"track_album": "{playlist_title}",
"track_album_artist": "{track_artist}",
"track_artist": "{subscription_name}",
"track_date": "{upload_date_standardized}",
"track_file_name": "{track_number_padded} - {track_title_sanitized}.{ext}",
"track_full_path": "{artist_dir}/{album_dir}/{track_file_name}",
"track_genre": "{subscription_indent_1}",
"track_genre_default": "Unset",
"track_number": "{playlist_index}",
"track_number_padded": "{playlist_index_padded}",
"track_original_date": "{track_date}",
"track_title": "{title}",
"track_total": "{playlist_count}",
"track_year": "{playlist_max_upload_year}",
"url": "{subscription_value}",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": "{subscription_array}"
},
"preset": [
"_music_base",
"_multi_url",
"_albums_from_playlists",
"_throttle_protection",
"YouTube Releases",
"__preset__"
],
"throttle_protection": {
"enable": "{\n %print(\n %if(\n enable_throttle_protection,\n \"Throttle protection is enabled. Disable using the override variable `enable_throttle_protection: False`\",\n \"Throttle protection is disabled. Use at your own risk!\"\n ),\n enable_throttle_protection\n )\n}",
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"ytdl_options": {
"break_on_existing": true
}
}

View file

@ -0,0 +1,242 @@
{
"audio_extract": {
"codec": "best",
"enable": true
},
"download": [
{
"download_reverse": true,
"include_sibling_metadata": true,
"playlist_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
],
"variables": {},
"webpage_url": "{ webpage_url }",
"ytdl_options": {}
}
],
"format": "ba[ext=webm]/ba",
"music_tags": {
"album": [
"{ playlist_title }"
],
"albumartist": [
"Lester Young"
],
"albumartists": [
"Lester Young"
],
"artist": [
"Lester Young"
],
"artists": [
"Lester Young"
],
"date": [
"{ upload_date_standardized }"
],
"genres": [
"Jazz"
],
"original_date": [
"{ upload_date_standardized }"
],
"title": [
"{ title }"
],
"track": [
"{ playlist_index }"
],
"tracktotal": [
"{ playlist_count }"
],
"year": [
"{ playlist_max_upload_year }"
]
},
"output_options": {
"download_archive_name": ".ytdl-sub-Lester Young-download-archive.json",
"file_name": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/{ %concat( playlist_index_padded, \" - \", %sanitize( title ), \".\", ext ) }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpd5oeacb3",
"preserve_mtime": false,
"thumbnail_name": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/folder.{ thumbnail_ext }"
},
"overrides": {
"album_cover_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/folder.{ thumbnail_ext }",
"album_dir": "[{ playlist_max_upload_year }] { %sanitize( playlist_title ) }",
"artist_dir": "Lester Young",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"include_sibling_metadata": true,
"modified_webpage_url": "{ webpage_url }",
"music_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpd5oeacb3",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"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_print": true,
"resolution_readable": "{ width }x{ height }",
"subscription_array": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
],
"subscription_indent_1": "Jazz",
"subscription_value": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"subscription_value_1": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"track_album": "{ playlist_title }",
"track_album_artist": "Lester Young",
"track_artist": "Lester Young",
"track_date": "{ upload_date_standardized }",
"track_file_name": "{ playlist_index_padded } - { %sanitize( title ) }.{ ext }",
"track_full_path": "Lester Young/{ %concat( \"[\", playlist_max_upload_year, \"] \", %sanitize( playlist_title ) ) }/{ %concat( playlist_index_padded, \" - \", %sanitize( title ), \".\", ext ) }",
"track_genre": "Jazz",
"track_genre_default": "Unset",
"track_number": "{ playlist_index }",
"track_number_padded": "{ playlist_index_padded }",
"track_original_date": "{ upload_date_standardized }",
"track_title": "{ title }",
"track_total": "{ playlist_count }",
"track_year": "{ playlist_max_upload_year }",
"url": "https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/channel/UCsItMF6_fP754ihIsSRLk5A/playlists"
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
}
}

View file

@ -0,0 +1,314 @@
{
"download": [
{
"download_reverse": true,
"include_sibling_metadata": false,
"playlist_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
],
"variables": {},
"webpage_url": "{ modified_webpage_url }",
"ytdl_options": {}
}
],
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-Rick Astley-download-archive.json",
"file_name": "{ music_video_file_name }.{ ext }",
"info_json_name": "{ music_video_file_name }.{ info_json_ext }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpyukbh6ta",
"preserve_mtime": false,
"thumbnail_name": "{ music_video_file_name }.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
"%contains_url_field": "{ %not( %is_null( %get_url_field( $0, '' ) ) ) }",
"%flat_array__category_to_map_format": "{ %array_apply_fixed( %assert_then( %is_array( $1 ), $1, \"If using album categories, each category must map to an array\" ), $0, %flat_array__url_to_map_format ) }",
"%flat_array__url_keyed_map_format": "{ { %map_get( $0, \"url\" ): $0 } }",
"%flat_array__url_to_map_format": "{ %elif( %is_map( $0 ), %map_extend( $0, { \"category\": $1 } ), %is_string( $0 ), { \"url\": $0, \"category\": $1 }, %throw( \"If using album categories, each URL must be either a string or map with metadata fields\" ) ) }",
"%get_url_field": "{ %map_get( %map( %map_get( category_url_map, ytdl_sub_input_url, { } ) ), $0, $1 ) }",
"%get_url_i": "{ %map_get( %map( %array_at( category_url_array, %int( %sub( $0, 1 ) ), { } ) ), \"url\", %array_at( subscription_array, %int( %sub( $0, 1 ) ), '' ) ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"category_url_array": "{ [ ] }",
"category_url_map": "{ [ ] }",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"file_title": "{ title_sanitized_plex }",
"file_uid": "{ uid_sanitized_plex }",
"include_sibling_metadata": false,
"modified_webpage_url": "{ webpage_url }",
"music_video_album": "{ %get_url_field( \"category\", \"Music Videos\" ) }",
"music_video_album_default": "Music Videos",
"music_video_artist": "Rick Astley",
"music_video_date": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpyukbh6ta",
"music_video_file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }",
"music_video_file_name_suffix": "",
"music_video_genre": "Pop",
"music_video_genre_default": "ytdl-sub",
"music_video_title": "{ %get_url_field( \"title\", title ) }",
"music_video_year": "{ %int( %elif( %contains_url_field( \"date\" ), %slice( %get_url_field( \"date\", upload_date_standardized ), 0, 4 ), %contains_url_field( \"year\" ), %get_url_field( \"date\", upload_year ), upload_year ) ) }",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"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_print": true,
"resolution_readable": "{ width }x{ height }",
"subscription_array": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
],
"subscription_indent_1": "Pop",
"subscription_map": {},
"subscription_value": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"subscription_value_1": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
""
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"album": "{ music_video_album }",
"artist": "Rick Astley",
"genre": "Pop",
"premiered": "{ music_video_date }",
"title": "{ music_video_title }",
"year": "{ music_video_year }"
}
}

View file

@ -0,0 +1,314 @@
{
"download": [
{
"download_reverse": true,
"include_sibling_metadata": false,
"playlist_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
],
"variables": {},
"webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"ytdl_options": {}
}
],
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-Rick Astley-download-archive.json",
"file_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ), '' ) }.{ %map_get( entry_metadata, \"ext\" ) }",
"info_json_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ), '' ) }.info.json",
"keep_files_date_eval": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmput7fc_rs",
"preserve_mtime": false,
"thumbnail_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ), '' ) }.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
"%contains_url_field": "{ %not( %is_null( %get_url_field( $0, '' ) ) ) }",
"%flat_array__category_to_map_format": "{ %array_apply_fixed( %assert_then( %is_array( $1 ), $1, \"If using album categories, each category must map to an array\" ), $0, %flat_array__url_to_map_format ) }",
"%flat_array__url_keyed_map_format": "{ { %map_get( $0, \"url\" ): $0 } }",
"%flat_array__url_to_map_format": "{ %elif( %is_map( $0 ), %map_extend( $0, { \"category\": $1 } ), %is_string( $0 ), { \"url\": $0, \"category\": $1 }, %throw( \"If using album categories, each URL must be either a string or map with metadata fields\" ) ) }",
"%get_url_field": "{ %map_get( %map( %map_get( category_url_map, ytdl_sub_input_url, { } ) ), $0, $1 ) }",
"%get_url_i": "{ %map_get( %map( %array_at( category_url_array, %int( %sub( $0, 1 ) ), { } ) ), \"url\", %array_at( subscription_array, %int( %sub( $0, 1 ) ), '' ) ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"category_url_array": "{ [ ] }",
"category_url_map": "{ [ ] }",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"file_title": "{ %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"file_uid": "{ %sanitize_plex_episode( %map_get( entry_metadata, \"id\" ) ) }",
"include_sibling_metadata": false,
"modified_webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"music_video_album": "{ %get_url_field( \"category\", \"Music Videos\" ) }",
"music_video_album_default": "Music Videos",
"music_video_artist": "Rick Astley",
"music_video_date": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmput7fc_rs",
"music_video_file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) }",
"music_video_file_name_suffix": "",
"music_video_genre": "Pop",
"music_video_genre_default": "ytdl-sub",
"music_video_title": "{ %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"music_video_year": "{ %int( %elif( %contains_url_field( \"date\" ), %slice( %get_url_field( \"date\", upload_date_standardized ), 0, 4 ), %contains_url_field( \"year\" ), %get_url_field( \"date\", upload_year ), upload_year ) ) }",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [ ] }",
"resolution_assert_is_ignored": "{ %print_if_true( %concat( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ), \" has a match in resolution_assert_ignore_titles, skipping resolution assert.\" ), %contains_any( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ), [ ] ) ) }",
"resolution_assert_print": true,
"resolution_readable": "{ width }x{ height }",
"subscription_array": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
],
"subscription_indent_1": "Pop",
"subscription_map": {},
"subscription_value": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"subscription_value_1": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
""
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"album": "{ %get_url_field( \"category\", \"Music Videos\" ) }",
"artist": "Rick Astley",
"genre": "Pop",
"premiered": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"title": "{ %get_url_field( \"title\", %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"year": "{ %int( %elif( %contains_url_field( \"date\" ), %slice( %get_url_field( \"date\", upload_date_standardized ), 0, 4 ), %contains_url_field( \"year\" ), %get_url_field( \"date\", upload_year ), upload_year ) ) }"
}
}

View file

@ -0,0 +1,235 @@
{
"download": [
{
"download_reverse": false,
"url": "{ %array_apply(urls, %bilateral_url) }",
"webpage_url": "{modified_webpage_url}",
"ytdl_options": {
"playlist_items": "-1:0:-1"
}
},
{
"include_sibling_metadata": "{include_sibling_metadata}",
"playlist_thumbnails": [
{
"name": "{avatar_uncropped_thumbnail_file_name}",
"uid": "avatar_uncropped"
},
{
"name": "{banner_uncropped_thumbnail_file_name}",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "{avatar_uncropped_thumbnail_file_name}",
"uid": "avatar_uncropped"
},
{
"name": "{banner_uncropped_thumbnail_file_name}",
"uid": "banner_uncropped"
}
],
"url": "{ %array_at(urls, 0) }",
"webpage_url": "{modified_webpage_url}"
},
{
"include_sibling_metadata": "{include_sibling_metadata}",
"url": "{ %array_slice(urls, 1) }",
"webpage_url": "{modified_webpage_url}"
}
],
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"file_name": "{music_video_file_name}.{ext}",
"info_json_name": "{music_video_file_name}.{info_json_ext}",
"maintain_download_archive": true,
"output_directory": "{music_video_directory}",
"thumbnail_name": "{music_video_file_name}.jpg"
},
"overrides": {
"%bilateral_url": "{ \n %if(\n %and(\n enable_bilateral_scraping,\n subscription_has_download_archive,\n %is_bilateral_url($0)\n ),\n $0,\n \"\"\n )\n}",
"%contains_url_field": "{ %not( %is_null( %get_url_field( $0, null ) ) ) }",
"%flat_array__category_to_map_format": "{ \n %array_apply_fixed(\n %assert_then(\n %is_array( $1 ),\n $1,\n \"If using album categories, each category must map to an array\"\n ),\n $0,\n %flat_array__url_to_map_format \n )\n}",
"%flat_array__url_keyed_map_format": "{ { %map_get($0, \"url\"): $0 } }",
"%flat_array__url_to_map_format": "{\n %elif(\n %is_map($0),\n %map_extend( $0, { \"category\": $1 } ),\n %is_string($0),\n { \"url\": $0, \"category\": $1 },\n %throw(\"If using album categories, each URL must be either a string or map with metadata fields\")\n )\n}",
"%get_url_field": "{ %map_get( %map( %map_get( category_url_map, ytdl_sub_input_url, {} ) ), $0, $1 ) }",
"%get_url_i": "{ \n %map_get(\n %map( %array_at( category_url_array, %int( %sub($0, 1) ), {} ) ),\n \"url\",\n %array_at(subscription_array, %int( %sub($0, 1) ), null)\n )\n}",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"category_url_array": "{ %array_flatten( %map_apply( subscription_map, %flat_array__category_to_map_format ) ) }",
"category_url_map": "{\n %array_reduce(\n %array_apply( category_url_array, %flat_array__url_keyed_map_format ),\n %map_extend\n )\n}",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"file_title": "{title_sanitized_plex}",
"file_uid": "{uid_sanitized_plex}",
"include_sibling_metadata": false,
"modified_webpage_url": "{webpage_url}",
"music_video_album": "{ %get_url_field(\"category\", music_video_album_default) }",
"music_video_album_default": "Music Videos",
"music_video_artist": "{subscription_name}",
"music_video_date": "{ \n %elif(\n %contains_url_field(\"date\"),\n %get_url_field(\"date\", upload_date_standardized),\n\n %contains_url_field(\"year\"),\n %concat( %get_url_field(\"date\", upload_year), \"-01-01\"),\n\n upload_date_standardized\n )\n}",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp3bmucwsg",
"music_video_file_name": "{music_video_artist_sanitized}/{music_video_title_sanitized}{music_video_file_name_suffix}",
"music_video_file_name_suffix": "",
"music_video_genre": "{subscription_indent_1}",
"music_video_genre_default": "ytdl-sub",
"music_video_title": "{ %get_url_field(\"title\", title) }",
"music_video_year": "{\n %int(%elif(\n %contains_url_field(\"date\"),\n %slice( %get_url_field(\"date\", upload_date_standardized), 0, 4 ),\n\n %contains_url_field(\"year\"),\n %get_url_field(\"date\", upload_year),\n\n upload_year\n ))\n}",
"resolution_assert": "{\n %if(\n %and(\n enable_resolution_assert,\n %ne( height, 0 ),\n %not(resolution_assert_is_ignored)\n ),\n %assert(\n %gte( height, resolution_assert_height_gte ),\n %concat(\n \"Entry \",\n title,\n \" downloaded at a low resolution (\",\n resolution_readable,\n \"), you've probably been throttled. \",\n \"Stopping further downloads, wait a few hours and try again. \",\n \"Disable using the override variable `enable_resolution_assert: False`.\"\n )\n ),\n \"false is no-op\"\n )\n}",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [] }",
"resolution_assert_is_ignored": "{\n %print_if_true(\n %concat(title, \" has a match in resolution_assert_ignore_titles, skipping resolution assert.\"),\n %contains_any(title, resolution_assert_ignore_titles)\n )\n}",
"resolution_assert_print": "{\n %print(\n %if(\n enable_resolution_assert,\n \"Resolution assert is enabled, will fail on low-quality video downloads and presume throttle. Disable using the override variable `enable_resolution_assert: False`\",\n \"Resolution assert is disabled. Use at your own risk!\"\n ),\n enable_resolution_assert,\n -1\n )\n}",
"resolution_readable": "{width}x{height}",
"subscription_array": "{%from_json('''[\"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc\"]''')}",
"subscription_indent_1": "Pop",
"subscription_map": "{ {} }",
"subscription_value": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"subscription_value_1": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url": "{subscription_value}",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": "{ %array_apply(%range(100, 1), %get_url_i) }"
},
"preset": [
"_base",
"_plex_base",
"_url_bilateral_overrides",
"_multi_url_bilateral_inner",
"_multi_url",
"_multi_url_bilateral",
"_throttle_protection",
"_url_categorized",
"_plex_video_base",
"_music_video_base",
"_music_video_tags",
"Plex Music Videos",
"__preset__"
],
"throttle_protection": {
"enable": "{\n %print(\n %if(\n enable_throttle_protection,\n \"Throttle protection is enabled. Disable using the override variable `enable_throttle_protection: False`\",\n \"Throttle protection is disabled. Use at your own risk!\"\n ),\n enable_throttle_protection\n )\n}",
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"album": "{music_video_album}",
"artist": "{music_video_artist}",
"genre": "{music_video_genre}",
"premiered": "{music_video_date}",
"title": "{music_video_title}",
"year": "{music_video_year}"
},
"ytdl_options": {
"break_on_existing": true
}
}

View file

@ -0,0 +1,314 @@
{
"download": [
{
"download_reverse": true,
"include_sibling_metadata": false,
"playlist_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "",
"uid": "avatar_uncropped"
},
{
"name": "",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
],
"variables": {},
"webpage_url": "{ webpage_url }",
"ytdl_options": {}
}
],
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-Rick Astley-download-archive.json",
"file_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", title ) ), '' ) }.{ ext }",
"info_json_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", title ) ), '' ) }.{ info_json_ext }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpstvsa5ot",
"preserve_mtime": false,
"thumbnail_name": "{ %concat( \"Rick Astley\", \"/\", %sanitize( %get_url_field( \"title\", title ) ), '' ) }.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
"%contains_url_field": "{ %not( %is_null( %get_url_field( $0, '' ) ) ) }",
"%flat_array__category_to_map_format": "{ %array_apply_fixed( %assert_then( %is_array( $1 ), $1, \"If using album categories, each category must map to an array\" ), $0, %flat_array__url_to_map_format ) }",
"%flat_array__url_keyed_map_format": "{ { %map_get( $0, \"url\" ): $0 } }",
"%flat_array__url_to_map_format": "{ %elif( %is_map( $0 ), %map_extend( $0, { \"category\": $1 } ), %is_string( $0 ), { \"url\": $0, \"category\": $1 }, %throw( \"If using album categories, each URL must be either a string or map with metadata fields\" ) ) }",
"%get_url_field": "{ %map_get( %map( %map_get( category_url_map, ytdl_sub_input_url, { } ) ), $0, $1 ) }",
"%get_url_i": "{ %map_get( %map( %array_at( category_url_array, %int( %sub( $0, 1 ) ), { } ) ), \"url\", %array_at( subscription_array, %int( %sub( $0, 1 ) ), '' ) ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"avatar_uncropped_thumbnail_file_name": "",
"banner_uncropped_thumbnail_file_name": "",
"category_url_array": "{ [ ] }",
"category_url_map": "{ [ ] }",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"file_title": "{ title_sanitized_plex }",
"file_uid": "{ uid_sanitized_plex }",
"include_sibling_metadata": false,
"modified_webpage_url": "{ webpage_url }",
"music_video_album": "{ %get_url_field( \"category\", \"Music Videos\" ) }",
"music_video_album_default": "Music Videos",
"music_video_artist": "Rick Astley",
"music_video_date": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"music_video_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpstvsa5ot",
"music_video_file_name": "Rick Astley/{ %sanitize( %get_url_field( \"title\", title ) ) }",
"music_video_file_name_suffix": "",
"music_video_genre": "Pop",
"music_video_genre_default": "ytdl-sub",
"music_video_title": "{ %get_url_field( \"title\", title ) }",
"music_video_year": "{ %int( %elif( %contains_url_field( \"date\" ), %slice( %get_url_field( \"date\", upload_date_standardized ), 0, 4 ), %contains_url_field( \"year\" ), %get_url_field( \"date\", upload_year ), upload_year ) ) }",
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"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_print": true,
"resolution_readable": "{ width }x{ height }",
"subscription_array": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
],
"subscription_indent_1": "Pop",
"subscription_map": {},
"subscription_value": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"subscription_value_1": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
""
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"album": "{ %get_url_field( \"category\", \"Music Videos\" ) }",
"artist": "Rick Astley",
"genre": "Pop",
"premiered": "{ %elif( %contains_url_field( \"date\" ), %get_url_field( \"date\", upload_date_standardized ), %contains_url_field( \"year\" ), %concat( %get_url_field( \"date\", upload_year ), \"-01-01\" ), upload_date_standardized ) }",
"title": "{ %get_url_field( \"title\", title ) }",
"year": "{ %int( %elif( %contains_url_field( \"date\" ), %slice( %get_url_field( \"date\", upload_date_standardized ), 0, 4 ), %contains_url_field( \"year\" ), %get_url_field( \"date\", upload_year ), upload_year ) ) }"
}
}

View file

@ -0,0 +1,250 @@
{
"chapters": {
"allow_chapters_from_comments": false,
"embed_chapters": true,
"enable": true,
"force_key_frames": false
},
"date_range": {
"breaks": true,
"enable": true,
"type": "upload_date"
},
"download": [
{
"download_reverse": true,
"include_sibling_metadata": false,
"playlist_thumbnails": [
{
"name": "poster.jpg",
"uid": "avatar_uncropped"
},
{
"name": "fanart.jpg",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "poster.jpg",
"uid": "avatar_uncropped"
},
{
"name": "fanart.jpg",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/@novapbs"
],
"variables": {},
"webpage_url": "{ modified_webpage_url }",
"ytdl_options": {}
}
],
"file_convert": {
"convert_to": "mp4",
"convert_with": "yt-dlp",
"enable": true
},
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-NOVA PBS-download-archive.json",
"file_name": "{ episode_file_path }.{ ext }",
"info_json_name": "{ episode_file_path }.{ info_json_ext }",
"keep_files_date_eval": "{ episode_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpc_p6gjjw/NOVA PBS",
"preserve_mtime": false,
"thumbnail_name": "{ thumbnail_file_name }"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
"%episode_ordering_": "{ %eq( %lower( tv_show_by_date_episode_ordering ), $0 ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"%ordering_pair_eq": "{ %eq( [ tv_show_by_date_season_ordering, tv_show_by_date_episode_ordering ], [ $0, $1 ] ) }",
"%season_ordering_": "{ %eq( %lower( tv_show_by_date_season_ordering ), $0 ) }",
"assert_not_collection": true,
"avatar_uncropped_thumbnail_file_name": "poster.jpg",
"banner_uncropped_thumbnail_file_name": "fanart.jpg",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"episode_content_rating": "TV-14",
"episode_date_standardized": "{ upload_date_standardized }",
"episode_file_name": "s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex }",
"episode_file_path": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/{ %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) }",
"episode_number": "{ upload_month }{ upload_day_padded }{ upload_date_index_padded }",
"episode_number_and_padded_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"episode_number_padded": "{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) }",
"episode_plot": "{ webpage_url }\n\n{ description }",
"episode_title": "{ upload_date_standardized } - { title }",
"episode_year": "{ %slice( upload_date_standardized, 0, 4 ) }",
"file_title": "{ title_sanitized_plex }",
"file_uid": "{ uid_sanitized_plex }",
"include_sibling_metadata": false,
"modified_webpage_url": "{ webpage_url }",
"only_recent_date_range": "2months",
"only_recent_max_files": 30,
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"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_print": true,
"resolution_readable": "{ width }x{ height }",
"s01_name": "",
"s01_url": "",
"season_directory_name": "Season { upload_year }",
"season_number": "{ upload_year }",
"season_number_padded": "{ upload_year }",
"season_poster_file_name": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/Season{ upload_year }.jpg",
"subscription_array": [
"https://www.youtube.com/@novapbs"
],
"subscription_indent_1": "Documentaries",
"subscription_indent_2": "TV-14",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "TV-14",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "upload_date",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpc_p6gjjw",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "Documentaries",
"tv_show_genre_default": "ytdl-sub",
"tv_show_name": "NOVA PBS",
"tv_show_poster_file_name": "poster.jpg",
"url": "https://www.youtube.com/@novapbs",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/@novapbs"
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"contentRating": "TV-14",
"date": "{ episode_date_standardized }",
"episode_id": "{ episode_number }",
"genre": "Documentaries",
"show": "NOVA PBS",
"synopsis": "{ episode_plot }",
"title": "{ episode_title }",
"year": "{ episode_year }"
}
}

View file

@ -0,0 +1,250 @@
{
"chapters": {
"allow_chapters_from_comments": false,
"embed_chapters": true,
"enable": true,
"force_key_frames": false
},
"date_range": {
"breaks": true,
"enable": true,
"type": "upload_date"
},
"download": [
{
"download_reverse": true,
"include_sibling_metadata": false,
"playlist_thumbnails": [
{
"name": "poster.jpg",
"uid": "avatar_uncropped"
},
{
"name": "fanart.jpg",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "poster.jpg",
"uid": "avatar_uncropped"
},
{
"name": "fanart.jpg",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/@novapbs"
],
"variables": {},
"webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"ytdl_options": {}
}
],
"file_convert": {
"convert_to": "mp4",
"convert_with": "yt-dlp",
"enable": true
},
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-NOVA PBS-download-archive.json",
"file_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }.{ ext }",
"info_json_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }.info.json",
"keep_files_date_eval": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpps6shcpt/NOVA PBS",
"preserve_mtime": false,
"thumbnail_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }-thumb.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
"%episode_ordering_": "{ %eq( %lower( tv_show_by_date_episode_ordering ), $0 ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"%ordering_pair_eq": "{ %eq( [ tv_show_by_date_season_ordering, tv_show_by_date_episode_ordering ], [ $0, $1 ] ) }",
"%season_ordering_": "{ %eq( %lower( tv_show_by_date_season_ordering ), $0 ) }",
"assert_not_collection": true,
"avatar_uncropped_thumbnail_file_name": "poster.jpg",
"banner_uncropped_thumbnail_file_name": "fanart.jpg",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"episode_content_rating": "TV-14",
"episode_date_standardized": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"episode_file_name": "s{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.e{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) } - { %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"episode_file_path": "{ %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ) }/{ %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) }",
"episode_number": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ) }{ %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) }{ %pad_zero( upload_date_index, 2 ) }",
"episode_number_and_padded_": "{ [ %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ), 6 ] }",
"episode_number_padded": "{ %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ) }",
"episode_plot": "{ %map_get( entry_metadata, \"webpage_url\" ) }\n\n{ %map_get_non_empty( entry_metadata, \"description\", '' ) }",
"episode_title": "{ %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) } - { %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) }",
"episode_year": "{ %slice( %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ), 0, 4 ) }",
"file_title": "{ %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) }",
"file_uid": "{ %sanitize_plex_episode( %map_get( entry_metadata, \"id\" ) ) }",
"include_sibling_metadata": false,
"modified_webpage_url": "{ %map_get( entry_metadata, \"webpage_url\" ) }",
"only_recent_date_range": "2months",
"only_recent_max_files": 30,
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [ ] }",
"resolution_assert_is_ignored": "{ %print_if_true( %concat( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ), \" has a match in resolution_assert_ignore_titles, skipping resolution assert.\" ), %contains_any( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ), [ ] ) ) }",
"resolution_assert_print": true,
"resolution_readable": "{ width }x{ height }",
"s01_name": "",
"s01_url": "",
"season_directory_name": "Season { %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }",
"season_number": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }",
"season_number_padded": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }",
"season_poster_file_name": "{ %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ) }/Season{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) }.jpg",
"subscription_array": [
"https://www.youtube.com/@novapbs"
],
"subscription_indent_1": "Documentaries",
"subscription_indent_2": "TV-14",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "{ %concat( %sanitize( %concat( \"Season \", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ) ) ), \"/\", %sanitize( %concat( \"s\", %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"year\" ) ), \".e\", %pad_zero( %int( %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ) ), 6 ), \" - \", %sanitize_plex_episode( %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) ) ) ) ) }-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{ [ %concat( %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ), %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) ), %pad_zero( upload_date_index, 2 ) ), 6 ] }",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "TV-14",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "upload_date",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmpps6shcpt",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "Documentaries",
"tv_show_genre_default": "ytdl-sub",
"tv_show_name": "NOVA PBS",
"tv_show_poster_file_name": "poster.jpg",
"url": "https://www.youtube.com/@novapbs",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/@novapbs"
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"contentRating": "TV-14",
"date": "{ %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ) }",
"episode_id": "{ %int( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"month\" ) ) }{ %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"day_padded\" ) }{ %pad_zero( upload_date_index, 2 ) }",
"genre": "Documentaries",
"show": "NOVA PBS",
"synopsis": "{ %map_get( entry_metadata, \"webpage_url\" ) }\n\n{ %map_get_non_empty( entry_metadata, \"description\", '' ) }",
"title": "{ %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) } - { %map_get_non_empty( entry_metadata, \"title\", %map_get( entry_metadata, \"id\" ) ) }",
"year": "{ %slice( %string( %map_get( %to_date_metadata( %map_get_non_empty( entry_metadata, \"upload_date\", %datetime_strftime( %map_get( entry_metadata, \"epoch\" ), \"%Y%m%d\" ) ) ), \"date_standardized\" ) ), 0, 4 ) }"
}
}

View file

@ -0,0 +1,255 @@
{
"chapters": {
"embed_chapters": true
},
"date_range": {
"type": "{tv_show_date_range_type}"
},
"download": [
{
"download_reverse": false,
"url": "{ %array_apply(urls, %bilateral_url) }",
"webpage_url": "{modified_webpage_url}",
"ytdl_options": {
"playlist_items": "-1:0:-1"
}
},
{
"include_sibling_metadata": "{include_sibling_metadata}",
"playlist_thumbnails": [
{
"name": "{avatar_uncropped_thumbnail_file_name}",
"uid": "avatar_uncropped"
},
{
"name": "{banner_uncropped_thumbnail_file_name}",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "{avatar_uncropped_thumbnail_file_name}",
"uid": "avatar_uncropped"
},
{
"name": "{banner_uncropped_thumbnail_file_name}",
"uid": "banner_uncropped"
}
],
"url": "{ %array_at(urls, 0) }",
"webpage_url": "{modified_webpage_url}"
},
{
"include_sibling_metadata": "{include_sibling_metadata}",
"url": "{ %array_slice(urls, 1) }",
"webpage_url": "{modified_webpage_url}"
}
],
"file_convert": {
"convert_to": "mp4"
},
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"file_name": "{episode_file_path}.{ext}",
"info_json_name": "{episode_file_path}.{info_json_ext}",
"keep_files_date_eval": "{episode_date_standardized}",
"maintain_download_archive": true,
"output_directory": "{tv_show_directory}/{tv_show_name_sanitized}",
"thumbnail_name": "{thumbnail_file_name}"
},
"overrides": {
"%bilateral_url": "{ \n %if(\n %and(\n enable_bilateral_scraping,\n subscription_has_download_archive,\n %is_bilateral_url($0)\n ),\n $0,\n \"\"\n )\n}",
"%episode_ordering_": "{ %eq( %lower(tv_show_by_date_episode_ordering), $0 ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"%ordering_pair_eq": "{\n %eq([tv_show_by_date_season_ordering, tv_show_by_date_episode_ordering], [$0, $1])\n}",
"%season_ordering_": "{ %eq( %lower(tv_show_by_date_season_ordering), $0 ) }",
"assert_not_collection": "{\n %assert(\n %and(\n %not( %bool(s01_url) ),\n %not( %bool(s01_name) )\n ),\n \"Provided `s01_url` or `s01_name` variable to TV Show by Date preset when it expects `url`. Perhaps you meant to use the `TV Show Collection` preset?\"\n )\n}",
"avatar_uncropped_thumbnail_file_name": "{tv_show_poster_file_name}",
"banner_uncropped_thumbnail_file_name": "{tv_show_fanart_file_name}",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"episode_content_rating": "{tv_show_content_rating}",
"episode_date_standardized": "{\n %if(\n %contains(tv_show_by_date_season_ordering, \"release\"),\n release_date_standardized,\n upload_date_standardized\n )\n}",
"episode_file_name": "s{season_number_padded}.e{episode_number_padded} - {file_title}",
"episode_file_path": "{season_directory_name_sanitized}/{episode_file_name_sanitized}",
"episode_number": "{ %array_at(episode_number_and_padded_, 0) }",
"episode_number_and_padded_": "{\n %elif(\n %episode_ordering_( \"upload-day\" ), [ %concat(upload_day, upload_date_index_padded), 4 ],\n %episode_ordering_( \"upload-month-day\" ), [ %concat(upload_month, upload_day_padded, upload_date_index_padded), 6],\n %episode_ordering_( \"upload-month-day-reversed\" ), [ %concat(upload_day_of_year_reversed, upload_date_index_reversed_padded), 5],\n %episode_ordering_( \"release-day\" ), [ %concat(release_day, upload_date_index_padded), 4 ],\n %episode_ordering_( \"release-month-day\" ), [ %concat(release_month, release_day_padded, upload_date_index_padded), 6],\n %episode_ordering_( \"release-month-day-reversed\" ), [ %concat(release_day_of_year_reversed, upload_date_index_reversed_padded), 5],\n %episode_ordering_( \"download-index\" ), [ download_index, 6 ],\n %throw(\n 'tv_show_by_date_episode_ordering must be one of the following: \"upload-day\", \"upload-month-day\", \"upload-month-day-reversed\", \"release-day\", \"release-month-day\", \"release-month-day-reversed\", \"download-index\"'\n )\n )\n}",
"episode_number_padded": "{ %pad_zero( %int(episode_number), %int(%array_at(episode_number_and_padded_, 1))) }",
"episode_plot": "{webpage_url}\n\n{description}",
"episode_title": "{episode_date_standardized} - {title}",
"episode_year": "{%slice(episode_date_standardized, 0, 4)}",
"file_title": "{title_sanitized_plex}",
"file_uid": "{uid_sanitized_plex}",
"include_sibling_metadata": false,
"modified_webpage_url": "{webpage_url}",
"only_recent_date_range": "2months",
"only_recent_max_files": 30,
"resolution_assert": "{\n %if(\n %and(\n enable_resolution_assert,\n %ne( height, 0 ),\n %not(resolution_assert_is_ignored)\n ),\n %assert(\n %gte( height, resolution_assert_height_gte ),\n %concat(\n \"Entry \",\n title,\n \" downloaded at a low resolution (\",\n resolution_readable,\n \"), you've probably been throttled. \",\n \"Stopping further downloads, wait a few hours and try again. \",\n \"Disable using the override variable `enable_resolution_assert: False`.\"\n )\n ),\n \"false is no-op\"\n )\n}",
"resolution_assert_height_gte": 361,
"resolution_assert_ignore_titles": "{ [] }",
"resolution_assert_is_ignored": "{\n %print_if_true(\n %concat(title, \" has a match in resolution_assert_ignore_titles, skipping resolution assert.\"),\n %contains_any(title, resolution_assert_ignore_titles)\n )\n}",
"resolution_assert_print": "{\n %print(\n %if(\n enable_resolution_assert,\n \"Resolution assert is enabled, will fail on low-quality video downloads and presume throttle. Disable using the override variable `enable_resolution_assert: False`\",\n \"Resolution assert is disabled. Use at your own risk!\"\n ),\n enable_resolution_assert,\n -1\n )\n}",
"resolution_readable": "{width}x{height}",
"s01_name": "",
"s01_url": "",
"season_directory_name": "Season {season_number_padded}",
"season_number": "{\n %elif(\n %season_ordering_( \"upload-year\" ), upload_year,\n %season_ordering_( \"upload-year-month\" ), %concat(upload_year, upload_month_padded),\n %season_ordering_( \"release-year\" ), release_year,\n %season_ordering_( \"release-year-month\" ), %concat(release_year, release_month_padded),\n %throw(\n 'tv_show_by_date_season_ordering must be one of the following: \"upload-year\", \"upload-year-month\", \"release-year\", \"release-year-month\"'\n )\n )\n}",
"season_number_padded": "{season_number}",
"season_poster_file_name": "{season_directory_name_sanitized}/Season{season_number_padded}.jpg",
"subscription_array": "{%from_json('''[\"https://www.youtube.com/@novapbs\"]''')}",
"subscription_indent_1": "Documentaries",
"subscription_indent_2": "{tv_show_content_rating_default}",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "{episode_file_path}-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{\n %assert_then(\n %or(\n %ordering_pair_eq(\"upload-year\", \"upload-month-day\"),\n %ordering_pair_eq(\"upload-year\", \"upload-month-day-reversed\"),\n %ordering_pair_eq(\"upload-year\", \"download-index\"),\n %ordering_pair_eq(\"upload-year-month\", \"upload-day\"),\n %ordering_pair_eq(\"release-year\", \"release-month-day\"),\n %ordering_pair_eq(\"release-year\", \"release-month-day-reversed\"),\n %ordering_pair_eq(\"release-year\", \"download-index\"),\n %ordering_pair_eq(\"release-year-month\", \"release-day\")\n ),\n episode_number_and_padded_,\n \"Detected incompatibility between tv_show_by_date_season_ordering and tv_show_by_date_episode_ordering. Ensure you are not using both upload and release date, and that the year/month/day are included in the combined season and episode.\"\n )\n}",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "{subscription_indent_2}",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "{\n %if(\n %contains(tv_show_by_date_season_ordering, \"release\"),\n \"release_date\",\n \"upload_date\"\n )\n}",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp5yx9y73k",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "{subscription_indent_1}",
"tv_show_genre_default": "ytdl-sub",
"tv_show_name": "{subscription_name}",
"tv_show_poster_file_name": "poster.jpg",
"url": "{subscription_value}",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": "{subscription_array}"
},
"preset": [
"_season_by_year",
"plex_tv_show_by_date",
"season_by_year__episode_by_month_day",
"Plex TV Show by Date",
"__preset__"
],
"throttle_protection": {
"enable": "{\n %print(\n %if(\n enable_throttle_protection,\n \"Throttle protection is enabled. Disable using the override variable `enable_throttle_protection: False`\",\n \"Throttle protection is disabled. Use at your own risk!\"\n ),\n enable_throttle_protection\n )\n}",
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"contentRating": "{episode_content_rating}",
"date": "{episode_date_standardized}",
"episode_id": "{episode_number}",
"genre": "{tv_show_genre}",
"show": "{tv_show_name}",
"synopsis": "{episode_plot}",
"title": "{episode_title}",
"year": "{episode_year}"
},
"ytdl_options": {
"break_on_existing": true
}
}

View file

@ -0,0 +1,250 @@
{
"chapters": {
"allow_chapters_from_comments": false,
"embed_chapters": true,
"enable": true,
"force_key_frames": false
},
"date_range": {
"breaks": true,
"enable": true,
"type": "upload_date"
},
"download": [
{
"download_reverse": true,
"include_sibling_metadata": false,
"playlist_thumbnails": [
{
"name": "poster.jpg",
"uid": "avatar_uncropped"
},
{
"name": "fanart.jpg",
"uid": "banner_uncropped"
}
],
"source_thumbnails": [
{
"name": "poster.jpg",
"uid": "avatar_uncropped"
},
{
"name": "fanart.jpg",
"uid": "banner_uncropped"
}
],
"url": [
"https://www.youtube.com/@novapbs"
],
"variables": {},
"webpage_url": "{ webpage_url }",
"ytdl_options": {}
}
],
"file_convert": {
"convert_to": "mp4",
"convert_with": "yt-dlp",
"enable": true
},
"format": "(bv*[ext=mp4][vcodec~='^((he|a)vc|h26[45])']+ba[ext=m4a]) / (bv[ext=mp4]*+ba[ext=m4a]/b)",
"output_options": {
"download_archive_name": ".ytdl-sub-NOVA PBS-download-archive.json",
"file_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }.{ ext }",
"info_json_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }.{ info_json_ext }",
"keep_files_date_eval": "{ upload_date_standardized }",
"maintain_download_archive": true,
"output_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp3j0cm_tw/NOVA PBS",
"preserve_mtime": false,
"thumbnail_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }-thumb.jpg"
},
"overrides": {
"%bilateral_url": "{ %if( %and( enable_bilateral_scraping, subscription_has_download_archive, %is_bilateral_url( $0 ) ), $0, '' ) }",
"%episode_ordering_": "{ %eq( %lower( tv_show_by_date_episode_ordering ), $0 ) }",
"%is_bilateral_url": "{ %contains( $0, \"youtube.com/playlist\" ) }",
"%ordering_pair_eq": "{ %eq( [ tv_show_by_date_season_ordering, tv_show_by_date_episode_ordering ], [ $0, $1 ] ) }",
"%season_ordering_": "{ %eq( %lower( tv_show_by_date_season_ordering ), $0 ) }",
"assert_not_collection": true,
"avatar_uncropped_thumbnail_file_name": "poster.jpg",
"banner_uncropped_thumbnail_file_name": "fanart.jpg",
"enable_bilateral_scraping": true,
"enable_resolution_assert": true,
"enable_throttle_protection": true,
"episode_content_rating": "TV-14",
"episode_date_standardized": "{ upload_date_standardized }",
"episode_file_name": "s{ upload_year }.e{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) } - { title_sanitized_plex }",
"episode_file_path": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/{ %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) }",
"episode_number": "{ upload_month }{ upload_day_padded }{ upload_date_index_padded }",
"episode_number_and_padded_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"episode_number_padded": "{ %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ) }",
"episode_plot": "{ webpage_url }\n\n{ description }",
"episode_title": "{ upload_date_standardized } - { title }",
"episode_year": "{ %slice( upload_date_standardized, 0, 4 ) }",
"file_title": "{ title_sanitized_plex }",
"file_uid": "{ uid_sanitized_plex }",
"include_sibling_metadata": false,
"modified_webpage_url": "{ webpage_url }",
"only_recent_date_range": "2months",
"only_recent_max_files": 30,
"resolution_assert": "{ %if( %and( enable_resolution_assert, %ne( height, 0 ), %not( resolution_assert_is_ignored ) ), %assert( %gte( height, resolution_assert_height_gte ), %concat( \"Entry \", title, \" downloaded at a low resolution (\", resolution_readable, \"), you've probably been throttled. \", \"Stopping further downloads, wait a few hours and try again. \", \"Disable using the override variable `enable_resolution_assert: False`.\" ) ), \"false is no-op\" ) }",
"resolution_assert_height_gte": 361,
"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_print": true,
"resolution_readable": "{ width }x{ height }",
"s01_name": "",
"s01_url": "",
"season_directory_name": "Season { upload_year }",
"season_number": "{ upload_year }",
"season_number_padded": "{ upload_year }",
"season_poster_file_name": "{ %sanitize( %concat( \"Season \", upload_year ) ) }/Season{ upload_year }.jpg",
"subscription_array": [
"https://www.youtube.com/@novapbs"
],
"subscription_indent_1": "Documentaries",
"subscription_indent_2": "TV-14",
"subscription_value": "https://www.youtube.com/@novapbs",
"subscription_value_1": "https://www.youtube.com/@novapbs",
"thumbnail_file_name": "{ %concat( %sanitize( %concat( \"Season \", upload_year ) ), \"/\", %sanitize( %concat( \"s\", upload_year, \".e\", %pad_zero( %int( %concat( upload_month, upload_day_padded, upload_date_index_padded ) ), 6 ), \" - \", title_sanitized_plex ) ) ) }-thumb.jpg",
"tv_show_by_date_episode_ordering": "upload-month-day",
"tv_show_by_date_ordering_pair_validation_": "{ [ %concat( upload_month, upload_day_padded, upload_date_index_padded ), 6 ] }",
"tv_show_by_date_season_ordering": "upload-year",
"tv_show_content_rating": "TV-14",
"tv_show_content_rating_default": "TV-14",
"tv_show_date_range_type": "upload_date",
"tv_show_directory": "/var/folders/rw/hl1xmkmj68zdl2kjx3l0dwzc0000gn/T/tmp3j0cm_tw",
"tv_show_fanart_file_name": "fanart.jpg",
"tv_show_genre": "Documentaries",
"tv_show_genre_default": "ytdl-sub",
"tv_show_name": "NOVA PBS",
"tv_show_poster_file_name": "poster.jpg",
"url": "https://www.youtube.com/@novapbs",
"url10": "",
"url100": "",
"url11": "",
"url12": "",
"url13": "",
"url14": "",
"url15": "",
"url16": "",
"url17": "",
"url18": "",
"url19": "",
"url2": "",
"url20": "",
"url21": "",
"url22": "",
"url23": "",
"url24": "",
"url25": "",
"url26": "",
"url27": "",
"url28": "",
"url29": "",
"url3": "",
"url30": "",
"url31": "",
"url32": "",
"url33": "",
"url34": "",
"url35": "",
"url36": "",
"url37": "",
"url38": "",
"url39": "",
"url4": "",
"url40": "",
"url41": "",
"url42": "",
"url43": "",
"url44": "",
"url45": "",
"url46": "",
"url47": "",
"url48": "",
"url49": "",
"url5": "",
"url50": "",
"url51": "",
"url52": "",
"url53": "",
"url54": "",
"url55": "",
"url56": "",
"url57": "",
"url58": "",
"url59": "",
"url6": "",
"url60": "",
"url61": "",
"url62": "",
"url63": "",
"url64": "",
"url65": "",
"url66": "",
"url67": "",
"url68": "",
"url69": "",
"url7": "",
"url70": "",
"url71": "",
"url72": "",
"url73": "",
"url74": "",
"url75": "",
"url76": "",
"url77": "",
"url78": "",
"url79": "",
"url8": "",
"url80": "",
"url81": "",
"url82": "",
"url83": "",
"url84": "",
"url85": "",
"url86": "",
"url87": "",
"url88": "",
"url89": "",
"url9": "",
"url90": "",
"url91": "",
"url92": "",
"url93": "",
"url94": "",
"url95": "",
"url96": "",
"url97": "",
"url98": "",
"url99": "",
"urls": [
"https://www.youtube.com/@novapbs"
]
},
"throttle_protection": {
"enable": true,
"sleep_per_download_s": {
"max": 28.4,
"min": 13.8
},
"sleep_per_request_s": {
"max": 0.75,
"min": 0.0
},
"sleep_per_subscription_s": {
"max": 26.1,
"min": 16.3
}
},
"video_tags": {
"contentRating": "TV-14",
"date": "{ upload_date_standardized }",
"episode_id": "{ upload_month }{ upload_day_padded }{ upload_date_index_padded }",
"genre": "Documentaries",
"show": "NOVA PBS",
"synopsis": "{ webpage_url }\n\n{ description }",
"title": "{ upload_date_standardized } - { title }",
"year": "{ %slice( upload_date_standardized, 0, 4 ) }"
}
}

View file

@ -6,7 +6,6 @@ import pytest
from ytdl_sub.config.preset import Preset
from ytdl_sub.plugins.nfo_tags import NfoTagsOptions
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
from ytdl_sub.utils.exceptions import ValidationException
@ -120,6 +119,26 @@ class TestPreset:
== "today-2months"
)
def test_preset_preserve_mtime_option(self, config_file, youtube_video, output_options):
# Test preserve_mtime defaults to False
preset_default = Preset(
config=config_file,
name="test_default",
value={"download": youtube_video, "output_options": output_options},
)
assert preset_default.output_options.preserve_mtime is False
# Test preserve_mtime can be set to True
preset_enabled = Preset(
config=config_file,
name="test_enabled",
value={
"download": youtube_video,
"output_options": dict(output_options, **{"preserve_mtime": True}),
},
)
assert preset_enabled.output_options.preserve_mtime is True
@pytest.mark.parametrize(
"parent_preset", ["preset_self_loop", "preset_loop_0", "preset_loop_1"]
)
@ -137,100 +156,6 @@ class TestPreset:
},
)
def test_preset_error__source_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "{dne_var}"},
},
)
def test_preset_error__override_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {"output_directory": "{dne_var}", "file_name": "file"},
},
)
def test_preset_error__dict_source_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "file"},
"nfo_tags": {
"nfo_name": "the nfo name",
"nfo_root": "the root",
"tags": {"tag_a": "{dne_var}"},
},
},
)
def test_preset_error__dict_override_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": output_options,
"output_directory_nfo_tags": {
"nfo_name": "the nfo name",
"nfo_root": "the root",
"tags": {"tag_a": "{dne_var}"},
},
},
)
def test_preset_error__dict_override_variable_not_static(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="static formatters must contain variables that "
"have no dependency to entry variables",
):
_ = Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {
"output_directory": "{title}",
"file_name": "{uid}",
},
},
)
def test_preset_with_multi_url__contains_empty_url(self, config_file, output_options):
_ = Preset(
config=config_file,
@ -392,26 +317,3 @@ class TestPreset:
"overrides": {name: "ack"},
},
)
def test_preset_error_added_url_variable_cannot_resolve(self, config_file, output_options):
with pytest.raises(
ValidationException,
match=re.escape(
"variable the_bad_one cannot use the variables subtitles_ext because it "
"depends on other variables that are computed later in execution"
),
):
_ = Preset(
config=config_file,
name="test",
value={
"download": {
"url": "youtube.com/watch?v=123abc",
"variables": {"the_bad_one": "{subtitles_ext}"},
},
"subtitles": {
"embed_subtitles": True,
},
"output_options": {"output_directory": "dir", "file_name": "acjk"},
},
)

View file

@ -1,4 +1,3 @@
import json
import re
from contextlib import contextmanager
from pathlib import Path
@ -6,11 +5,15 @@ from typing import Dict
from unittest.mock import patch
import pytest
import yaml
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.validators.variable_validation import ResolutionLevel
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.plugins.nfo_tags import NfoTagsOptions
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.script import ScriptUtils
@contextmanager
@ -472,23 +475,6 @@ def test_advanced_tv_show_subscriptions(
overrides = subs[5].overrides
assert overrides.script.get("subscription_name").native == "Gardening with Ciscoe"
assert (
overrides.apply_formatter(overrides.dict["url"])
== "https://www.youtube.com/@gardeningwithciscoe4430"
)
assert (
overrides.apply_formatter(overrides.dict["url2"])
== "https://www.youtube.com/playlist?list=PLi8V8UemxeG6lo5if5H5g5EbsteELcb0_"
)
assert overrides.apply_formatter(overrides.dict["subscription_array"]) == json.dumps(
[
"https://www.youtube.com/@gardeningwithciscoe4430",
"https://www.youtube.com/playlist?list=PLi8V8UemxeG6lo5if5H5g5EbsteELcb0_",
"https://www.youtube.com/playlist?list=PLsJlQSR-KjmaQqqJ9jq18cF6XXXAR4kyn",
"https://www.youtube.com/watch?v=2vq-vPubS5I",
]
)
def test_music_subscriptions(default_config: ConfigFile, music_subscriptions_path: Path):
@ -524,7 +510,7 @@ def test_music_video_subscriptions(default_config: ConfigFile, music_video_subsc
)
assert jackson.get("subscription_indent_1").native == "Pop"
assert (
jackson.get("url").native
jackson.get("urls").native[0]
== "https://www.youtube.com/playlist?list=OLAK5uy_mnY03zP6abNWH929q2XhGzWD_2uKJ_n8E"
)
@ -532,17 +518,7 @@ def test_music_video_subscriptions(default_config: ConfigFile, music_video_subsc
gnr = subs[3].overrides.script
assert gnr.get("subscription_name").native == "Guns N' Roses"
assert (
gnr.get("url").native
== "https://www.youtube.com/playlist?list=PLOTK54q5K4INNXaHKtmXYr6J7CajWjqeJ"
)
gnr_urls = gnr.get("urls").native
assert gnr_urls[0] == "https://www.youtube.com/playlist?list=PLOTK54q5K4INNXaHKtmXYr6J7CajWjqeJ"
assert gnr.get("subscription_indent_1").native == "Rock"
assert gnr.get("url2").native == "https://www.youtube.com/watch?v=OldpIhHPsbs"
def test_default_docker_config_and_subscriptions():
default_config = ConfigFile.from_file_path("docker/root/defaults/config.yaml")
default_subs = Subscription.from_file_path(
config=default_config, subscription_path=Path("docker/root/defaults/subscriptions.yaml")
)
assert len(default_subs) == 1
assert gnr_urls[1] == "https://www.youtube.com/watch?v=OldpIhHPsbs"

View file

@ -0,0 +1,92 @@
from pathlib import Path
import pytest
import yaml
from resources import expected_json
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.file_path import FilePathTruncater
def _ensure_resolved_yaml(
sub: Subscription, output_directory: str, preset_type: str, resolution_level: int
) -> None:
output_yaml = sub.resolved_yaml(resolution_level=resolution_level)
out = yaml.safe_load(output_yaml)
expected_out_filename = (
f"{preset_type}/inspect_sub_{ResolutionLevel.name_of(resolution_level)}.json"
)
expected_out = expected_json(out, expected_out_filename)
if resolution_level > ResolutionLevel.ORIGINAL:
output_path = Path(output_directory)
if "tv_show_directory" in expected_out["overrides"]:
output_path = output_path / sub.name
expected_out["output_options"]["output_directory"] = FilePathTruncater.to_native_filepath(
str(output_path)
)
if "tv_show_directory" in expected_out["overrides"]:
expected_out["overrides"]["tv_show_directory"] = output_directory
if "music_directory" in expected_out["overrides"]:
expected_out["overrides"]["music_directory"] = output_directory
if "music_video_directory" in expected_out["overrides"]:
expected_out["overrides"]["music_video_directory"] = output_directory
assert out == expected_out
@pytest.mark.parametrize("resolution_level", ResolutionLevel.all())
class TestResolution:
def test_resolution_tv_show(
self,
resolution_level: int,
default_config: ConfigFile,
tv_show_subscriptions_path: Path,
output_directory: str,
):
_ensure_resolved_yaml(
sub=Subscription.from_file_path(
config=default_config, subscription_path=tv_show_subscriptions_path
)[0],
output_directory=output_directory,
preset_type="tv_show",
resolution_level=resolution_level,
)
def test_resolution_music(
self,
resolution_level: int,
default_config: ConfigFile,
music_subscriptions_path: Path,
output_directory: str,
):
_ensure_resolved_yaml(
sub=Subscription.from_file_path(
config=default_config, subscription_path=music_subscriptions_path
)[0],
output_directory=output_directory,
preset_type="music",
resolution_level=resolution_level,
)
def test_resolution_music_video(
self,
resolution_level: int,
default_config: ConfigFile,
music_video_subscription_path: Path,
output_directory: str,
):
_ensure_resolved_yaml(
sub=Subscription.from_file_path(
config=default_config, subscription_path=music_video_subscription_path
)[0],
output_directory=output_directory,
preset_type="music_video",
resolution_level=resolution_level,
)

View file

@ -0,0 +1,172 @@
import re
import pytest
from ytdl_sub.config.preset import Preset
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
from ytdl_sub.utils.exceptions import ValidationException
class TestSubscriptionValidation:
def test_preset_error__source_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "{dne_var}"},
},
),
config=config_file,
)
def test_preset_error__override_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {"output_directory": "{dne_var}", "file_name": "file"},
},
),
config=config_file,
)
def test_preset_error__dict_source_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {"output_directory": "dir", "file_name": "file"},
"nfo_tags": {
"nfo_name": "the nfo name",
"nfo_root": "the root",
"tags": {"tag_a": "{dne_var}"},
},
},
),
config=config_file,
)
def test_preset_error__dict_override_variable_does_not_exist(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="contains the following variables that do not exist: dne_var",
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": output_options,
"output_directory_nfo_tags": {
"nfo_name": "the nfo name",
"nfo_root": "the root",
"tags": {"tag_a": "{dne_var}"},
},
},
),
config=config_file,
)
def test_preset_error__dict_override_variable_not_static(
self, config_file, output_options, youtube_video
):
with pytest.raises(
StringFormattingVariableNotFoundException,
match="static formatters must contain variables that "
"have no dependency to entry variables",
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": youtube_video,
"output_options": {
"output_directory": "{title}",
"file_name": "{uid}",
},
},
),
config=config_file,
)
def test_preset_error_added_url_variable_cannot_resolve(self, config_file, output_options):
with pytest.raises(
ValidationException,
match=re.escape(
"variable the_bad_one cannot use the variables subtitles_ext because it "
"depends on other variables that are computed later in execution"
),
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": {
"url": "youtube.com/watch?v=123abc",
"variables": {"the_bad_one": "{subtitles_ext}"},
},
"subtitles": {
"embed_subtitles": True,
},
"output_options": {"output_directory": "dir", "file_name": "acjk"},
},
),
config=config_file,
)
def test_preset_error_override_name_conflicts_with_plugin(self, config_file, output_options):
with pytest.raises(
ValidationException,
match=re.escape(
"Override variable with name throttle_protection 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."
),
):
_ = Subscription.from_preset(
preset=Preset(
config=config_file,
name="test",
value={
"download": {
"url": "youtube.com/watch?v=123abc",
},
"subtitles": {
"embed_subtitles": True,
},
"output_options": {"output_directory": "dir", "file_name": "acjk"},
"overrides": {"throttle_protection": "nope"},
},
),
config=config_file,
)

View file

@ -1,5 +1,6 @@
from typing import Type
from tools.docgen.configuration import ConfigurationDocGen
from tools.docgen.docgen import DocGen
from tools.docgen.entry_variables import EntryVariablesDocGen
from tools.docgen.plugins import PluginsDocGen
@ -28,3 +29,6 @@ class TestDocGen:
def test_plugins_generated(self):
_test_doc_gen(PluginsDocGen)
def test_configuration_generated(self):
_test_doc_gen(ConfigurationDocGen)

View file

@ -65,8 +65,8 @@ def mock_entry_to_dict(
"epoch": 1596878400,
"epoch_date": "20200808",
"epoch_hour": "09",
"title": "entry title",
"title_sanitized": "entry title",
"title": "entry {title}",
"title_sanitized": "entry {title}",
"ext": ext,
"description": "",
"creator": "abc123",
@ -113,8 +113,8 @@ def mock_entry_to_dict(
"playlist_count": 1,
"playlist_max_upload_year": 2021,
"playlist_max_upload_year_truncated": 21,
"playlist_title": "entry title",
"playlist_title_sanitized": "entry title",
"playlist_title": "entry {title}",
"playlist_title_sanitized": "entry {title}",
"playlist_description": "",
"playlist_webpage_url": "https://yourname.here",
"playlist_uid": "abc123",
@ -126,15 +126,15 @@ def mock_entry_to_dict(
"source_description": "",
"source_index": 1,
"source_index_padded": "01",
"source_title": "entry title",
"source_title_sanitized": "entry title",
"source_title": "entry {title}",
"source_title_sanitized": "entry {title}",
"source_webpage_url": "https://yourname.here",
"source_uid": "abc123",
"source_uploader": "abc123",
"source_uploader_id": "abc123",
"source_uploader_url": "https://yourname.here",
"uid_sanitized_plex": "abc",
"title_sanitized_plex": "entry title",
"title_sanitized_plex": "entry {title}",
"release_date": upload_date,
"release_date_standardized": "2021-01-12",
"release_year": 2021,

View file

@ -244,7 +244,9 @@ def test_no_positional_arg_command(mock_sys_exit, tv_show_config_path):
main()
assert mock_error.call_count == 1
assert mock_error.call_args.args[0] == "Must provide one of the commands: sub, dl, view"
assert mock_error.call_args.args[0] == (
"Must provide one of the commands: sub, dl, view, cli-to-sub"
)
def test_bad_config_path(mock_sys_exit):
@ -264,3 +266,18 @@ def test_bad_config_path(mock_sys_exit):
"The config file 'does_not_exist.yaml' could not be found. "
"Did you set --config correctly?"
)
def test_cli_to_sub(mock_sys_exit, capsys):
with (
mock_sys_exit(expected_exit_code=0),
patch.object(
sys,
"argv",
["ytdl-sub", "cli-to-sub", "--mark-watched", "--mtime"],
),
):
main()
captured = capsys.readouterr()
assert captured.out == ("ytdl_options:\n mark_watched: true\n updatetime: true\n\n")

View file

@ -0,0 +1,111 @@
import re
from typing import Any
from typing import Dict
import pytest
import yt_dlp
from conftest import assert_logs
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.downloaders.ytdlp import YTDLP
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_path import FilePathTruncater
@pytest.fixture
def preset_dict(output_directory) -> Dict[str, Any]:
return {
"download": "https://your.name.here",
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
}
class TestYtdlOptions:
def test_ytdl_options_are_strings(
self,
default_config: ConfigFile,
preset_dict: Dict[str, Any],
working_directory,
):
expected_ytdl_options = {
"ignoreerrors": True,
"outtmpl": FilePathTruncater.to_native_filepath(
f"{working_directory}/test_ytdl_options/%(id)S.%(ext)s"
),
"writethumbnail": False,
"ffmpeg_location": FFMPEG.ffmpeg_path(),
"match_filter": yt_dlp.utils.match_filter_func(
["!is_live & !is_upcoming & !post_live"], []
),
"skip_download": True,
"writeinfojson": True,
"extract_flat": "discard",
}
with (
assert_logs(
logger=YTDLP.logger,
expected_message=f"ytdl_options: {str(expected_ytdl_options)}",
log_level="debug",
expected_occurrences=1,
),
):
_ = Subscription.from_dict(
config=default_config,
preset_name="test_ytdl_options",
preset_dict=preset_dict,
).download(dry_run=True)
def test_cookiefile_does_not_exist(
self,
default_config: ConfigFile,
preset_dict: Dict[str, Any],
):
preset_dict["ytdl_options"] = {
"cookiefile": "/path/to/nowhere",
}
error_msg = "Specified cookiefile /path/to/nowhere but it does not exist as a file."
with pytest.raises(ValidationException, match=re.escape(error_msg)):
Subscription.from_dict(
config=default_config,
preset_name="test_ytdl_options",
preset_dict=preset_dict,
).download(dry_run=False)
def test_ytdl_option_types_preserved(
self,
default_config: ConfigFile,
output_directory: str,
):
preset_dict = {
"download": "https://your.name.here",
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
"ytdl_options": {
"break_on_existing": True,
"js_runtimes": {"deno": {"path": "/usr/local/bin/{dnope}"}},
"string_path": "verify overrides: {test_string}",
"list_test": ["hmmm"],
},
"overrides": {"test_string": "hi", "dnope": "deno"},
}
sub = Subscription.from_dict(
config=default_config,
preset_name="test_ytdl_options",
preset_dict=preset_dict,
)
out = sub.ytdl_options.to_native_dict(sub.overrides)
expected = {
"break_on_existing": True,
"js_runtimes": {"deno": {"path": "/usr/local/bin/deno"}},
"string_path": "verify overrides: hi",
"list_test": ["hmmm"],
}
assert out == expected

View file

@ -1,6 +1,7 @@
import re
import pytest
import yaml
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.subscriptions.subscription import Subscription
@ -24,3 +25,68 @@ class TestTvShowByDatePreset:
"overrides": {"tv_show_directory": "abc", "s01_url": "test"},
},
)
def test_backward_compatibility_single_download(self, default_config):
a = yaml.safe_load(
Subscription.from_dict(
config=default_config,
preset_name="a",
preset_dict={
"preset": "Jellyfin TV Show by Date",
"overrides": {"tv_show_directory": "abc", "url": "test_1"},
},
).resolved_yaml()
)
b = yaml.safe_load(
Subscription.from_dict(
config=default_config,
preset_name="a",
preset_dict={
"preset": "Jellyfin TV Show by Date",
"overrides": {"tv_show_directory": "abc", "subscription_value": "test_1"},
},
).resolved_yaml()
)
assert a["download"] == b["download"]
def test_backward_compatibility_multi_download(self, default_config):
a = yaml.safe_load(
Subscription.from_dict(
config=default_config,
preset_name="a",
preset_dict={
"preset": "Jellyfin TV Show by Date",
"overrides": {"tv_show_directory": "abc", "url": "test_1", "url2": "test_2"},
},
).resolved_yaml()
)
b = yaml.safe_load(
Subscription.from_dict(
config=default_config,
preset_name="a",
preset_dict={
"preset": "Jellyfin TV Show by Date",
"overrides": {
"tv_show_directory": "abc",
"subscription_array": ["test_1", "test_2"],
},
},
).resolved_yaml()
)
c = yaml.safe_load(
Subscription.from_dict(
config=default_config,
preset_name="a",
preset_dict={
"preset": "Jellyfin TV Show by Date",
"overrides": {"tv_show_directory": "abc", "urls": ["test_1", "test_2"]},
},
).resolved_yaml()
)
assert a["download"] == b["download"]
assert a["download"] == c["download"]

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