Merge branch 'master' into j/ruff-linter
This commit is contained in:
commit
249f421295
113 changed files with 6551 additions and 2119 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -146,8 +146,11 @@ docker/testing/volumes
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
.ytdl-sub-working-directory
|
.ytdl-sub-working-directory
|
||||||
|
.ytdl-sub-lock
|
||||||
|
|
||||||
ffmpeg.exe
|
ffmpeg.exe
|
||||||
ffprobe.exe
|
ffprobe.exe
|
||||||
|
|
||||||
tools/docgen/out
|
tools/docgen/out
|
||||||
|
|
||||||
|
prof/
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ __preset__:
|
||||||
|
|
||||||
# Pass any arg directly to yt-dlp's Python API
|
# Pass any arg directly to yt-dlp's Python API
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
cookiefile: "/config/cookie.txt"
|
cookiefile: "/config/ytdl-sub-configs/cookie.txt"
|
||||||
|
|
||||||
###################################################################
|
###################################################################
|
||||||
# TV Show Presets. Can replace Plex with Plex/Jellyfin/Emby/Kodi
|
# TV Show Presets. Can replace Plex with Plex/Jellyfin/Emby/Kodi
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,10 @@ RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
|
||||||
phantomjs --version && \
|
phantomjs --version && \
|
||||||
cd -; \
|
cd -; \
|
||||||
fi && \
|
fi && \
|
||||||
# Install ytdl-sub, ensure it is installed properly
|
# Configure pip globally
|
||||||
python3 -m pip install --break-system-packages --no-cache-dir ytdl_sub-*.whl && \
|
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 && \
|
ytdl-sub -h && \
|
||||||
# Delete unneeded packages after install
|
# Delete unneeded packages after install
|
||||||
rm ytdl_sub-*.whl && \
|
rm ytdl_sub-*.whl && \
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,10 @@ RUN mkdir -p /config && \
|
||||||
# Install Deno, required for YouTube downloads
|
# Install Deno, required for YouTube downloads
|
||||||
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
|
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
|
||||||
deno --help && \
|
deno --help && \
|
||||||
# Install ytdl-sub, ensure it is installed properly
|
# Configure pip globally
|
||||||
python3 -m pip install --no-cache-dir --break-system-packages ytdl_sub-*.whl && \
|
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 && \
|
ytdl-sub -h && \
|
||||||
# Delete unneeded packages after install
|
# Delete unneeded packages after install
|
||||||
rm ytdl_sub-*.whl && \
|
rm ytdl_sub-*.whl && \
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,10 @@ RUN mkdir -pv "${DEFAULT_WORKSPACE}" && \
|
||||||
# Install Deno, required for YouTube downloads
|
# Install Deno, required for YouTube downloads
|
||||||
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
|
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y --no-modify-path && \
|
||||||
deno --help && \
|
deno --help && \
|
||||||
# Install ytdl-sub, ensure it is installed properly
|
# Configure pip globally
|
||||||
python3 -m pip install --no-cache-dir --break-system-packages ytdl_sub-*.whl && \
|
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 && \
|
ytdl-sub -h && \
|
||||||
# Delete unneeded packages after install
|
# Delete unneeded packages after install
|
||||||
rm ytdl_sub-*.whl && \
|
rm ytdl_sub-*.whl && \
|
||||||
|
|
|
||||||
16
docker/root/custom-cont-init.d/defaults
Normal file → Executable file
16
docker/root/custom-cont-init.d/defaults
Normal file → Executable file
|
|
@ -17,25 +17,25 @@ echo "Starting ytdl-sub..."
|
||||||
echo "alias ls='ls --color=auto'" > /config/.bashrc && \
|
echo "alias ls='ls --color=auto'" > /config/.bashrc && \
|
||||||
echo "cd ." >> /config/.bashrc
|
echo "cd ." >> /config/.bashrc
|
||||||
|
|
||||||
|
# always create empty cron log file on start
|
||||||
|
echo "" > "$LOGS_TO_STDOUT"
|
||||||
|
|
||||||
# permissions
|
# permissions
|
||||||
chown -R ${PUID:-abc}:${PGID:-abc} \
|
chown -R ${PUID:-abc}:${PGID:-abc} \
|
||||||
/config
|
/config
|
||||||
|
|
||||||
# always create empty cron log file on start
|
|
||||||
echo "" > "$LOGS_TO_STDOUT"
|
|
||||||
|
|
||||||
# update command reference:
|
# update command reference:
|
||||||
# https://github.com/yt-dlp/yt-dlp/wiki/Installation#with-pip
|
# https://github.com/yt-dlp/yt-dlp/wiki/Installation#with-pip
|
||||||
if [ "$UPDATE_YT_DLP_ON_START" == "stable" ] ; then
|
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."
|
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
|
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."
|
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
|
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."
|
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 -U pip hatchling wheel
|
||||||
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 --force-reinstall "yt-dlp[default] @ https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz"
|
||||||
else
|
else
|
||||||
echo "UPDATE_YT_DLP_ON_START is not set, using packaged version."
|
echo "UPDATE_YT_DLP_ON_START is not set, using packaged version."
|
||||||
fi
|
fi
|
||||||
|
|
@ -47,6 +47,8 @@ if [ "$CRON_SCHEDULE" != "" ] ; then
|
||||||
|
|
||||||
# create cron script wrapper
|
# create cron script wrapper
|
||||||
echo '#!/bin/bash' > "$CRON_WRAPPER_SCRIPT"
|
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 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" >> "$CRON_WRAPPER_SCRIPT"
|
||||||
echo "cd \"$DEFAULT_WORKSPACE\"" >> "$CRON_WRAPPER_SCRIPT"
|
echo "cd \"$DEFAULT_WORKSPACE\"" >> "$CRON_WRAPPER_SCRIPT"
|
||||||
echo ". \"$CRON_SCRIPT\" >> \"$LOGS_TO_STDOUT\" 2>&1" >> "$CRON_WRAPPER_SCRIPT"
|
echo ". \"$CRON_SCRIPT\" >> \"$LOGS_TO_STDOUT\" 2>&1" >> "$CRON_WRAPPER_SCRIPT"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
echo "Beginning cron job..."
|
|
||||||
|
|
||||||
# Place your ytdl-sub command(s) here.
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ __preset__:
|
||||||
|
|
||||||
# Pass any arg directly to yt-dlp's Python API
|
# Pass any arg directly to yt-dlp's Python API
|
||||||
# ytdl_options:
|
# ytdl_options:
|
||||||
# cookiefile: "/config/cookie.txt"
|
# cookiefile: "/config/ytdl-sub-configs/cookie.txt"
|
||||||
|
|
||||||
###################################################################
|
###################################################################
|
||||||
# Subscriptions nested under this will use the
|
# Subscriptions nested under this will use the
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
project = "ytdl-sub"
|
project = "ytdl-sub"
|
||||||
copyright = "2024, Jesse Bannon"
|
copyright = "2026, Jesse Bannon"
|
||||||
author = "Jesse Bannon"
|
author = "Jesse Bannon"
|
||||||
release = ""
|
release = ""
|
||||||
|
|
||||||
|
|
@ -15,10 +15,8 @@ release = ""
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
||||||
extensions = [
|
extensions = [
|
||||||
"sphinx.ext.autodoc",
|
|
||||||
"sphinx.ext.autosectionlabel",
|
"sphinx.ext.autosectionlabel",
|
||||||
"sphinx.ext.extlinks",
|
"sphinx.ext.extlinks",
|
||||||
"sphinx.ext.napoleon",
|
|
||||||
"sphinx_copybutton",
|
"sphinx_copybutton",
|
||||||
"sphinx_design",
|
"sphinx_design",
|
||||||
]
|
]
|
||||||
|
|
@ -70,19 +68,3 @@ extlinks = {
|
||||||
"lsio-gh": ("https://github.com/linuxserver/%s", "%s image"),
|
"lsio-gh": ("https://github.com/linuxserver/%s", "%s image"),
|
||||||
"ytdl-sub-gh": ("https://github.com/jmbannon/ytdl-sub/%s", "src %s"),
|
"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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
Configuration File
|
||||||
==================
|
==================
|
||||||
|
|
||||||
ytdl-sub is configured using a ``config.yaml`` file.
|
ytdl-sub is configured using a ``config.yaml`` file.
|
||||||
|
|
||||||
The ``config.yaml`` is made up of two sections:
|
The ``config.yaml`` is made up of two sections:
|
||||||
|
|
@ -11,113 +17,118 @@ The ``config.yaml`` is made up of two sections:
|
||||||
configuration:
|
configuration:
|
||||||
presets:
|
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``.
|
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
|
If you prefer to use a Windows backslash, note that it must have
|
||||||
``C:\\double\\bashslash\\paths`` in order to escape the backslash character.
|
``C:\\double\\bashslash\\paths`` in order to escape the backslash character. This is due
|
||||||
|
to it being a YAML escape 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:
|
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
configuration:
|
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:
|
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()
|
umask: "022"
|
||||||
:members:
|
working_directory: ".ytdl-sub-working-directory"
|
||||||
:member-order: bysource
|
|
||||||
|
|
||||||
|
dl_aliases
|
||||||
|
----------
|
||||||
|
.. _dl_aliases:
|
||||||
|
|
||||||
presets
|
Alias definitions to shorten :ref:`dl arguments <usage:Download Options>`. For example,
|
||||||
-------
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
presets:
|
configuration:
|
||||||
custom_preset:
|
dl_aliases:
|
||||||
...
|
mv: "--preset music_video"
|
||||||
parent_preset:
|
u: "--download.url"
|
||||||
...
|
|
||||||
child_preset:
|
|
||||||
preset:
|
|
||||||
- "parent_preset"
|
|
||||||
|
|
||||||
In the example above, ``child_preset`` inherits all fields defined in ``parent_preset``.
|
Simplifies
|
||||||
Use parent presets where possible to reduce duplicate yaml definitions.
|
|
||||||
|
|
||||||
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:
|
to
|
||||||
preset:
|
|
||||||
- "custom_preset"
|
|
||||||
- "parent_preset"
|
|
||||||
|
|
||||||
In this example, ``child_preset`` will inherit all fields from ``custom_preset`` and
|
.. code-block:: bash
|
||||||
``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:
|
|
||||||
|
|
||||||
- if two conflicting keys arent lists or mappings, overwrite the higher priority one
|
ytdl-sub dl --mv --u "youtube.com/watch?v=a1b2c3"
|
||||||
- otherwise, combine then re-evaluate
|
|
||||||
|
|
||||||
If you are only inheriting from one preset, using a single string instead of a list is
|
experimental
|
||||||
valid, for example ``preset: "parent_preset"``, but we recommend always using a list for
|
------------
|
||||||
consistent readability between presets.
|
Experimental flags reside under the ``experimental`` key.
|
||||||
|
|
||||||
.. _`mergedeep`:
|
``enable_update_with_info_json``
|
||||||
https://mergedeep.readthedocs.io/en/latest/
|
|
||||||
.. _`a TYPESAFE_ADDITIVE merge`:
|
Enables modifying subscription files using info.json files using the argument
|
||||||
https://mergedeep.readthedocs.io/en/latest/index.html#merge-strategies
|
``--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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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.
|
opus, vorbis, wav, and best to grab the best possible format at runtime.
|
||||||
|
|
||||||
|
|
||||||
``enable``
|
``enable``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``quality``
|
``quality``
|
||||||
|
|
||||||
:expected type: Float
|
: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``
|
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.
|
(worse) for variable bitrate, or a specific bitrate like ``128`` for 128k.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
chapters
|
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
|
Defaults to False. If chapters do not exist in the video/description itself, attempt to
|
||||||
scrape comments to find the chapters.
|
scrape comments to find the chapters.
|
||||||
|
|
||||||
|
|
||||||
``embed_chapters``
|
``embed_chapters``
|
||||||
|
|
||||||
:expected type: Optional[Boolean]
|
:expected type: Optional[Boolean]
|
||||||
:description:
|
:description:
|
||||||
Defaults to True. Embed chapters into the file.
|
Defaults to True. Embed chapters into the file.
|
||||||
|
|
||||||
|
|
||||||
``enable``
|
``enable``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``force_key_frames``
|
``force_key_frames``
|
||||||
|
|
||||||
:expected type: Optional[Boolean]
|
: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
|
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.
|
needing a re-encode, but the resulting video may have fewer artifacts around the cuts.
|
||||||
|
|
||||||
|
|
||||||
``remove_chapters_regex``
|
``remove_chapters_regex``
|
||||||
|
|
||||||
:expected type: Optional[List[RegexString]
|
: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
|
List of regex patterns to match chapter titles against and remove them from the
|
||||||
entry.
|
entry.
|
||||||
|
|
||||||
|
|
||||||
``remove_sponsorblock_categories``
|
``remove_sponsorblock_categories``
|
||||||
|
|
||||||
:expected type: Optional[List[String]]
|
: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
|
categories that are specified in ``sponsorblock_categories`` or "all", which removes
|
||||||
everything specified in ``sponsorblock_categories``.
|
everything specified in ``sponsorblock_categories``.
|
||||||
|
|
||||||
|
|
||||||
``sponsorblock_categories``
|
``sponsorblock_categories``
|
||||||
|
|
||||||
:expected type: Optional[List[String]]
|
: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",
|
"intro", "outro", "selfpromo", "preview", "filler", "interaction", "music_offtopic",
|
||||||
"poi_highlight", or "all" to include all categories.
|
"poi_highlight", or "all" to include all categories.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
date_range
|
date_range
|
||||||
|
|
@ -169,14 +159,12 @@ intended download files.
|
||||||
:description:
|
:description:
|
||||||
Only download videos after or on this datetime, inclusive.
|
Only download videos after or on this datetime, inclusive.
|
||||||
|
|
||||||
|
|
||||||
``before``
|
``before``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
:expected type: Optional[OverridesFormatter]
|
||||||
:description:
|
:description:
|
||||||
Only download videos only before this datetime, not inclusive.
|
Only download videos only before this datetime, not inclusive.
|
||||||
|
|
||||||
|
|
||||||
``breaks``
|
``breaks``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
:expected type: Optional[OverridesFormatter]
|
||||||
|
|
@ -184,7 +172,6 @@ intended download files.
|
||||||
Toggle to enable breaking subsequent metadata downloads if an entry's upload date
|
Toggle to enable breaking subsequent metadata downloads if an entry's upload date
|
||||||
is out of range. Defaults to True.
|
is out of range. Defaults to True.
|
||||||
|
|
||||||
|
|
||||||
``enable``
|
``enable``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``type``
|
``type``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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``.
|
Which type of date to use. Must be either ``upload_date`` or ``release_date``.
|
||||||
Defaults to ``upload_date``.
|
Defaults to ``upload_date``.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
download
|
download
|
||||||
|
|
@ -305,7 +290,6 @@ Also supports custom ffmpeg conversions:
|
||||||
- Video: avi, flv, mkv, mov, mp4, webm
|
- Video: avi, flv, mkv, mov, mp4, webm
|
||||||
- Audio: aac, flac, mp3, m4a, opus, vorbis, wav
|
- Audio: aac, flac, mp3, m4a, opus, vorbis, wav
|
||||||
|
|
||||||
|
|
||||||
``convert_with``
|
``convert_with``
|
||||||
|
|
||||||
:expected type: Optional[String]
|
: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
|
yt-dlp whereas ``ffmpeg`` specifies it will be converted using a custom command specified
|
||||||
with ``ffmpeg_post_process_args``. Defaults to ``yt-dlp``.
|
with ``ffmpeg_post_process_args``. Defaults to ``yt-dlp``.
|
||||||
|
|
||||||
|
|
||||||
``enable``
|
``enable``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``ffmpeg_post_process_args``
|
``ffmpeg_post_process_args``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
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``.
|
can still be set with ``convert_with`` set to ``yt-dlp``.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
filter_exclude
|
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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``kodi_safe``
|
``kodi_safe``
|
||||||
|
|
||||||
:expected type: OverridesBooleanFormatterValidator
|
: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
|
emojis and some foreign language characters. Setting this to True will replace those
|
||||||
characters with '□'.
|
characters with '□'.
|
||||||
|
|
||||||
|
|
||||||
``nfo_name``
|
``nfo_name``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
:expected type: EntryFormatter
|
||||||
:description:
|
:description:
|
||||||
The NFO file name.
|
The NFO file name.
|
||||||
|
|
||||||
|
|
||||||
``nfo_root``
|
``nfo_root``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
:expected type: EntryFormatter
|
||||||
|
|
@ -501,7 +479,6 @@ with a ``.nfo`` extension. You can add any values into the NFO.
|
||||||
<episodedetails>
|
<episodedetails>
|
||||||
</episodedetails>
|
</episodedetails>
|
||||||
|
|
||||||
|
|
||||||
``tags``
|
``tags``
|
||||||
|
|
||||||
:expected type: NfoTags
|
:expected type: NfoTags
|
||||||
|
|
@ -538,7 +515,6 @@ with a ``.nfo`` extension. You can add any values into the NFO.
|
||||||
<genre>Comedy</genre>
|
<genre>Comedy</genre>
|
||||||
<genre>Drama</genre>
|
<genre>Drama</genre>
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
output_directory_nfo_tags
|
output_directory_nfo_tags
|
||||||
|
|
@ -570,7 +546,6 @@ Usage:
|
||||||
this field can be set using an override variable to easily toggle whether this plugin
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``kodi_safe``
|
``kodi_safe``
|
||||||
|
|
||||||
:expected type: OverridesBooleanFormatterValidator
|
:expected type: OverridesBooleanFormatterValidator
|
||||||
|
|
@ -579,14 +554,12 @@ Usage:
|
||||||
emojis and some foreign language characters. Setting this to True will replace those
|
emojis and some foreign language characters. Setting this to True will replace those
|
||||||
characters with '□'.
|
characters with '□'.
|
||||||
|
|
||||||
|
|
||||||
``nfo_name``
|
``nfo_name``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
:expected type: EntryFormatter
|
||||||
:description:
|
:description:
|
||||||
The NFO file name.
|
The NFO file name.
|
||||||
|
|
||||||
|
|
||||||
``nfo_root``
|
``nfo_root``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
:expected type: EntryFormatter
|
||||||
|
|
@ -599,7 +572,6 @@ Usage:
|
||||||
<tvshow>
|
<tvshow>
|
||||||
</tvshow>
|
</tvshow>
|
||||||
|
|
||||||
|
|
||||||
``tags``
|
``tags``
|
||||||
|
|
||||||
:expected type: NfoTags
|
:expected type: NfoTags
|
||||||
|
|
@ -634,7 +606,6 @@ Usage:
|
||||||
<genre>Comedy</genre>
|
<genre>Comedy</genre>
|
||||||
<genre>Drama</genre>
|
<genre>Drama</genre>
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
output_options
|
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 file name to store a subscriptions download archive placed relative to
|
||||||
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
|
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
|
||||||
|
|
||||||
|
|
||||||
``file_name``
|
``file_name``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
: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
|
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.
|
``"Season {upload_year}/{title}.{ext}"``, and will be placed in the output directory.
|
||||||
|
|
||||||
|
|
||||||
``info_json_name``
|
``info_json_name``
|
||||||
|
|
||||||
:expected type: Optional[EntryFormatter]
|
: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
|
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.
|
directory. Can be set to empty string or `null` to disable info json writes.
|
||||||
|
|
||||||
|
|
||||||
``keep_files_after``
|
``keep_files_after``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
files after ``19000101``, which implies all files. Can be used in conjunction with
|
||||||
``keep_max_files``.
|
``keep_max_files``.
|
||||||
|
|
||||||
|
|
||||||
``keep_files_before``
|
``keep_files_before``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
files before ``now``, which implies all files. Can be used in conjunction with
|
||||||
``keep_max_files``.
|
``keep_max_files``.
|
||||||
|
|
||||||
|
|
||||||
``keep_files_date_eval``
|
``keep_files_date_eval``
|
||||||
|
|
||||||
:expected type: str
|
: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
|
perform evaluation for keep_files_before/after and keep_max_files. Defaults
|
||||||
to the entry's upload_date_standardized variable.
|
to the entry's upload_date_standardized variable.
|
||||||
|
|
||||||
|
|
||||||
``keep_max_files``
|
``keep_max_files``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
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``.
|
applied. Can be used in conjunction with ``keep_files_before`` and ``keep_files_after``.
|
||||||
|
|
||||||
|
|
||||||
``maintain_download_archive``
|
``maintain_download_archive``
|
||||||
|
|
||||||
:expected type: Optional[Boolean]
|
:expected type: Optional[Boolean]
|
||||||
|
|
@ -745,7 +709,6 @@ Defines where to output files and thumbnails after all post-processing has compl
|
||||||
|
|
||||||
Defaults to False.
|
Defaults to False.
|
||||||
|
|
||||||
|
|
||||||
``migrated_download_archive_name``
|
``migrated_download_archive_name``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
name first, and fallback to ``download_archive_name``. It will always save to this file
|
||||||
and remove the original ``download_archive_name``.
|
and remove the original ``download_archive_name``.
|
||||||
|
|
||||||
|
|
||||||
``output_directory``
|
``output_directory``
|
||||||
|
|
||||||
:expected type: OverridesFormatter
|
:expected type: OverridesFormatter
|
||||||
:description:
|
:description:
|
||||||
The output directory to store all media files downloaded.
|
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``
|
``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
|
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.
|
directory. Can be set to empty string or `null` to disable thumbnail writes.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
overrides
|
overrides
|
||||||
|
|
@ -836,7 +804,6 @@ used with no modifications.
|
||||||
If a file has no chapters and is set to "pass", then ``chapter_title`` is
|
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.
|
set to the entry's title and ``chapter_index``, ``chapter_count`` are both set to 1.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
square_thumbnail
|
square_thumbnail
|
||||||
|
|
@ -883,7 +850,6 @@ Usage:
|
||||||
this field can be set using an override variable to easily toggle whether this plugin
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``kodi_safe``
|
``kodi_safe``
|
||||||
|
|
||||||
:expected type: OverridesBooleanFormatterValidator
|
:expected type: OverridesBooleanFormatterValidator
|
||||||
|
|
@ -892,14 +858,12 @@ Usage:
|
||||||
emojis and some foreign language characters. Setting this to True will replace those
|
emojis and some foreign language characters. Setting this to True will replace those
|
||||||
characters with '□'.
|
characters with '□'.
|
||||||
|
|
||||||
|
|
||||||
``nfo_name``
|
``nfo_name``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
:expected type: EntryFormatter
|
||||||
:description:
|
:description:
|
||||||
The NFO file name.
|
The NFO file name.
|
||||||
|
|
||||||
|
|
||||||
``nfo_root``
|
``nfo_root``
|
||||||
|
|
||||||
:expected type: EntryFormatter
|
:expected type: EntryFormatter
|
||||||
|
|
@ -912,7 +876,6 @@ Usage:
|
||||||
<season>
|
<season>
|
||||||
</season>
|
</season>
|
||||||
|
|
||||||
|
|
||||||
``tags``
|
``tags``
|
||||||
|
|
||||||
:expected type: NfoTags
|
:expected type: NfoTags
|
||||||
|
|
@ -926,7 +889,6 @@ Usage:
|
||||||
<title>My custom season name!</title>
|
<title>My custom season name!</title>
|
||||||
</season>
|
</season>
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
subtitles
|
subtitles
|
||||||
|
|
@ -954,7 +916,6 @@ It will set the respective language to the correct subtitle file.
|
||||||
:description:
|
:description:
|
||||||
Defaults to False. Whether to allow auto generated subtitles.
|
Defaults to False. Whether to allow auto generated subtitles.
|
||||||
|
|
||||||
|
|
||||||
``embed_subtitles``
|
``embed_subtitles``
|
||||||
|
|
||||||
:expected type: Optional[Boolean]
|
: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
|
Defaults to False. Whether to embed the subtitles into the video file. Note that
|
||||||
webm files can only embed "vtt" subtitle types.
|
webm files can only embed "vtt" subtitle types.
|
||||||
|
|
||||||
|
|
||||||
``enable``
|
``enable``
|
||||||
|
|
||||||
:expected type: Optional[OverridesFormatter]
|
: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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``languages``
|
``languages``
|
||||||
|
|
||||||
:expected type: Optional[List[String]]
|
: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 code(s) to download for subtitles. Supports a single or list of multiple
|
||||||
language codes. Defaults to only "en".
|
language codes. Defaults to only "en".
|
||||||
|
|
||||||
|
|
||||||
``languages_required``
|
``languages_required``
|
||||||
|
|
||||||
:expected type: Optional[List[String]]
|
: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,
|
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.
|
ytdl-sub will throw an error. NOTE: currently this only checks file-based subtitles.
|
||||||
|
|
||||||
|
|
||||||
``subtitles_name``
|
``subtitles_name``
|
||||||
|
|
||||||
:expected type: Optional[EntryFormatter]
|
: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
|
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.
|
multiple subtitles. It will set the respective language to the correct subtitle file.
|
||||||
|
|
||||||
|
|
||||||
``subtitles_type``
|
``subtitles_type``
|
||||||
|
|
||||||
:expected type: Optional[String]
|
:expected type: Optional[String]
|
||||||
:description:
|
:description:
|
||||||
Defaults to "srt". One of the subtitle file types "srt", "vtt", "ass", "lrc".
|
Defaults to "srt". One of the subtitle file types "srt", "vtt", "ass", "lrc".
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
throttle_protection
|
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
|
this field can be set using an override variable to easily toggle whether this plugin
|
||||||
is enabled or not via Boolean.
|
is enabled or not via Boolean.
|
||||||
|
|
||||||
|
|
||||||
``max_downloads_per_subscription``
|
``max_downloads_per_subscription``
|
||||||
|
|
||||||
:expected type: Optional[Range]
|
:expected type: Optional[Range]
|
||||||
:description:
|
:description:
|
||||||
Number of downloads to perform per subscription.
|
Number of downloads to perform per subscription.
|
||||||
|
|
||||||
|
|
||||||
``sleep_per_download_s``
|
``sleep_per_download_s``
|
||||||
|
|
||||||
:expected type: Optional[Range]
|
: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
|
Number in seconds to sleep between each download. Does not include time it takes for
|
||||||
ytdl-sub to perform post-processing.
|
ytdl-sub to perform post-processing.
|
||||||
|
|
||||||
|
|
||||||
``sleep_per_request_s``
|
``sleep_per_request_s``
|
||||||
|
|
||||||
:expected type: Optional[Range]
|
: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,
|
download for the entry. Also, yt-dlp only supports a single value at this time for this,
|
||||||
so will always use the max value.
|
so will always use the max value.
|
||||||
|
|
||||||
|
|
||||||
``sleep_per_subscription_s``
|
``sleep_per_subscription_s``
|
||||||
|
|
||||||
:expected type: Optional[Range]
|
:expected type: Optional[Range]
|
||||||
:description:
|
:description:
|
||||||
Number in seconds to sleep between each subscription.
|
Number in seconds to sleep between each subscription.
|
||||||
|
|
||||||
|
|
||||||
``subscription_download_probability``
|
``subscription_download_probability``
|
||||||
|
|
||||||
:expected type: Optional[Float]
|
: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
|
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.
|
guaranteed over time to eventually download the subscription.
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
video_tags
|
video_tags
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,13 @@ pow
|
||||||
:description:
|
:description:
|
||||||
``**`` operator. Returns the exponential of the base and exponent value.
|
``**`` 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
|
sub
|
||||||
~~~
|
~~~
|
||||||
:spec: ``sub(values: Numeric, ...) -> Numeric``
|
:spec: ``sub(values: Numeric, ...) -> Numeric``
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ supply a cookies file path.
|
||||||
|
|
||||||
# Directly set plugin options:
|
# Directly set plugin options:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
cookiefile: "/config/cookie.txt"
|
cookiefile: "/config/ytdl-sub-configs/cookie.txt"
|
||||||
|
|
||||||
Layout
|
Layout
|
||||||
------
|
------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
Deprecation Notices
|
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
|
Sep 2024
|
||||||
--------
|
--------
|
||||||
|
|
|
||||||
|
|
@ -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
|
Docker and Unraid
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
Cron is preconfigured in every ytdl-sub docker container. Enable by adding the following
|
:doc:`The 'ytdl-sub' Docker container images <../install/docker>` provide optional cron
|
||||||
ENV variables to your docker setup.
|
support. Enable cron support by setting `a cron schedule`_ in the ``CRON_SCHEDULE``
|
||||||
|
environment variable:
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
:caption: ./compose.yaml
|
||||||
|
:emphasize-lines: 4
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ytdl-sub:
|
ytdl-sub:
|
||||||
environment:
|
environment:
|
||||||
- CRON_SCHEDULE="0 */6 * * *"
|
CRON_SCHEDULE: "0 */6 * * *"
|
||||||
- CRON_RUN_ON_START=false
|
# 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
|
The container cron wrapper script will write output from the cron job to
|
||||||
run the script once every 6 hours.
|
``/config/ytdl-sub-configs/.cron.log``. The default image ``ENTRYPOINT`` will ``$ tail
|
||||||
- ``CRON_RUN_ON_START`` toggles whether to run your cron script on container start in
|
...`` that file so you can monitor the cron job in the container's output and thus also
|
||||||
addition to the cron schedule.
|
in the Docker logs.
|
||||||
|
|
||||||
The cron script will reside in the main directory with the file name ``cron``. Cron
|
You may also set the ``CRON_RUN_ON_START`` environment variable to ``true`` to have the
|
||||||
logs should show when viewing the Docker logs.
|
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-setup:
|
||||||
|
|
||||||
Linux
|
Linux, Mac OS X, BSD, or other UNIX's
|
||||||
-----
|
-------------------------------------
|
||||||
Must configure crontab manually, like so:
|
|
||||||
|
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
|
.. code-block:: shell
|
||||||
|
:caption: ~/.local/bin/ytdl-sub-cron
|
||||||
|
:emphasize-lines: 2,3
|
||||||
|
|
||||||
crontab -e
|
#!/bin/bash
|
||||||
0 */6 * * * /config/run_cron
|
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:
|
.. _windows-setup:
|
||||||
|
|
@ -53,17 +82,51 @@ Must configure crontab manually, like so:
|
||||||
Windows
|
Windows
|
||||||
-------
|
-------
|
||||||
|
|
||||||
To be tested (please contact code owner or join the discord server if you can test this
|
For most Windows users, the best way to run commands periodically is `the Task
|
||||||
out for us)
|
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
|
Next Steps
|
||||||
----------
|
----------
|
||||||
|
|
||||||
Once you have a significant quantity of subscriptions or have use cases not served using
|
At this point, ``ytdl-sub`` should run periodically and keep your subscriptions current
|
||||||
:doc:`YAML keys and the special characters <./subscriptions>`, it's time to start
|
in your media library without your intervention. As your :doc:`subscriptions file
|
||||||
:doc:`defining your own custom presets <./first_config>`.
|
<./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
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,33 @@ Basic Configuration
|
||||||
|
|
||||||
A configuration file serves two purposes:
|
A configuration file serves two purposes:
|
||||||
|
|
||||||
1. Set advanced functionality that is not specifiable in a subscription file, such as
|
1. Set application-level functionality that is not specifiable in a subscription file.
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
.. code-block:: yaml
|
||||||
:linenos:
|
:linenos:
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
of all subscriptions in the file <config_reference/subscription_yaml:file preset>`. The
|
||||||
configuration file supports :ref:`a few special options
|
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
|
: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
|
complete details, but for almost all of the use cases served by ``ytdl-sub``, the above
|
||||||
is accurate and representative.
|
is accurate and representative.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ For example::
|
||||||
|
|
||||||
$ docker compose run --rm --user="${PUID}:${PGID}" --entrypoint="ytdl-sub" ytdl-sub sub
|
$ 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
|
Install with Docker Compose
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
2. Organize one or more YouTube channels/playlists, where each season represents a
|
||||||
separate channel/playlist.
|
separate channel/playlist.
|
||||||
|
|
||||||
|
Today, ytdl-supports up to 40 seasons with 11 URLs per season.
|
||||||
|
|
||||||
Example
|
Example
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
|
||||||
|
|
@ -185,6 +187,29 @@ Must define ``tv_show_directory``. Available presets:
|
||||||
s02_name: "Covers"
|
s02_name: "Covers"
|
||||||
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
|
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
|
Advanced Usage
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,7 @@ Using the command:
|
||||||
--overrides.tv_show_name "Rick A" \
|
--overrides.tv_show_name "Rick A" \
|
||||||
--overrides.url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
|
--overrides.url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
|
||||||
|
|
||||||
See how to shorten commands using `download aliases
|
See how to shorten commands using :ref:`download aliases <config_reference/config_yaml:dl_aliases>`.
|
||||||
<https://ytdl-sub.readthedocs.io/en/latest/config_reference/config_yaml.html#ytdl_sub.config.config_validator.ConfigOptions.dl_aliases>`_.
|
|
||||||
|
|
||||||
|
|
||||||
View Options
|
View Options
|
||||||
|
|
@ -105,3 +104,11 @@ Preview the source variables for a given URL. Helpful to create new subscription
|
||||||
|
|
||||||
-sc, --split-chapters
|
-sc, --split-chapters
|
||||||
View source variables after splitting by 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]
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ presets:
|
||||||
# ytdl_options lets you pass any arg into yt-dlp's Python API
|
# ytdl_options lets you pass any arg into yt-dlp's Python API
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
# Set the cookie file
|
# 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
|
# For YouTube, get English metadata if multiple languages are present
|
||||||
extractor_args:
|
extractor_args:
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,6 @@ TV Show Only Recent:
|
||||||
# to set only for that subscriptions
|
# to set only for that subscriptions
|
||||||
"~BBC News":
|
"~BBC News":
|
||||||
url: "https://www.youtube.com/@BBCNews" # use url2, url3, ... for multi-url in this form
|
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"
|
"Frontline PBS": "https://www.youtube.com/@frontline"
|
||||||
"Whitehouse": "https://www.bitchute.com/channel/zWsYVmCOu4JA/" # Supports non-YT sites
|
"Whitehouse": "https://www.bitchute.com/channel/zWsYVmCOu4JA/" # Supports non-YT sites
|
||||||
|
|
@ -15,7 +15,7 @@ classifiers = [
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yt-dlp[default]==2025.10.14",
|
"yt-dlp[default]==2026.3.3",
|
||||||
"colorama~=0.4",
|
"colorama~=0.4",
|
||||||
"mergedeep~=1.3",
|
"mergedeep~=1.3",
|
||||||
"mediafile~=0.12",
|
"mediafile~=0.12",
|
||||||
|
|
@ -48,12 +48,12 @@ test = [
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"black==24.10.0",
|
"black==24.10.0",
|
||||||
"isort==6.1.0",
|
"isort==7.0.0",
|
||||||
"pylint==3.3.8",
|
"pylint==4.0.1",
|
||||||
"ruff==0.14.0",
|
"ruff==0.14.0",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"sphinx>=7,<9",
|
"sphinx>=7,<10",
|
||||||
"sphinx-rtd-theme>=2,<4",
|
"sphinx-rtd-theme>=2,<4",
|
||||||
"sphinx-book-theme~=1.0",
|
"sphinx-book-theme~=1.0",
|
||||||
"sphinx-copybutton~=0.5",
|
"sphinx-copybutton~=0.5",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import gc
|
import gc
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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_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 _maybe_validate_transaction_log_file
|
||||||
from ytdl_sub.cli.output_transaction_log import output_transaction_log
|
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.dl import DownloadArgsParser
|
||||||
from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME
|
from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME
|
||||||
from ytdl_sub.cli.parsers.main import parser
|
from ytdl_sub.cli.parsers.main import parser
|
||||||
|
|
@ -74,6 +76,7 @@ def _download_subscriptions_from_yaml_files(
|
||||||
subscription_override_dict: Dict,
|
subscription_override_dict: Dict,
|
||||||
update_with_info_json: bool,
|
update_with_info_json: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
shuffle: bool,
|
||||||
) -> List[Subscription]:
|
) -> List[Subscription]:
|
||||||
"""
|
"""
|
||||||
Downloads all subscriptions from one or many subscription yaml files.
|
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
|
Whether to actually download or update using existing info json
|
||||||
dry_run
|
dry_run
|
||||||
Whether to dry run or not
|
Whether to dry run or not
|
||||||
|
shuffle
|
||||||
|
Whether to shuffle the subscription download order
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -111,6 +116,10 @@ def _download_subscriptions_from_yaml_files(
|
||||||
subscription_override_dict=subscription_override_dict,
|
subscription_override_dict=subscription_override_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if shuffle:
|
||||||
|
logger.info("Shuffling subscriptions")
|
||||||
|
random.shuffle(subscriptions)
|
||||||
|
|
||||||
for subscription in subscriptions:
|
for subscription in subscriptions:
|
||||||
with subscription.exception_handling():
|
with subscription.exception_handling():
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -206,6 +215,10 @@ def main() -> List[Subscription]:
|
||||||
|
|
||||||
args, extra_args = parser.parse_known_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
|
|
||||||
|
if args.subparser == "cli-to-sub":
|
||||||
|
print_cli_to_sub(args=extra_args)
|
||||||
|
return []
|
||||||
|
|
||||||
# Load the config
|
# Load the config
|
||||||
if args.config:
|
if args.config:
|
||||||
config = ConfigFile.from_file_path(args.config)
|
config = ConfigFile.from_file_path(args.config)
|
||||||
|
|
@ -248,6 +261,7 @@ def main() -> List[Subscription]:
|
||||||
subscription_override_dict=subscription_override_dict,
|
subscription_override_dict=subscription_override_dict,
|
||||||
update_with_info_json=args.update_with_info_json,
|
update_with_info_json=args.update_with_info_json,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
shuffle=args.shuffle,
|
||||||
)
|
)
|
||||||
|
|
||||||
# One-off download
|
# 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)
|
_view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters)
|
||||||
)
|
)
|
||||||
else:
|
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:
|
if not args.suppress_transaction_log:
|
||||||
output_transaction_log(
|
output_transaction_log(
|
||||||
|
|
|
||||||
64
src/ytdl_sub/cli/parsers/cli_to_sub.py
Normal file
64
src/ytdl_sub/cli/parsers/cli_to_sub.py
Normal 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}))
|
||||||
|
|
@ -172,6 +172,10 @@ class SubArguments:
|
||||||
short="-o",
|
short="-o",
|
||||||
long="--dl-override",
|
long="--dl-override",
|
||||||
)
|
)
|
||||||
|
SHUFFLE = CLIArgument(
|
||||||
|
short="-sh",
|
||||||
|
long="--shuffle",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
subscription_parser = subparsers.add_parser("sub")
|
subscription_parser = subparsers.add_parser("sub")
|
||||||
|
|
@ -197,6 +201,13 @@ subscription_parser.add_argument(
|
||||||
help="override all subscription config values using `dl` syntax, "
|
help="override all subscription config values using `dl` syntax, "
|
||||||
"i.e. --dl-override='--ytdl_options.max_downloads 3'",
|
"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
|
# DOWNLOAD PARSER
|
||||||
|
|
@ -221,3 +232,7 @@ view_parser.add_argument(
|
||||||
help="View source variables after splitting by chapters",
|
help="View source variables after splitting by chapters",
|
||||||
)
|
)
|
||||||
view_parser.add_argument("url", help="URL to view source variables for")
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 DEFAULT_LOCK_DIRECTORY
|
||||||
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
|
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
|
||||||
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
|
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 FFmpegFileValidator
|
||||||
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
|
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
|
|
@ -22,6 +24,10 @@ from ytdl_sub.validators.validators import StringValidator
|
||||||
|
|
||||||
|
|
||||||
class ExperimentalValidator(StrictDictValidator):
|
class ExperimentalValidator(StrictDictValidator):
|
||||||
|
"""
|
||||||
|
Experimental flags reside under the ``experimental`` key.
|
||||||
|
"""
|
||||||
|
|
||||||
_optional_keys = {"enable_update_with_info_json"}
|
_optional_keys = {"enable_update_with_info_json"}
|
||||||
_allow_extra_keys = True
|
_allow_extra_keys = True
|
||||||
|
|
||||||
|
|
@ -43,6 +49,11 @@ class ExperimentalValidator(StrictDictValidator):
|
||||||
|
|
||||||
|
|
||||||
class PersistLogsValidator(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"}
|
_required_keys = {"logs_directory"}
|
||||||
_optional_keys = {"keep_logs_after", "keep_successful_logs"}
|
_optional_keys = {"keep_logs_after", "keep_successful_logs"}
|
||||||
|
|
||||||
|
|
@ -67,8 +78,8 @@ class PersistLogsValidator(StrictDictValidator):
|
||||||
@property
|
@property
|
||||||
def logs_directory(self) -> str:
|
def logs_directory(self) -> str:
|
||||||
"""
|
"""
|
||||||
Write log files to this directory with names like
|
Required field. Write log files to this directory with names like
|
||||||
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. (required)
|
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``.
|
||||||
"""
|
"""
|
||||||
return self._logs_directory.value
|
return self._logs_directory.value
|
||||||
|
|
||||||
|
|
@ -93,15 +104,54 @@ class PersistLogsValidator(StrictDictValidator):
|
||||||
@property
|
@property
|
||||||
def keep_successful_logs(self) -> bool:
|
def keep_successful_logs(self) -> bool:
|
||||||
"""
|
"""
|
||||||
If the ``persist_logs:`` key is in the configuration, then ``ytdl-sub`` *always*
|
Defaults to ``True``. When this key is ``False``, only write log files for failed
|
||||||
writes log files for the subscription both for successful downloads and when it
|
subscriptions.
|
||||||
encounters an error while downloading. When this key is ``False``, only write
|
|
||||||
log files for errors. (default ``True``)
|
|
||||||
"""
|
"""
|
||||||
return self._keep_successful_logs.value
|
return self._keep_successful_logs.value
|
||||||
|
|
||||||
|
|
||||||
class ConfigOptions(StrictDictValidator):
|
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 = {
|
_optional_keys = {
|
||||||
"working_directory",
|
"working_directory",
|
||||||
"umask",
|
"umask",
|
||||||
|
|
@ -147,11 +197,18 @@ class ConfigOptions(StrictDictValidator):
|
||||||
key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
|
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
|
@property
|
||||||
def working_directory(self) -> str:
|
def working_directory(self) -> str:
|
||||||
"""
|
"""
|
||||||
The directory to temporarily store downloaded files before moving them into their final
|
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
|
# Expands tildas to actual paths, use native os sep
|
||||||
return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
|
return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
|
||||||
|
|
@ -159,7 +216,7 @@ class ConfigOptions(StrictDictValidator):
|
||||||
@property
|
@property
|
||||||
def umask(self) -> Optional[str]:
|
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
|
return self._umask.value
|
||||||
|
|
||||||
|
|
@ -168,7 +225,7 @@ class ConfigOptions(StrictDictValidator):
|
||||||
"""
|
"""
|
||||||
.. _dl_aliases:
|
.. _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
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
|
@ -220,23 +277,23 @@ class ConfigOptions(StrictDictValidator):
|
||||||
The directory to temporarily store file locks, which prevents multiple instances
|
The directory to temporarily store file locks, which prevents multiple instances
|
||||||
of ``ytdl-sub`` from running. Note that file locks do not work on
|
of ``ytdl-sub`` from running. Note that file locks do not work on
|
||||||
network-mounted directories. Ensure that this directory resides on the host
|
network-mounted directories. Ensure that this directory resides on the host
|
||||||
machine. (default ``/tmp``)
|
machine. Defaults to ``/tmp``.
|
||||||
"""
|
"""
|
||||||
return self._lock_directory.value
|
return self._lock_directory.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ffmpeg_path(self) -> str:
|
def ffmpeg_path(self) -> str:
|
||||||
"""
|
"""
|
||||||
Path to ffmpeg executable. (default ``/usr/bin/ffmpeg`` for Linux,
|
Path to ffmpeg executable. Defaults to ``/usr/bin/ffmpeg`` for Linux,
|
||||||
``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows)
|
``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows.
|
||||||
"""
|
"""
|
||||||
return self._ffmpeg_path.value
|
return self._ffmpeg_path.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ffprobe_path(self) -> str:
|
def ffprobe_path(self) -> str:
|
||||||
"""
|
"""
|
||||||
Path to ffprobe executable. (default ``/usr/bin/ffprobe`` for Linux,
|
Path to ffprobe executable. Defaults to ``/usr/bin/ffprobe`` for Linux,
|
||||||
``./ffprobe.exe`` in the same directory as ytdl-sub for Windows)
|
``./ffprobe.exe`` in the same directory as ytdl-sub for Windows.
|
||||||
"""
|
"""
|
||||||
return self._ffprobe_path.value
|
return self._ffprobe_path.value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import os
|
||||||
|
|
||||||
from ytdl_sub.utils.system import IS_WINDOWS
|
from ytdl_sub.utils.system import IS_WINDOWS
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
def _existing_path(*paths: str) -> str:
|
def _existing_path(*paths: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from typing import Iterable
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Set
|
from typing import Set
|
||||||
|
from typing import Type
|
||||||
import mergedeep
|
from typing import TypeVar
|
||||||
|
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.entries.script.variable_definitions import VARIABLES
|
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.entries.variables.override_variables import OverrideHelpers
|
||||||
from ytdl_sub.script.parser import parse
|
from ytdl_sub.script.parser import parse
|
||||||
from ytdl_sub.script.script import Script
|
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 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.script.utils.exceptions import ScriptVariableNotResolved
|
||||||
from ytdl_sub.utils.exceptions import InvalidVariableNameException
|
from ytdl_sub.utils.exceptions import InvalidVariableNameException
|
||||||
from ytdl_sub.utils.exceptions import StringFormattingException
|
from ytdl_sub.utils.exceptions import StringFormattingException
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
from ytdl_sub.utils.script import ScriptUtils
|
from ytdl_sub.utils.script import ScriptUtils
|
||||||
from ytdl_sub.utils.scriptable import Scriptable
|
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 StringFormatterValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import UnstructuredDictFormatterValidator
|
||||||
|
|
||||||
|
ExpectedT = TypeVar("ExpectedT")
|
||||||
|
|
||||||
|
|
||||||
class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
||||||
"""
|
"""
|
||||||
|
|
@ -88,6 +93,24 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
||||||
|
|
||||||
return True
|
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:
|
def ensure_variable_name_valid(self, name: str) -> None:
|
||||||
"""
|
"""
|
||||||
Ensures the variable name does not collide with any entry variables or built-in functions.
|
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(
|
def initial_variables(
|
||||||
self, unresolved_variables: Optional[Dict[str, str]] = None
|
self, unresolved_variables: Optional[Dict[str, SyntaxTree]] = None
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, SyntaxTree]:
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
Variables and format strings for all Override variables + additional variables (Optional)
|
Variables and format strings for all Override variables + additional variables (Optional)
|
||||||
"""
|
"""
|
||||||
initial_variables: Dict[str, str] = {}
|
initial_variables: Dict[str, SyntaxTree] = self.dict_with_parsed_format_strings
|
||||||
mergedeep.merge(
|
if unresolved_variables:
|
||||||
initial_variables,
|
initial_variables |= unresolved_variables
|
||||||
self.dict_with_format_strings,
|
return ScriptUtils.add_sanitized_parsed_variables(initial_variables)
|
||||||
unresolved_variables if unresolved_variables else {},
|
|
||||||
)
|
|
||||||
return ScriptUtils.add_sanitized_variables(initial_variables)
|
|
||||||
|
|
||||||
def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides":
|
def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides":
|
||||||
"""
|
"""
|
||||||
Initialize the override script with any unresolved variables
|
Initialize the override script with any unresolved variables
|
||||||
"""
|
"""
|
||||||
self.script.add(
|
self.script.add_parsed(
|
||||||
self.initial_variables(
|
self.initial_variables(
|
||||||
unresolved_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
|
for var_name in unresolved_variables
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -158,10 +187,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
||||||
script = entry.script
|
script = entry.script
|
||||||
unresolvable = entry.unresolvable
|
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:
|
try:
|
||||||
return script.resolve_once(
|
return script.resolve_once(
|
||||||
dict({"tmp_var": formatter.format_string}, **(function_overrides or {})),
|
dict({"tmp_var": formatter.format_string}, **(function_overrides or {})),
|
||||||
unresolvable=unresolvable,
|
unresolvable=unresolvable,
|
||||||
|
update=update,
|
||||||
)["tmp_var"]
|
)["tmp_var"]
|
||||||
except ScriptVariableNotResolved as exc:
|
except ScriptVariableNotResolved as exc:
|
||||||
raise StringFormattingException(
|
raise StringFormattingException(
|
||||||
|
|
@ -176,7 +210,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
||||||
formatter: StringFormatterValidator,
|
formatter: StringFormatterValidator,
|
||||||
entry: Optional[Entry] = None,
|
entry: Optional[Entry] = None,
|
||||||
function_overrides: Optional[Dict[str, str]] = None,
|
function_overrides: Optional[Dict[str, str]] = None,
|
||||||
) -> str:
|
expected_type: Type[ExpectedT] = str,
|
||||||
|
) -> ExpectedT:
|
||||||
"""
|
"""
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
|
|
@ -186,6 +221,8 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
||||||
Optional. Entry to add source variables to the formatter
|
Optional. Entry to add source variables to the formatter
|
||||||
function_overrides
|
function_overrides
|
||||||
Optional. Explicit values to override the overrides themselves and source variables
|
Optional. Explicit values to override the overrides themselves and source variables
|
||||||
|
expected_type
|
||||||
|
The expected type that should return. Defaults to string.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -196,37 +233,15 @@ class Overrides(UnstructuredDictFormatterValidator, Scriptable):
|
||||||
StringFormattingException
|
StringFormattingException
|
||||||
If the formatter that is trying to be resolved cannot
|
If the formatter that is trying to be resolved cannot
|
||||||
"""
|
"""
|
||||||
return formatter.post_process(
|
out = formatter.post_process(
|
||||||
str(
|
self._apply_to_resolvable(
|
||||||
self._apply_to_resolvable(
|
formatter=formatter, entry=entry, function_overrides=function_overrides
|
||||||
formatter=formatter, entry=entry, function_overrides=function_overrides
|
).native
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply_overrides_formatter_to_native(
|
if not isinstance(out, expected_type):
|
||||||
self,
|
raise StringFormattingException(
|
||||||
formatter: OverridesStringFormatterValidator,
|
f"Expected type {expected_type.__name__}, but received '{out.__class__.__name__}'"
|
||||||
) -> Any:
|
)
|
||||||
"""
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
formatter
|
|
||||||
Overrides formatter to apply
|
|
||||||
|
|
||||||
Returns
|
return out
|
||||||
-------
|
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
|
||||||
Returns True if enabled, False if disabled.
|
Returns True if enabled, False if disabled.
|
||||||
"""
|
"""
|
||||||
if isinstance(self.plugin_options, ToggleableOptionsDictValidator):
|
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
|
return True
|
||||||
|
|
||||||
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
|
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
from typing import Iterable
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from typing import Set
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
|
|
@ -44,3 +46,34 @@ class PresetPlugins:
|
||||||
if plugin_type in plugin_option_types:
|
if plugin_type in plugin_option_types:
|
||||||
return self.plugin_options[plugin_option_types.index(plugin_type)]
|
return self.plugin_options[plugin_option_types.index(plugin_type)]
|
||||||
return None
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import copy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
from mergedeep import mergedeep
|
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.plugin.preset_plugins import PresetPlugins
|
||||||
from ytdl_sub.config.preset_options import OutputOptions
|
from ytdl_sub.config.preset_options import OutputOptions
|
||||||
from ytdl_sub.config.preset_options import YTDLOptions
|
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.downloaders.url.validators import MultiUrlValidator
|
||||||
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
|
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
|
||||||
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
|
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
|
||||||
|
|
@ -55,6 +55,12 @@ class _PresetShell(StrictDictValidator):
|
||||||
|
|
||||||
|
|
||||||
class Preset(_PresetShell):
|
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
|
@classmethod
|
||||||
def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None:
|
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)
|
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):
|
def __init__(self, config: ConfigValidator, name: str, value: Any):
|
||||||
super().__init__(name=name, value=value)
|
super().__init__(name=name, value=value)
|
||||||
|
|
||||||
|
|
@ -192,13 +229,10 @@ class Preset(_PresetShell):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.plugins: PresetPlugins = self._validate_and_get_plugins()
|
self.plugins: PresetPlugins = self._validate_and_get_plugins()
|
||||||
self.overrides = self._validate_key(key="overrides", validator=Overrides, default={})
|
self.overrides = self._initialize_overrides_script(
|
||||||
|
overrides=self._validate_key(key="overrides", validator=Overrides, default={})
|
||||||
VariableValidation(
|
)
|
||||||
downloader_options=self.downloader_options,
|
self.overrides.ensure_variable_names_not_a_plugin(plugin_names=PRESET_KEYS)
|
||||||
output_options=self.output_options,
|
|
||||||
plugins=self.plugins,
|
|
||||||
).initialize_preset_overrides(overrides=self.overrides).ensure_proper_usage()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|
@ -227,11 +261,18 @@ class Preset(_PresetShell):
|
||||||
"""
|
"""
|
||||||
return cls(config=config, name=preset_name, value=preset_dict)
|
return cls(config=config, name=preset_name, value=preset_dict)
|
||||||
|
|
||||||
@property
|
def yaml(self, subscription_only: bool) -> str:
|
||||||
def yaml(self) -> str:
|
|
||||||
"""
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
subscription_only:
|
||||||
|
Only include the subscription contents, not the surrounding boiler-plate.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
Preset in YAML format
|
Preset in YAML format
|
||||||
"""
|
"""
|
||||||
|
if subscription_only:
|
||||||
|
return dump_yaml(self._value)
|
||||||
|
|
||||||
return dump_yaml({"presets": {self._name: self._value}})
|
return dump_yaml({"presets": {self._name: self._value}})
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ from ytdl_sub.config.overrides import Overrides
|
||||||
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
|
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
|
||||||
from ytdl_sub.config.validators.options import OptionsDictValidator
|
from ytdl_sub.config.validators.options import OptionsDictValidator
|
||||||
from ytdl_sub.entries.script.variable_definitions import VARIABLES as v
|
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 OverridesStringFormatterFilePathValidator
|
||||||
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
|
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
|
||||||
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
|
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
|
||||||
|
|
@ -57,12 +60,24 @@ class YTDLOptions(UnstructuredOverridesDictFormatterValidator):
|
||||||
def to_native_dict(self, overrides: Overrides) -> Dict:
|
def to_native_dict(self, overrides: Overrides) -> Dict:
|
||||||
"""
|
"""
|
||||||
Materializes the entire ytdl-options dict from OverrideStringFormatters into
|
Materializes the entire ytdl-options dict from OverrideStringFormatters into
|
||||||
native python
|
native python.
|
||||||
"""
|
"""
|
||||||
return {
|
out = {
|
||||||
key: overrides.apply_overrides_formatter_to_native(val)
|
key: overrides.apply_formatter(val, expected_type=object)
|
||||||
for key, val in self.dict.items()
|
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
|
# Disable for proper docstring formatting
|
||||||
|
|
@ -107,6 +122,7 @@ class OutputOptions(OptionsDictValidator):
|
||||||
"keep_max_files",
|
"keep_max_files",
|
||||||
"download_archive_standardized_date",
|
"download_archive_standardized_date",
|
||||||
"keep_files_date_eval",
|
"keep_files_date_eval",
|
||||||
|
"preserve_mtime",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -170,6 +186,10 @@ class OutputOptions(OptionsDictValidator):
|
||||||
default=f"{{{v.upload_date_standardized.variable_name}}}",
|
default=f"{{{v.upload_date_standardized.variable_name}}}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._preserve_mtime = self._validate_key_if_present(
|
||||||
|
key="preserve_mtime", validator=BoolValidator, default=False
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self._keep_files_before or self._keep_files_after or self._keep_max_files
|
self._keep_files_before or self._keep_files_after or self._keep_max_files
|
||||||
) and not self.maintain_download_archive:
|
) and not self.maintain_download_archive:
|
||||||
|
|
@ -309,6 +329,17 @@ class OutputOptions(OptionsDictValidator):
|
||||||
"""
|
"""
|
||||||
return self._keep_max_files
|
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]]:
|
def added_variables(self, unresolved_variables: Set[str]) -> Dict[PluginOperation, Set[str]]:
|
||||||
return {
|
return {
|
||||||
# PluginOperation.MODIFY_ENTRY_METADATA: {
|
# PluginOperation.MODIFY_ENTRY_METADATA: {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import copy
|
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Iterable
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
|
||||||
from typing import Set
|
from typing import Set
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from ytdl_sub.config.overrides import Overrides
|
from ytdl_sub.config.overrides import Overrides
|
||||||
from ytdl_sub.config.plugin.plugin_mapping import PluginMapping
|
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.preset_options import OutputOptions
|
||||||
from ytdl_sub.config.validators.options import OptionsValidator
|
from ytdl_sub.config.validators.options import OptionsValidator
|
||||||
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
|
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 Script
|
||||||
from ytdl_sub.script.script import _is_function
|
from ytdl_sub.script.utils.name_validation import is_function
|
||||||
from ytdl_sub.utils.scriptable import BASE_SCRIPT
|
from ytdl_sub.utils.script import ScriptUtils
|
||||||
from ytdl_sub.validators.string_formatter_validators import to_variable_dependency_format_string
|
|
||||||
from ytdl_sub.validators.string_formatter_validators import validate_formatters
|
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]:
|
@classmethod
|
||||||
dummy_variables: Dict[str, str] = {}
|
def name_of(cls, resolution_level: int) -> str:
|
||||||
for var in variables:
|
"""
|
||||||
dummy_variables[var] = ""
|
Name of the resolution level.
|
||||||
dummy_variables[f"{var}_sanitized"] = ""
|
"""
|
||||||
|
if resolution_level == cls.ORIGINAL:
|
||||||
|
return "original"
|
||||||
|
if resolution_level == cls.FILL:
|
||||||
|
return "fill"
|
||||||
|
if resolution_level == cls.RESOLVE:
|
||||||
|
return "resolve"
|
||||||
|
if resolution_level == cls.INTERNAL:
|
||||||
|
return "internal"
|
||||||
|
raise ValueError("Invalid resolution level")
|
||||||
|
|
||||||
return dummy_variables
|
@classmethod
|
||||||
|
def all(cls) -> List[int]:
|
||||||
|
"""
|
||||||
def _add_dummy_overrides(overrides: Overrides) -> Dict[str, str]:
|
All possible resolution levels.
|
||||||
# Have the dummy override variable contain all variable deps that it uses in the string
|
"""
|
||||||
dummy_overrides: Dict[str, str] = {}
|
return [cls.ORIGINAL, cls.FILL, cls.RESOLVE, cls.INTERNAL]
|
||||||
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()))
|
|
||||||
|
|
||||||
|
|
||||||
class VariableValidation:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
overrides: Overrides,
|
||||||
downloader_options: MultiUrlValidator,
|
downloader_options: MultiUrlValidator,
|
||||||
output_options: OutputOptions,
|
output_options: OutputOptions,
|
||||||
plugins: PresetPlugins,
|
plugins: PresetPlugins,
|
||||||
|
resolution_level: int = ResolutionLevel.RESOLVE,
|
||||||
):
|
):
|
||||||
|
self.overrides = overrides
|
||||||
self.downloader_options = downloader_options
|
self.downloader_options = downloader_options
|
||||||
self.output_options = output_options
|
self.output_options = output_options
|
||||||
self.plugins = plugins
|
self.plugins = plugins
|
||||||
|
|
||||||
self.script: Optional[Script] = None
|
self.script: Script = self.overrides.script
|
||||||
self.resolved_variables: Set[str] = set()
|
self.unresolved_variables = (
|
||||||
self.unresolved_variables: Set[str] = set()
|
self.plugins.get_all_variables(
|
||||||
|
additional_options=[self.output_options, self.downloader_options]
|
||||||
def initialize_preset_overrides(self, overrides: Overrides) -> "VariableValidation":
|
)
|
||||||
"""
|
| UNRESOLVED_VARIABLES
|
||||||
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.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:
|
def _add_runtime_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> 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:
|
|
||||||
"""
|
"""
|
||||||
Add dummy variables for script validation
|
Add dummy variables for script validation
|
||||||
"""
|
"""
|
||||||
added_variables = options.added_variables(
|
added_variables = options.added_variables(
|
||||||
unresolved_variables=self.unresolved_variables,
|
unresolved_variables=self.unresolved_runtime_variables,
|
||||||
).get(plugin_op, set())
|
).get(plugin_op, set())
|
||||||
modified_variables = options.modified_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
|
def ensure_proper_usage(self, partial_resolve_formatters: bool = False) -> Dict:
|
||||||
self.unresolved_variables -= resolved_variables
|
|
||||||
|
|
||||||
def ensure_proper_usage(self) -> None:
|
|
||||||
"""
|
"""
|
||||||
Validate variables resolve as plugins are executed, and return
|
Validate variables resolve as plugins are executed, and return
|
||||||
a mock script which contains actualized added variables from the plugins
|
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_runtime_variables(PluginOperation.DOWNLOADER, options=self.downloader_options)
|
||||||
self._add_subscription_override_variables()
|
|
||||||
|
|
||||||
# Always add output options first
|
# 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
|
# Metadata variables to be added
|
||||||
for plugin_options in PluginMapping.order_options_by(
|
for plugin_options in PluginMapping.order_options_by(
|
||||||
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY_METADATA
|
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(
|
for plugin_options in PluginMapping.order_options_by(
|
||||||
self.plugins.zipped(), PluginOperation.MODIFY_ENTRY
|
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 that any formatter in the plugin options can resolve
|
||||||
validate_formatters(
|
resolved_subscription |= validate_formatters(
|
||||||
script=self.script,
|
script=self.script,
|
||||||
unresolved_variables=self.unresolved_variables,
|
unresolved_variables=self.unresolved_variables,
|
||||||
|
unresolved_runtime_variables=self.unresolved_runtime_variables,
|
||||||
validator=plugin_options,
|
validator=plugin_options,
|
||||||
|
partial_resolve_formatters=partial_resolve_formatters,
|
||||||
)
|
)
|
||||||
|
|
||||||
validate_formatters(
|
resolved_subscription |= validate_formatters(
|
||||||
script=self.script,
|
script=self.script,
|
||||||
unresolved_variables=self.unresolved_variables,
|
unresolved_variables=self.unresolved_variables,
|
||||||
|
unresolved_runtime_variables=self.unresolved_runtime_variables,
|
||||||
validator=self.output_options,
|
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
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,12 @@ class UrlDownloaderBasePluginExtension(SourcePluginExtension[MultiUrlValidator])
|
||||||
|
|
||||||
if 0 <= input_url_idx < len(self.plugin_options.urls.list):
|
if 0 <= input_url_idx < len(self.plugin_options.urls.list):
|
||||||
validator = self.plugin_options.urls.list[input_url_idx]
|
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
|
return validator
|
||||||
|
|
||||||
# Match the first validator based on the URL, if one exists
|
# Match the first validator based on the URL, if one exists
|
||||||
for validator in self.plugin_options.urls.list:
|
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 validator
|
||||||
|
|
||||||
# Return the first validator if none exist
|
# Return the first validator if none exist
|
||||||
|
|
@ -382,7 +382,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
|
||||||
entries_to_iter: List[Optional[Entry]] = entries
|
entries_to_iter: List[Optional[Entry]] = entries
|
||||||
|
|
||||||
indices = list(range(len(entries_to_iter)))
|
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)
|
indices = reversed(indices)
|
||||||
|
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
|
|
@ -461,8 +461,8 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
|
||||||
ytdl_option_overrides=validator.ytdl_options.to_native_dict(self.overrides)
|
ytdl_option_overrides=validator.ytdl_options.to_native_dict(self.overrides)
|
||||||
)
|
)
|
||||||
|
|
||||||
include_sibling_metadata = self.overrides.evaluate_boolean(
|
include_sibling_metadata = self.overrides.apply_formatter(
|
||||||
validator.include_sibling_metadata
|
validator.include_sibling_metadata, expected_type=bool
|
||||||
)
|
)
|
||||||
|
|
||||||
parents, orphan_entries = self._download_url_metadata(
|
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
|
# download the bottom-most urls first since they are top-priority
|
||||||
for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))):
|
for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))):
|
||||||
# URLs can be empty. If they are, then skip
|
# 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
|
continue
|
||||||
|
|
||||||
for entry in self._download_metadata(url=url, validator=url_validator):
|
for url in reversed(urls):
|
||||||
entry.initialize_script(self.overrides).add(
|
assert isinstance(url, str)
|
||||||
{
|
|
||||||
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
|
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]:
|
def download(self, entry: Entry) -> Optional[Entry]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Set
|
from typing import Set
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ class UrlThumbnailValidator(StrictDictValidator):
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(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)
|
self._uid = self._validate_key(key="uid", validator=OverridesStringFormatterValidator)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -29,7 +30,7 @@ class UrlThumbnailValidator(StrictDictValidator):
|
||||||
"""
|
"""
|
||||||
File name for the thumbnail
|
File name for the thumbnail
|
||||||
"""
|
"""
|
||||||
return self._name
|
return self._thumb_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uid(self) -> OverridesStringFormatterValidator:
|
def uid(self) -> OverridesStringFormatterValidator:
|
||||||
|
|
@ -43,6 +44,19 @@ class UrlThumbnailListValidator(ListValidator[UrlThumbnailValidator]):
|
||||||
_inner_list_type = 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):
|
class UrlValidator(StrictDictValidator):
|
||||||
_required_keys = {"url"}
|
_required_keys = {"url"}
|
||||||
_optional_keys = {
|
_optional_keys = {
|
||||||
|
|
@ -68,7 +82,7 @@ class UrlValidator(StrictDictValidator):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
# TODO: url validate using yt-dlp IE
|
# 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(
|
self._variables = self._validate_key_if_present(
|
||||||
key="variables", validator=DictFormatterValidator, default={}
|
key="variables", validator=DictFormatterValidator, default={}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
@cache
|
||||||
def injected_variables(self) -> Set[MetadataVariable]:
|
def injected_variables(self) -> Set[MetadataVariable]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ VariableT = TypeVar("VariableT", bound="Variable")
|
||||||
|
|
||||||
|
|
||||||
def _get(
|
def _get(
|
||||||
cast: str,
|
|
||||||
metadata_variable_name: str,
|
metadata_variable_name: str,
|
||||||
metadata_key: str,
|
metadata_key: str,
|
||||||
variable_name: Optional[str],
|
variable_name: Optional[str],
|
||||||
|
|
@ -47,7 +46,7 @@ def _get(
|
||||||
return as_type(
|
return as_type(
|
||||||
variable_name=variable_name or metadata_key,
|
variable_name=variable_name or metadata_key,
|
||||||
metadata_key=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
|
Creates a map variable from entry metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"map",
|
|
||||||
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
@ -204,7 +202,6 @@ class ArrayMetadataVariable(MetadataVariable, ArrayVariable):
|
||||||
Creates an array variable from entry metadata
|
Creates an array variable from entry metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"array",
|
|
||||||
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
@ -226,7 +223,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
|
||||||
Creates a string variable from entry metadata
|
Creates a string variable from entry metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"string",
|
|
||||||
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
@ -245,7 +241,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
|
||||||
Creates a string variable from playlist metadata
|
Creates a string variable from playlist metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"string",
|
|
||||||
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
|
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
@ -264,7 +259,6 @@ class StringMetadataVariable(MetadataVariable, StringVariable):
|
||||||
Creates a string variable from source metadata
|
Creates a string variable from source metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"string",
|
|
||||||
metadata_variable_name=SOURCE_METADATA_VARIABLE_NAME,
|
metadata_variable_name=SOURCE_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
@ -301,7 +295,6 @@ class IntegerMetadataVariable(MetadataVariable, IntegerVariable):
|
||||||
Creates an int variable from entry metadata
|
Creates an int variable from entry metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"int",
|
|
||||||
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
metadata_variable_name=ENTRY_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
@ -320,7 +313,6 @@ class IntegerMetadataVariable(MetadataVariable, IntegerVariable):
|
||||||
Creates an int variable from playlist metadata
|
Creates an int variable from playlist metadata
|
||||||
"""
|
"""
|
||||||
return _get(
|
return _get(
|
||||||
"int",
|
|
||||||
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
|
metadata_variable_name=PLAYLIST_METADATA_VARIABLE_NAME,
|
||||||
metadata_key=metadata_key,
|
metadata_key=metadata_key,
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ from ytdl_sub.entries.script.variable_types import Variable
|
||||||
from ytdl_sub.script.functions import Functions
|
from ytdl_sub.script.functions import Functions
|
||||||
from ytdl_sub.script.utils.name_validation import is_valid_name
|
from ytdl_sub.script.utils.name_validation import is_valid_name
|
||||||
|
|
||||||
# TODO: use this
|
|
||||||
SUBSCRIPTION_ARRAY = "subscription_array"
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionVariables:
|
class SubscriptionVariables:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -163,7 +160,7 @@ class OverrideHelpers:
|
||||||
True if the override name itself is valid. False otherwise.
|
True if the override name itself is valid. False otherwise.
|
||||||
"""
|
"""
|
||||||
if name.startswith("%"):
|
if name.startswith("%"):
|
||||||
return is_valid_name(name=name[1:])
|
name = name[1:]
|
||||||
|
|
||||||
return is_valid_name(name=name)
|
return is_valid_name(name=name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ class DateRangePlugin(Plugin[DateRangeOptions]):
|
||||||
date_validator=self.plugin_options.after, overrides=self.overrides
|
date_validator=self.plugin_options.after, overrides=self.overrides
|
||||||
)
|
)
|
||||||
after_filter = f"{date_type} >= {after_str}"
|
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)
|
breaking_match_filters.append(after_filter)
|
||||||
else:
|
else:
|
||||||
match_filters.append(after_filter)
|
match_filters.append(after_filter)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _embed_thumbnail(self) -> bool:
|
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
|
@classmethod
|
||||||
def _embed_video_thumbnail(cls, entry: Entry) -> None:
|
def _embed_video_thumbnail(cls, entry: Entry) -> None:
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ from ytdl_sub.config.validators.options import OptionsValidator
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.exceptions import StringFormattingException
|
from ytdl_sub.utils.exceptions import StringFormattingException
|
||||||
from ytdl_sub.utils.logger import Logger
|
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
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
logger = Logger.get("filter-exclude")
|
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.
|
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.
|
If any filter evaluates to True, the entry will be excluded.
|
||||||
|
|
@ -29,6 +30,8 @@ class FilterExcludeOptions(ListFormatterValidator, OptionsValidator):
|
||||||
{ %contains( %lower(description), '#short' ) }
|
{ %contains( %lower(description), '#short' ) }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_inner_list_type = BooleanFormatterValidator
|
||||||
|
|
||||||
|
|
||||||
class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
|
class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
|
||||||
plugin_options_type = FilterExcludeOptions
|
plugin_options_type = FilterExcludeOptions
|
||||||
|
|
@ -52,7 +55,9 @@ class FilterExcludePlugin(Plugin[FilterExcludeOptions]):
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
for formatter in self.plugin_options.list:
|
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:
|
if should_exclude:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ from ytdl_sub.config.validators.options import OptionsValidator
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.exceptions import StringFormattingException
|
from ytdl_sub.utils.exceptions import StringFormattingException
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.utils.script import ScriptUtils
|
from ytdl_sub.validators.string_formatter_validators import BooleanFormatterValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
|
from ytdl_sub.validators.validators import ListValidator
|
||||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
logger = Logger.get("filter-include")
|
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.
|
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.
|
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]):
|
class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
|
||||||
plugin_options_type = FilterIncludeOptions
|
plugin_options_type = FilterIncludeOptions
|
||||||
|
|
@ -61,8 +63,8 @@ class FilterIncludePlugin(Plugin[FilterIncludeOptions]):
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
for formatter in self.plugin_options.list:
|
for formatter in self.plugin_options.list:
|
||||||
should_exclude = ScriptUtils.bool_formatter_output(
|
should_exclude = self.overrides.apply_formatter(
|
||||||
self.overrides.apply_formatter(formatter=formatter, entry=entry)
|
formatter=formatter, entry=entry, expected_type=bool
|
||||||
)
|
)
|
||||||
if not should_exclude:
|
if not should_exclude:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ class SharedNfoTagsPlugin(Plugin[SharedNfoTagsOptions], ABC):
|
||||||
if not nfo_tags:
|
if not nfo_tags:
|
||||||
return
|
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_root = to_max_3_byte_utf8_string(nfo_root)
|
||||||
nfo_tags = {
|
nfo_tags = {
|
||||||
to_max_3_byte_utf8_string(key): [
|
to_max_3_byte_utf8_string(key): [
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class SquareThumbnailPlugin(Plugin[SquareThumbnailOptions]):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _square_thumbnail(self) -> bool:
|
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
|
@classmethod
|
||||||
def _convert_to_square_thumbnail(cls, entry: Entry) -> None:
|
def _convert_to_square_thumbnail(cls, entry: Entry) -> None:
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ class _RandomizedRangeValidator(StrictDictValidator, ABC):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _randomized_float(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
|
def _randomized_float(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
|
||||||
actualized_min = float(overrides.apply_formatter(self._min, entry=entry))
|
actualized_min = overrides.apply_formatter(self._min, entry=entry, expected_type=float)
|
||||||
actualized_max = float(overrides.apply_formatter(self._max, entry=entry))
|
actualized_max = overrides.apply_formatter(self._max, entry=entry, expected_type=float)
|
||||||
|
|
||||||
if actualized_min < 0:
|
if actualized_min < 0:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
|
|
@ -70,7 +70,7 @@ class _RandomizedRangeValidator(StrictDictValidator, ABC):
|
||||||
-------
|
-------
|
||||||
Max possible value
|
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:
|
if actualized_max < 0:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
f"max must be greater than zero, received {actualized_max}"
|
f"max must be greater than zero, received {actualized_max}"
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class VideoTagsPlugin(Plugin[VideoTagsOptions]):
|
||||||
Tags the entry's audio file using values defined in the metadata options
|
Tags the entry's audio file using values defined in the metadata options
|
||||||
"""
|
"""
|
||||||
tags_to_write: Dict[str, str] = {}
|
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)
|
tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)
|
||||||
tags_to_write[tag_name] = tag_value
|
tags_to_write[tag_name] = tag_value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ presets:
|
||||||
|
|
||||||
# Set the default date_range to 2 months
|
# Set the default date_range to 2 months
|
||||||
overrides:
|
overrides:
|
||||||
date_range: "2months" # keep for legacy-reasons
|
only_recent_date_range: "2months"
|
||||||
only_recent_date_range: "{date_range}"
|
|
||||||
|
|
||||||
#############################################################################
|
#############################################################################
|
||||||
# Only Recent
|
# Only Recent
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -108,103 +108,4 @@ presets:
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription_map: "{ {} }"
|
subscription_map: "{ {} }"
|
||||||
url: "{ %get_url_i(1) }"
|
urls: "{ %array_apply(%range(100, 1), %get_url_i) }"
|
||||||
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) }"
|
|
||||||
|
|
@ -8,7 +8,7 @@ presets:
|
||||||
urls:
|
urls:
|
||||||
# The first URL will be all the artist's tracks.
|
# The first URL will be all the artist's tracks.
|
||||||
# Treat these as singles - an album with a single track
|
# Treat these as singles - an album with a single track
|
||||||
- url: "{url}/tracks"
|
- url: "{url}"
|
||||||
include_sibling_metadata: False
|
include_sibling_metadata: False
|
||||||
variables:
|
variables:
|
||||||
sc_track_album: "{title}"
|
sc_track_album: "{title}"
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ presets:
|
||||||
- "_tv_show_collection_asserts"
|
- "_tv_show_collection_asserts"
|
||||||
|
|
||||||
download:
|
download:
|
||||||
- url: "{collection_season_1_url}"
|
- url: "{ %array_at( %get_season_urls(collection_season_1_url), 0) }"
|
||||||
variables:
|
variables:
|
||||||
collection_season_number: "1"
|
collection_season_number: "1"
|
||||||
collection_season_name: "{collection_season_1_name}"
|
collection_season_name: "{collection_season_1_name}"
|
||||||
|
|
@ -142,318 +142,493 @@ presets:
|
||||||
uid: "avatar_uncropped"
|
uid: "avatar_uncropped"
|
||||||
- name: "{tv_show_fanart_file_name}"
|
- name: "{tv_show_fanart_file_name}"
|
||||||
uid: "banner_uncropped"
|
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:
|
variables:
|
||||||
collection_season_number: "2"
|
collection_season_number: "2"
|
||||||
collection_season_name: "{collection_season_2_name}"
|
collection_season_name: "{collection_season_2_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "3"
|
collection_season_number: "3"
|
||||||
collection_season_name: "{collection_season_3_name}"
|
collection_season_name: "{collection_season_3_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "4"
|
collection_season_number: "4"
|
||||||
collection_season_name: "{collection_season_4_name}"
|
collection_season_name: "{collection_season_4_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "5"
|
collection_season_number: "5"
|
||||||
collection_season_name: "{collection_season_5_name}"
|
collection_season_name: "{collection_season_5_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "6"
|
collection_season_number: "6"
|
||||||
collection_season_name: "{collection_season_6_name}"
|
collection_season_name: "{collection_season_6_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "7"
|
collection_season_number: "7"
|
||||||
collection_season_name: "{collection_season_7_name}"
|
collection_season_name: "{collection_season_7_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "8"
|
collection_season_number: "8"
|
||||||
collection_season_name: "{collection_season_8_name}"
|
collection_season_name: "{collection_season_8_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "9"
|
collection_season_number: "9"
|
||||||
collection_season_name: "{collection_season_9_name}"
|
collection_season_name: "{collection_season_9_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "10"
|
collection_season_number: "10"
|
||||||
collection_season_name: "{collection_season_10_name}"
|
collection_season_name: "{collection_season_10_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "11"
|
collection_season_number: "11"
|
||||||
collection_season_name: "{collection_season_11_name}"
|
collection_season_name: "{collection_season_11_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "12"
|
collection_season_number: "12"
|
||||||
collection_season_name: "{collection_season_12_name}"
|
collection_season_name: "{collection_season_12_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "13"
|
collection_season_number: "13"
|
||||||
collection_season_name: "{collection_season_13_name}"
|
collection_season_name: "{collection_season_13_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "14"
|
collection_season_number: "14"
|
||||||
collection_season_name: "{collection_season_14_name}"
|
collection_season_name: "{collection_season_14_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "15"
|
collection_season_number: "15"
|
||||||
collection_season_name: "{collection_season_15_name}"
|
collection_season_name: "{collection_season_15_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "16"
|
collection_season_number: "16"
|
||||||
collection_season_name: "{collection_season_16_name}"
|
collection_season_name: "{collection_season_16_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "17"
|
collection_season_number: "17"
|
||||||
collection_season_name: "{collection_season_17_name}"
|
collection_season_name: "{collection_season_17_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "18"
|
collection_season_number: "18"
|
||||||
collection_season_name: "{collection_season_18_name}"
|
collection_season_name: "{collection_season_18_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "19"
|
collection_season_number: "19"
|
||||||
collection_season_name: "{collection_season_19_name}"
|
collection_season_name: "{collection_season_19_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "20"
|
collection_season_number: "20"
|
||||||
collection_season_name: "{collection_season_20_name}"
|
collection_season_name: "{collection_season_20_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "21"
|
collection_season_number: "21"
|
||||||
collection_season_name: "{collection_season_21_name}"
|
collection_season_name: "{collection_season_21_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "22"
|
collection_season_number: "22"
|
||||||
collection_season_name: "{collection_season_22_name}"
|
collection_season_name: "{collection_season_22_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "23"
|
collection_season_number: "23"
|
||||||
collection_season_name: "{collection_season_23_name}"
|
collection_season_name: "{collection_season_23_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "24"
|
collection_season_number: "24"
|
||||||
collection_season_name: "{collection_season_24_name}"
|
collection_season_name: "{collection_season_24_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "25"
|
collection_season_number: "25"
|
||||||
collection_season_name: "{collection_season_25_name}"
|
collection_season_name: "{collection_season_25_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "26"
|
collection_season_number: "26"
|
||||||
collection_season_name: "{collection_season_26_name}"
|
collection_season_name: "{collection_season_26_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "27"
|
collection_season_number: "27"
|
||||||
collection_season_name: "{collection_season_27_name}"
|
collection_season_name: "{collection_season_27_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "28"
|
collection_season_number: "28"
|
||||||
collection_season_name: "{collection_season_28_name}"
|
collection_season_name: "{collection_season_28_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "29"
|
collection_season_number: "29"
|
||||||
collection_season_name: "{collection_season_29_name}"
|
collection_season_name: "{collection_season_29_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "30"
|
collection_season_number: "30"
|
||||||
collection_season_name: "{collection_season_30_name}"
|
collection_season_name: "{collection_season_30_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "31"
|
collection_season_number: "31"
|
||||||
collection_season_name: "{collection_season_31_name}"
|
collection_season_name: "{collection_season_31_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "32"
|
collection_season_number: "32"
|
||||||
collection_season_name: "{collection_season_32_name}"
|
collection_season_name: "{collection_season_32_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "33"
|
collection_season_number: "33"
|
||||||
collection_season_name: "{collection_season_33_name}"
|
collection_season_name: "{collection_season_33_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "34"
|
collection_season_number: "34"
|
||||||
collection_season_name: "{collection_season_34_name}"
|
collection_season_name: "{collection_season_34_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "35"
|
collection_season_number: "35"
|
||||||
collection_season_name: "{collection_season_35_name}"
|
collection_season_name: "{collection_season_35_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "36"
|
collection_season_number: "36"
|
||||||
collection_season_name: "{collection_season_36_name}"
|
collection_season_name: "{collection_season_36_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "37"
|
collection_season_number: "37"
|
||||||
collection_season_name: "{collection_season_37_name}"
|
collection_season_name: "{collection_season_37_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "38"
|
collection_season_number: "38"
|
||||||
collection_season_name: "{collection_season_38_name}"
|
collection_season_name: "{collection_season_38_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "39"
|
collection_season_number: "39"
|
||||||
collection_season_name: "{collection_season_39_name}"
|
collection_season_name: "{collection_season_39_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
variables:
|
||||||
collection_season_number: "40"
|
collection_season_number: "40"
|
||||||
collection_season_name: "{collection_season_40_name}"
|
collection_season_name: "{collection_season_40_name}"
|
||||||
playlist_thumbnails:
|
playlist_thumbnails:
|
||||||
- name: "{season_poster_file_name}"
|
- name: "{season_poster_file_name}"
|
||||||
uid: "latest_entry"
|
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:
|
output_directory_nfo_tags:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -625,6 +800,7 @@ presets:
|
||||||
collection_season_38_name: "{s38_name}"
|
collection_season_38_name: "{s38_name}"
|
||||||
collection_season_39_name: "{s39_name}"
|
collection_season_39_name: "{s39_name}"
|
||||||
collection_season_40_name: "{s40_name}"
|
collection_season_40_name: "{s40_name}"
|
||||||
|
collection_season_0_name: "{s00_name}"
|
||||||
|
|
||||||
# Legacy url variable
|
# Legacy url variable
|
||||||
collection_season_1_url: "{s01_url}"
|
collection_season_1_url: "{s01_url}"
|
||||||
|
|
@ -667,6 +843,7 @@ presets:
|
||||||
collection_season_38_url: "{s38_url}"
|
collection_season_38_url: "{s38_url}"
|
||||||
collection_season_39_url: "{s39_url}"
|
collection_season_39_url: "{s39_url}"
|
||||||
collection_season_40_url: "{s40_url}"
|
collection_season_40_url: "{s40_url}"
|
||||||
|
collection_season_0_url: "{s00_url}"
|
||||||
|
|
||||||
s01_name: ""
|
s01_name: ""
|
||||||
s02_name: ""
|
s02_name: ""
|
||||||
|
|
@ -708,6 +885,7 @@ presets:
|
||||||
s38_name: ""
|
s38_name: ""
|
||||||
s39_name: ""
|
s39_name: ""
|
||||||
s40_name: ""
|
s40_name: ""
|
||||||
|
s00_name: ""
|
||||||
|
|
||||||
s01_url: ""
|
s01_url: ""
|
||||||
s02_url: ""
|
s02_url: ""
|
||||||
|
|
@ -749,13 +927,22 @@ presets:
|
||||||
s38_url: ""
|
s38_url: ""
|
||||||
s39_url: ""
|
s39_url: ""
|
||||||
s40_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:
|
_tv_show_collection_bilateral:
|
||||||
preset:
|
preset:
|
||||||
- "_url_bilateral_overrides"
|
- "_url_bilateral_overrides"
|
||||||
|
|
||||||
download:
|
download:
|
||||||
- url: "{ %bilateral_url(collection_season_1_url) }"
|
- url: "{ %array_apply( %get_season_urls(collection_season_1_url), %bilateral_url) }"
|
||||||
variables:
|
variables:
|
||||||
collection_season_number: "1"
|
collection_season_number: "1"
|
||||||
collection_season_name: "{collection_season_1_name}"
|
collection_season_name: "{collection_season_1_name}"
|
||||||
|
|
@ -763,7 +950,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "2"
|
collection_season_number: "2"
|
||||||
collection_season_name: "{collection_season_2_name}"
|
collection_season_name: "{collection_season_2_name}"
|
||||||
|
|
@ -771,7 +958,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "3"
|
collection_season_number: "3"
|
||||||
collection_season_name: "{collection_season_3_name}"
|
collection_season_name: "{collection_season_3_name}"
|
||||||
|
|
@ -779,7 +966,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "4"
|
collection_season_number: "4"
|
||||||
collection_season_name: "{collection_season_4_name}"
|
collection_season_name: "{collection_season_4_name}"
|
||||||
|
|
@ -787,7 +974,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "5"
|
collection_season_number: "5"
|
||||||
collection_season_name: "{collection_season_5_name}"
|
collection_season_name: "{collection_season_5_name}"
|
||||||
|
|
@ -795,7 +982,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "6"
|
collection_season_number: "6"
|
||||||
collection_season_name: "{collection_season_6_name}"
|
collection_season_name: "{collection_season_6_name}"
|
||||||
|
|
@ -803,7 +990,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "7"
|
collection_season_number: "7"
|
||||||
collection_season_name: "{collection_season_7_name}"
|
collection_season_name: "{collection_season_7_name}"
|
||||||
|
|
@ -811,7 +998,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "8"
|
collection_season_number: "8"
|
||||||
collection_season_name: "{collection_season_8_name}"
|
collection_season_name: "{collection_season_8_name}"
|
||||||
|
|
@ -819,7 +1006,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "9"
|
collection_season_number: "9"
|
||||||
collection_season_name: "{collection_season_9_name}"
|
collection_season_name: "{collection_season_9_name}"
|
||||||
|
|
@ -827,7 +1014,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "10"
|
collection_season_number: "10"
|
||||||
collection_season_name: "{collection_season_10_name}"
|
collection_season_name: "{collection_season_10_name}"
|
||||||
|
|
@ -835,7 +1022,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "11"
|
collection_season_number: "11"
|
||||||
collection_season_name: "{collection_season_11_name}"
|
collection_season_name: "{collection_season_11_name}"
|
||||||
|
|
@ -843,7 +1030,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "12"
|
collection_season_number: "12"
|
||||||
collection_season_name: "{collection_season_12_name}"
|
collection_season_name: "{collection_season_12_name}"
|
||||||
|
|
@ -851,7 +1038,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "13"
|
collection_season_number: "13"
|
||||||
collection_season_name: "{collection_season_13_name}"
|
collection_season_name: "{collection_season_13_name}"
|
||||||
|
|
@ -859,7 +1046,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "14"
|
collection_season_number: "14"
|
||||||
collection_season_name: "{collection_season_14_name}"
|
collection_season_name: "{collection_season_14_name}"
|
||||||
|
|
@ -867,7 +1054,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "15"
|
collection_season_number: "15"
|
||||||
collection_season_name: "{collection_season_15_name}"
|
collection_season_name: "{collection_season_15_name}"
|
||||||
|
|
@ -875,7 +1062,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "16"
|
collection_season_number: "16"
|
||||||
collection_season_name: "{collection_season_16_name}"
|
collection_season_name: "{collection_season_16_name}"
|
||||||
|
|
@ -883,7 +1070,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "17"
|
collection_season_number: "17"
|
||||||
collection_season_name: "{collection_season_17_name}"
|
collection_season_name: "{collection_season_17_name}"
|
||||||
|
|
@ -891,7 +1078,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "18"
|
collection_season_number: "18"
|
||||||
collection_season_name: "{collection_season_18_name}"
|
collection_season_name: "{collection_season_18_name}"
|
||||||
|
|
@ -899,7 +1086,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "19"
|
collection_season_number: "19"
|
||||||
collection_season_name: "{collection_season_19_name}"
|
collection_season_name: "{collection_season_19_name}"
|
||||||
|
|
@ -907,7 +1094,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "20"
|
collection_season_number: "20"
|
||||||
collection_season_name: "{collection_season_20_name}"
|
collection_season_name: "{collection_season_20_name}"
|
||||||
|
|
@ -915,7 +1102,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "21"
|
collection_season_number: "21"
|
||||||
collection_season_name: "{collection_season_21_name}"
|
collection_season_name: "{collection_season_21_name}"
|
||||||
|
|
@ -923,7 +1110,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "22"
|
collection_season_number: "22"
|
||||||
collection_season_name: "{collection_season_22_name}"
|
collection_season_name: "{collection_season_22_name}"
|
||||||
|
|
@ -931,7 +1118,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "23"
|
collection_season_number: "23"
|
||||||
collection_season_name: "{collection_season_23_name}"
|
collection_season_name: "{collection_season_23_name}"
|
||||||
|
|
@ -939,7 +1126,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "24"
|
collection_season_number: "24"
|
||||||
collection_season_name: "{collection_season_24_name}"
|
collection_season_name: "{collection_season_24_name}"
|
||||||
|
|
@ -947,7 +1134,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "25"
|
collection_season_number: "25"
|
||||||
collection_season_name: "{collection_season_25_name}"
|
collection_season_name: "{collection_season_25_name}"
|
||||||
|
|
@ -955,7 +1142,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "26"
|
collection_season_number: "26"
|
||||||
collection_season_name: "{collection_season_26_name}"
|
collection_season_name: "{collection_season_26_name}"
|
||||||
|
|
@ -963,7 +1150,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "27"
|
collection_season_number: "27"
|
||||||
collection_season_name: "{collection_season_27_name}"
|
collection_season_name: "{collection_season_27_name}"
|
||||||
|
|
@ -971,7 +1158,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "28"
|
collection_season_number: "28"
|
||||||
collection_season_name: "{collection_season_28_name}"
|
collection_season_name: "{collection_season_28_name}"
|
||||||
|
|
@ -979,7 +1166,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "29"
|
collection_season_number: "29"
|
||||||
collection_season_name: "{collection_season_29_name}"
|
collection_season_name: "{collection_season_29_name}"
|
||||||
|
|
@ -987,7 +1174,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "30"
|
collection_season_number: "30"
|
||||||
collection_season_name: "{collection_season_30_name}"
|
collection_season_name: "{collection_season_30_name}"
|
||||||
|
|
@ -995,7 +1182,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "31"
|
collection_season_number: "31"
|
||||||
collection_season_name: "{collection_season_31_name}"
|
collection_season_name: "{collection_season_31_name}"
|
||||||
|
|
@ -1003,7 +1190,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "32"
|
collection_season_number: "32"
|
||||||
collection_season_name: "{collection_season_32_name}"
|
collection_season_name: "{collection_season_32_name}"
|
||||||
|
|
@ -1011,7 +1198,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "33"
|
collection_season_number: "33"
|
||||||
collection_season_name: "{collection_season_33_name}"
|
collection_season_name: "{collection_season_33_name}"
|
||||||
|
|
@ -1019,7 +1206,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "34"
|
collection_season_number: "34"
|
||||||
collection_season_name: "{collection_season_34_name}"
|
collection_season_name: "{collection_season_34_name}"
|
||||||
|
|
@ -1027,7 +1214,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "35"
|
collection_season_number: "35"
|
||||||
collection_season_name: "{collection_season_35_name}"
|
collection_season_name: "{collection_season_35_name}"
|
||||||
|
|
@ -1035,7 +1222,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "36"
|
collection_season_number: "36"
|
||||||
collection_season_name: "{collection_season_36_name}"
|
collection_season_name: "{collection_season_36_name}"
|
||||||
|
|
@ -1043,7 +1230,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "37"
|
collection_season_number: "37"
|
||||||
collection_season_name: "{collection_season_37_name}"
|
collection_season_name: "{collection_season_37_name}"
|
||||||
|
|
@ -1051,7 +1238,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "38"
|
collection_season_number: "38"
|
||||||
collection_season_name: "{collection_season_38_name}"
|
collection_season_name: "{collection_season_38_name}"
|
||||||
|
|
@ -1059,7 +1246,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "39"
|
collection_season_number: "39"
|
||||||
collection_season_name: "{collection_season_39_name}"
|
collection_season_name: "{collection_season_39_name}"
|
||||||
|
|
@ -1067,7 +1254,7 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
variables:
|
||||||
collection_season_number: "40"
|
collection_season_number: "40"
|
||||||
collection_season_name: "{collection_season_40_name}"
|
collection_season_name: "{collection_season_40_name}"
|
||||||
|
|
@ -1075,6 +1262,15 @@ presets:
|
||||||
ytdl_options:
|
ytdl_options:
|
||||||
playlist_items: "-1:0:-1"
|
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:
|
_tv_show_collection_asserts:
|
||||||
overrides:
|
overrides:
|
||||||
url: ""
|
url: ""
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import math
|
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 AnyArgument
|
||||||
from ytdl_sub.script.types.resolvable import Float
|
from ytdl_sub.script.types.resolvable import Float
|
||||||
from ytdl_sub.script.types.resolvable import Integer
|
from ytdl_sub.script.types.resolvable import Integer
|
||||||
|
|
@ -96,3 +98,18 @@ class NumericFunctions:
|
||||||
Returns min of all values.
|
Returns min of all values.
|
||||||
"""
|
"""
|
||||||
return _to_numeric(min(val.value for val in 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)])
|
||||||
|
|
|
||||||
|
|
@ -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 InvalidVariableName
|
||||||
from ytdl_sub.script.utils.exceptions import UserException
|
from ytdl_sub.script.utils.exceptions import UserException
|
||||||
from ytdl_sub.script.utils.exceptions import VariableDoesNotExist
|
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
|
from ytdl_sub.script.utils.name_validation import validate_variable_name
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|
@ -144,6 +146,9 @@ class _Parser:
|
||||||
):
|
):
|
||||||
self._text = text
|
self._text = text
|
||||||
self._name = name
|
self._name = name
|
||||||
|
if name and is_function(name):
|
||||||
|
self._name = to_function_name(name)
|
||||||
|
|
||||||
self._custom_function_names = custom_function_names
|
self._custom_function_names = custom_function_names
|
||||||
self._variable_names = variable_names
|
self._variable_names = variable_names
|
||||||
self._pos = 0
|
self._pos = 0
|
||||||
|
|
@ -605,7 +610,7 @@ def parse(
|
||||||
name=name,
|
name=name,
|
||||||
custom_function_names=custom_function_names,
|
custom_function_names=custom_function_names,
|
||||||
variable_names=variable_names,
|
variable_names=variable_names,
|
||||||
).ast
|
).ast.maybe_resolvable_casted()
|
||||||
|
|
||||||
|
|
||||||
# pylint: enable=invalid-name
|
# pylint: enable=invalid-name
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# pylint: disable=missing-raises-doc
|
# pylint: disable=missing-raises-doc
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -7,40 +8,28 @@ from typing import Set
|
||||||
from ytdl_sub.script.functions import Functions
|
from ytdl_sub.script.functions import Functions
|
||||||
from ytdl_sub.script.parser import parse
|
from ytdl_sub.script.parser import parse
|
||||||
from ytdl_sub.script.script_output import ScriptOutput
|
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 BuiltInFunctionType
|
||||||
from ytdl_sub.script.types.resolvable import Lambda
|
from ytdl_sub.script.types.resolvable import Lambda
|
||||||
from ytdl_sub.script.types.resolvable import Resolvable
|
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.syntax_tree import SyntaxTree
|
||||||
from ytdl_sub.script.types.variable import FunctionArgument
|
from ytdl_sub.script.types.variable import FunctionArgument
|
||||||
from ytdl_sub.script.types.variable import Variable
|
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 UNREACHABLE
|
||||||
from ytdl_sub.script.utils.exceptions import CycleDetected
|
from ytdl_sub.script.utils.exceptions import CycleDetected
|
||||||
from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments
|
from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments
|
||||||
from ytdl_sub.script.utils.exceptions import InvalidCustomFunctionArguments
|
from ytdl_sub.script.utils.exceptions import InvalidCustomFunctionArguments
|
||||||
from ytdl_sub.script.utils.exceptions import RuntimeException
|
from ytdl_sub.script.utils.exceptions import RuntimeException
|
||||||
from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
|
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.name_validation import validate_variable_name
|
||||||
from ytdl_sub.script.utils.type_checking import FunctionSpec
|
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:
|
class Script:
|
||||||
"""
|
"""
|
||||||
Takes a dictionary of both
|
Takes a dictionary of both
|
||||||
|
|
@ -49,43 +38,71 @@ class Script:
|
||||||
``{ %custom_function: syntax }``
|
``{ %custom_function: syntax }``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _ensure_no_cycle(
|
def _throw_cycle_error(
|
||||||
self, name: str, dep: str, deps: List[str], definitions: Dict[str, SyntaxTree]
|
self, name: str, dep: str, deps: List[str], definitions: Dict[str, SyntaxTree]
|
||||||
):
|
):
|
||||||
if dep not in definitions:
|
type_name, pre = (
|
||||||
return # does not exist, will throw downstream in parser
|
("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]:
|
raise CycleDetected(f"Cycle detected within these {type_name}: {cycle_deps_str}")
|
||||||
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}")
|
|
||||||
|
|
||||||
def _traverse_variable_dependencies(
|
def _traverse_variable_dependencies(
|
||||||
self,
|
self,
|
||||||
variable_name: str,
|
variable_name: str,
|
||||||
variable_dependency: SyntaxTree,
|
variable_dependency: SyntaxTree,
|
||||||
deps: List[str],
|
deps: List[str],
|
||||||
|
ensured: Dict[str, Set[str]],
|
||||||
) -> None:
|
) -> None:
|
||||||
for dep in variable_dependency.variables:
|
for dep in variable_dependency.variables:
|
||||||
self._ensure_no_cycle(
|
if variable_name == dep.name:
|
||||||
name=variable_name, dep=dep.name, deps=deps, definitions=self._variables
|
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(
|
self._traverse_variable_dependencies(
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
variable_dependency=self._variables[dep.name],
|
variable_dependency=self._variables[dep.name],
|
||||||
deps=deps + [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]):
|
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():
|
for variable_name, variable_definition in variables.items():
|
||||||
self._traverse_variable_dependencies(
|
self._traverse_variable_dependencies(
|
||||||
variable_name=variable_name,
|
variable_name=variable_name,
|
||||||
variable_dependency=variable_definition,
|
variable_dependency=variable_definition,
|
||||||
deps=[],
|
deps=[],
|
||||||
|
ensured=ensured,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _traverse_custom_function_dependencies(
|
def _traverse_custom_function_dependencies(
|
||||||
|
|
@ -95,9 +112,10 @@ class Script:
|
||||||
deps: List[str],
|
deps: List[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
for dep in custom_function_dependency.custom_functions:
|
for dep in custom_function_dependency.custom_functions:
|
||||||
self._ensure_no_cycle(
|
if custom_function_name == dep.name:
|
||||||
name=custom_function_name, dep=dep.name, deps=deps, definitions=self._functions
|
self._throw_cycle_error(
|
||||||
)
|
name=custom_function_name, dep=dep.name, deps=deps, definitions=self._functions
|
||||||
|
)
|
||||||
self._traverse_custom_function_dependencies(
|
self._traverse_custom_function_dependencies(
|
||||||
custom_function_name=custom_function_name,
|
custom_function_name=custom_function_name,
|
||||||
custom_function_dependency=self._functions[dep.name],
|
custom_function_dependency=self._functions[dep.name],
|
||||||
|
|
@ -240,23 +258,23 @@ class Script:
|
||||||
|
|
||||||
def __init__(self, script: Dict[str, str]):
|
def __init__(self, script: Dict[str, str]):
|
||||||
function_names: Set[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] = {
|
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] = {
|
self._functions: Dict[str, SyntaxTree] = {
|
||||||
# custom_function_name must be passed to properly type custom function
|
# custom_function_name must be passed to properly type custom function
|
||||||
# arguments uniquely if they're nested (i.e. $0 to $custom_func___0)
|
# 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,
|
text=function_value,
|
||||||
name=_function_name(function_key),
|
name=to_function_name(function_key),
|
||||||
custom_function_names=function_names,
|
custom_function_names=function_names,
|
||||||
variable_names=variable_names,
|
variable_names=variable_names,
|
||||||
)
|
)
|
||||||
for function_key, function_value in script.items()
|
for function_key, function_value in script.items()
|
||||||
if _is_function(function_key)
|
if is_function(function_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
self._variables: Dict[str, SyntaxTree] = {
|
self._variables: Dict[str, SyntaxTree] = {
|
||||||
|
|
@ -267,13 +285,13 @@ class Script:
|
||||||
variable_names=variable_names,
|
variable_names=variable_names,
|
||||||
)
|
)
|
||||||
for variable_key, variable_value in script.items()
|
for variable_key, variable_value in script.items()
|
||||||
if not _is_function(variable_key)
|
if not is_function(variable_key)
|
||||||
}
|
}
|
||||||
self._validate()
|
self._validate()
|
||||||
|
|
||||||
def _update_internally(self, resolved_variables: Dict[str, Resolvable]) -> None:
|
def _update_internally(self, resolved_variables: Dict[str, Resolvable]) -> None:
|
||||||
for variable_name, resolved in resolved_variables.items():
|
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(
|
def _recursive_get_unresolved_output_filter_variables(
|
||||||
self, current_var: SyntaxTree, subset_to_resolve: Set[str], unresolvable: Set[Variable]
|
self, current_var: SyntaxTree, subset_to_resolve: Set[str], unresolvable: Set[Variable]
|
||||||
|
|
@ -302,14 +320,21 @@ class Script:
|
||||||
unresolvable=unresolvable,
|
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
|
return subset_to_resolve
|
||||||
|
|
||||||
def _get_unresolved_output_filter(
|
def _get_unresolved_output_filter(
|
||||||
self,
|
self,
|
||||||
unresolved: Dict[Variable, SyntaxTree],
|
|
||||||
output_filter: Set[str],
|
output_filter: Set[str],
|
||||||
unresolvable: Set[Variable],
|
unresolvable: Set[Variable],
|
||||||
) -> Dict[Variable, SyntaxTree]:
|
) -> Set[str]:
|
||||||
"""
|
"""
|
||||||
When an output filter is applied, only a subset of variables that the filter
|
When an output filter is applied, only a subset of variables that the filter
|
||||||
depends on need to be resolved.
|
depends on need to be resolved.
|
||||||
|
|
@ -330,7 +355,7 @@ class Script:
|
||||||
unresolvable=unresolvable,
|
unresolvable=unresolvable,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {var: syntax for var, syntax in unresolved.items() if var.name in subset_to_resolve}
|
return subset_to_resolve
|
||||||
|
|
||||||
def _resolve(
|
def _resolve(
|
||||||
self,
|
self,
|
||||||
|
|
@ -366,18 +391,21 @@ class Script:
|
||||||
|
|
||||||
unresolvable: Set[Variable] = {Variable(name) for name in (unresolvable or {})}
|
unresolvable: Set[Variable] = {Variable(name) for name in (unresolvable or {})}
|
||||||
unresolved_filter = set(resolved.keys()).union(unresolvable)
|
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:
|
if output_filter:
|
||||||
unresolved = self._get_unresolved_output_filter(
|
unresolved = {
|
||||||
unresolved=unresolved,
|
Variable(name): self._variables[name]
|
||||||
output_filter=output_filter,
|
for name in self._get_unresolved_output_filter(
|
||||||
unresolvable=unresolvable,
|
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:
|
while unresolved:
|
||||||
unresolved_count: int = len(unresolved)
|
unresolved_count: int = len(unresolved)
|
||||||
|
|
@ -398,8 +426,8 @@ class Script:
|
||||||
|
|
||||||
# Otherwise, if it has dependencies that are all resolved, then
|
# Otherwise, if it has dependencies that are all resolved, then
|
||||||
# resolve the definition
|
# resolve the definition
|
||||||
elif not definition.is_subset_of(
|
elif definition.is_subset_of(
|
||||||
variables=resolved.keys(), custom_function_definitions=self._functions
|
variables=resolved, custom_function_definitions=self._functions
|
||||||
):
|
):
|
||||||
resolved[variable] = unresolved[variable].resolve(
|
resolved[variable] = unresolved[variable].resolve(
|
||||||
resolved_variables=resolved,
|
resolved_variables=resolved,
|
||||||
|
|
@ -482,12 +510,12 @@ class Script:
|
||||||
added_variables_to_validate: Set[str] = set()
|
added_variables_to_validate: Set[str] = set()
|
||||||
|
|
||||||
functions_to_add = {
|
functions_to_add = {
|
||||||
_function_name(name): definition
|
to_function_name(name): definition
|
||||||
for name, definition in variables.items()
|
for name, definition in variables.items()
|
||||||
if _is_function(name)
|
if is_function(name)
|
||||||
}
|
}
|
||||||
variables_to_add = {
|
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()
|
custom_function_names = set(self._functions.keys()) | functions_to_add.keys()
|
||||||
|
|
@ -517,11 +545,52 @@ class Script:
|
||||||
|
|
||||||
return self
|
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(
|
def resolve_once(
|
||||||
self,
|
self,
|
||||||
variable_definitions: Dict[str, str],
|
variable_definitions: Dict[str, str],
|
||||||
resolved: Optional[Dict[str, Resolvable]] = None,
|
resolved: Optional[Dict[str, Resolvable]] = None,
|
||||||
unresolvable: Optional[Set[str]] = None,
|
unresolvable: Optional[Set[str]] = None,
|
||||||
|
update: bool = False,
|
||||||
) -> Dict[str, Resolvable]:
|
) -> Dict[str, Resolvable]:
|
||||||
"""
|
"""
|
||||||
Given a new set of variable definitions, resolve them using the Script, but do not
|
Given a new set of variable definitions, resolve them using the Script, but do not
|
||||||
|
|
@ -536,6 +605,8 @@ class Script:
|
||||||
unresolvable
|
unresolvable
|
||||||
Optional. Unresolvable variables that will be ignored in resolution, including all
|
Optional. Unresolvable variables that will be ignored in resolution, including all
|
||||||
variables with a dependency to them.
|
variables with a dependency to them.
|
||||||
|
update
|
||||||
|
Whether to update the script's state with resolved variables. Defaults to False.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -548,11 +619,51 @@ class Script:
|
||||||
pre_resolved=resolved,
|
pre_resolved=resolved,
|
||||||
unresolvable=unresolvable,
|
unresolvable=unresolvable,
|
||||||
output_filter=set(list(variable_definitions.keys())),
|
output_filter=set(list(variable_definitions.keys())),
|
||||||
|
update=update,
|
||||||
).output
|
).output
|
||||||
finally:
|
finally:
|
||||||
for name in variable_definitions.keys():
|
for name in variable_definitions.keys():
|
||||||
if name in self._variables:
|
self._variables.pop(name, None)
|
||||||
del self._variables[name]
|
|
||||||
|
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:
|
def get(self, variable_name: str) -> Resolvable:
|
||||||
"""
|
"""
|
||||||
|
|
@ -581,6 +692,18 @@ class Script:
|
||||||
|
|
||||||
raise RuntimeException(f"Tried to get unresolved variable {variable_name}")
|
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
|
@property
|
||||||
def variable_names(self) -> Set[str]:
|
def variable_names(self) -> Set[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -599,4 +722,121 @@ class Script:
|
||||||
Set[str]
|
Set[str]
|
||||||
Names of all functions within the Script.
|
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)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class UnresolvedArray(_Array, VariableDependency, FutureResolvable):
|
||||||
value: List[Argument]
|
value: List[Argument]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _iterable_arguments(self) -> List[Argument]:
|
def iterable_arguments(self) -> List[Argument]:
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
def resolve(
|
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]:
|
def future_resolvable_type(self) -> Type[Resolvable]:
|
||||||
return Array
|
return Array
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import copy
|
|
||||||
import functools
|
import functools
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
from typing import Type
|
from typing import Type
|
||||||
from typing import Union
|
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 Array
|
||||||
from ytdl_sub.script.types.array import UnresolvedArray
|
from ytdl_sub.script.types.array import UnresolvedArray
|
||||||
from ytdl_sub.script.types.resolvable import Argument
|
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 BuiltInFunctionType
|
||||||
from ytdl_sub.script.types.resolvable import FunctionType
|
from ytdl_sub.script.types.resolvable import FunctionType
|
||||||
from ytdl_sub.script.types.resolvable import FutureResolvable
|
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 Lambda
|
||||||
from ytdl_sub.script.types.resolvable import NamedCustomFunction
|
from ytdl_sub.script.types.resolvable import NamedCustomFunction
|
||||||
from ytdl_sub.script.types.resolvable import Resolvable
|
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)
|
@dataclass(frozen=True)
|
||||||
class Function(FunctionType, VariableDependency, ABC):
|
class Function(FunctionType, VariableDependency, ABC):
|
||||||
@property
|
@property
|
||||||
def _iterable_arguments(self) -> List[Argument]:
|
def iterable_arguments(self) -> List[Argument]:
|
||||||
return self.args
|
return self.args
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,27 +60,79 @@ class CustomFunction(Function, NamedCustomFunction):
|
||||||
# Should be validated in the Script
|
# Should be validated in the Script
|
||||||
raise UNREACHABLE
|
raise UNREACHABLE
|
||||||
|
|
||||||
resolved_variables_with_args = copy.deepcopy(resolved_variables)
|
function_args: List[FunctionArgument] = []
|
||||||
for i, arg in enumerate(resolved_args):
|
for i, arg in enumerate(resolved_args):
|
||||||
function_arg = FunctionArgument.from_idx(idx=i, custom_function_name=self.name)
|
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
|
# function args should always be unique since they are only defined once
|
||||||
# in the custom function as %custom_function_name___idx
|
# in the custom function as %custom_function_name___idx
|
||||||
# and returned as a set from each custom function.
|
# and returned as a set from each custom function.
|
||||||
raise UNREACHABLE
|
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(
|
out = custom_functions[self.name].resolve(
|
||||||
resolved_variables=resolved_variables_with_args,
|
resolved_variables=resolved_variables,
|
||||||
custom_functions=custom_functions,
|
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
|
# Implies the custom function does not exist. This should have
|
||||||
# been checked in the parser with
|
# been checked in the parser with
|
||||||
raise UNREACHABLE
|
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):
|
class BuiltInFunction(Function, BuiltInFunctionType):
|
||||||
def validate_args(self) -> "BuiltInFunction":
|
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)}"
|
f"Runtime error occurred when executing the function %{self.name}: {str(exc)}"
|
||||||
) from 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):
|
def __hash__(self):
|
||||||
return hash((self.name, *self.args))
|
return hash((self.name, *self.args))
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class UnresolvedMap(_Map, VariableDependency, FutureResolvable):
|
||||||
value: Dict[Argument, Argument]
|
value: Dict[Argument, Argument]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _iterable_arguments(self) -> List[Argument]:
|
def iterable_arguments(self) -> List[Argument]:
|
||||||
return list(itertools.chain(*self.value.items()))
|
return list(itertools.chain(*self.value.items()))
|
||||||
|
|
||||||
def resolve(
|
def resolve(
|
||||||
|
|
@ -55,6 +55,35 @@ class UnresolvedMap(_Map, VariableDependency, FutureResolvable):
|
||||||
|
|
||||||
return Map(output)
|
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]:
|
def future_resolvable_type(self) -> Type[Resolvable]:
|
||||||
return Map
|
return Map
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,14 @@ class NamedCustomFunction(NamedArgument, ABC):
|
||||||
class ParsedCustomFunction(NamedCustomFunction):
|
class ParsedCustomFunction(NamedCustomFunction):
|
||||||
num_input_args: int
|
num_input_args: int
|
||||||
|
|
||||||
|
def definition_name(self) -> str:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
The function definition name, including the %
|
||||||
|
"""
|
||||||
|
return f"%{self.name}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FunctionType(NamedArgument, ABC):
|
class FunctionType(NamedArgument, ABC):
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
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 Argument
|
||||||
from ytdl_sub.script.types.resolvable import Resolvable
|
from ytdl_sub.script.types.resolvable import Resolvable
|
||||||
from ytdl_sub.script.types.resolvable import String
|
from ytdl_sub.script.types.resolvable import String
|
||||||
|
|
@ -15,7 +16,7 @@ class SyntaxTree(VariableDependency):
|
||||||
ast: List[Argument]
|
ast: List[Argument]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _iterable_arguments(self) -> List[Argument]:
|
def iterable_arguments(self) -> List[Argument]:
|
||||||
return self.ast
|
return self.ast
|
||||||
|
|
||||||
def resolve(
|
def resolve(
|
||||||
|
|
@ -40,6 +41,30 @@ class SyntaxTree(VariableDependency):
|
||||||
# Otherwise, to concat multiple resolved outputs, we must concat as strings
|
# Otherwise, to concat multiple resolved outputs, we must concat as strings
|
||||||
return String("".join([str(res) for res in resolved]))
|
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
|
@property
|
||||||
def maybe_resolvable(self) -> Optional[Resolvable]:
|
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.
|
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
|
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]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from typing import Dict
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Set
|
from typing import Set
|
||||||
|
from typing import Tuple
|
||||||
from typing import Type
|
from typing import Type
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
from typing import final
|
from typing import final
|
||||||
|
|
@ -27,7 +28,7 @@ TypeT = TypeVar("TypeT")
|
||||||
class VariableDependency(ABC):
|
class VariableDependency(ABC):
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _iterable_arguments(self) -> List[Argument]:
|
def iterable_arguments(self) -> List[Argument]:
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -38,7 +39,7 @@ class VariableDependency(ABC):
|
||||||
self, ttype: Type[TypeT], subclass: bool = False, instance: bool = True
|
self, ttype: Type[TypeT], subclass: bool = False, instance: bool = True
|
||||||
) -> List[TypeT]:
|
) -> List[TypeT]:
|
||||||
output: List[TypeT] = []
|
output: List[TypeT] = []
|
||||||
for arg in self._iterable_arguments:
|
for arg in self.iterable_arguments:
|
||||||
if subclass and issubclass(type(arg), ttype):
|
if subclass and issubclass(type(arg), ttype):
|
||||||
output.append(arg)
|
output.append(arg)
|
||||||
elif instance and isinstance(arg, ttype):
|
elif instance and isinstance(arg, ttype):
|
||||||
|
|
@ -104,7 +105,7 @@ class VariableDependency(ABC):
|
||||||
All CustomFunctions that this depends on.
|
All CustomFunctions that this depends on.
|
||||||
"""
|
"""
|
||||||
output: Set[ParsedCustomFunction] = set()
|
output: Set[ParsedCustomFunction] = set()
|
||||||
for arg in self._iterable_arguments:
|
for arg in self.iterable_arguments:
|
||||||
if isinstance(arg, NamedCustomFunction):
|
if isinstance(arg, NamedCustomFunction):
|
||||||
if not isinstance(arg, FunctionType):
|
if not isinstance(arg, FunctionType):
|
||||||
# A NamedCustomFunction should also always be a FunctionType
|
# A NamedCustomFunction should also always be a FunctionType
|
||||||
|
|
@ -138,6 +139,28 @@ class VariableDependency(ABC):
|
||||||
Resolved value
|
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
|
@classmethod
|
||||||
def _resolve_argument_type(
|
def _resolve_argument_type(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -159,10 +182,35 @@ class VariableDependency(ABC):
|
||||||
|
|
||||||
raise UNREACHABLE
|
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
|
@final
|
||||||
def is_subset_of(
|
def is_subset_of(
|
||||||
self,
|
self,
|
||||||
variables: Iterable[Variable],
|
variables: Dict[Variable, Resolvable],
|
||||||
custom_function_definitions: Dict[str, "VariableDependency"],
|
custom_function_definitions: Dict[str, "VariableDependency"],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -170,13 +218,14 @@ class VariableDependency(ABC):
|
||||||
-------
|
-------
|
||||||
True if it contains all input variables as a dependency. False otherwise.
|
True if it contains all input variables as a dependency. False otherwise.
|
||||||
"""
|
"""
|
||||||
for custom_function in self.custom_functions:
|
# If there are lambdas, see if they are custom functions. If so, check them
|
||||||
if custom_function_definitions[custom_function.name].is_subset_of(
|
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
|
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
|
@final
|
||||||
def contains(
|
def contains(
|
||||||
|
|
@ -189,9 +238,53 @@ class VariableDependency(ABC):
|
||||||
-------
|
-------
|
||||||
True if it contains any of the input variables. False otherwise.
|
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(
|
if custom_function_definitions[custom_function.name].contains(
|
||||||
variables=variables, custom_function_definitions=custom_function_definitions
|
variables=variables, custom_function_definitions=custom_function_definitions
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
return len(self.variables.intersection(variables)) > 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,24 @@ def validate_custom_function_name(custom_function_name: str) -> None:
|
||||||
f"Custom function name '%{custom_function_name}' is invalid:"
|
f"Custom function name '%{custom_function_name}' is invalid:"
|
||||||
" The name is used by a built-in function and cannot be overwritten."
|
" 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}"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import inspect
|
import inspect
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from inspect import FullArgSpec
|
from inspect import FullArgSpec
|
||||||
|
from types import NoneType
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -50,7 +51,7 @@ def get_optional_type(optional_type: Type) -> Type[NamedType]:
|
||||||
-------
|
-------
|
||||||
Type within the Optional[Type]
|
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(
|
def _is_union_compatible(
|
||||||
|
|
|
||||||
|
|
@ -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 import Preset
|
||||||
from ytdl_sub.config.preset_options import OutputOptions
|
from ytdl_sub.config.preset_options import OutputOptions
|
||||||
from ytdl_sub.config.preset_options import YTDLOptions
|
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.downloaders.url.validators import MultiUrlValidator
|
||||||
from ytdl_sub.entries.variables.override_variables import SubscriptionVariables
|
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.file_handler import FileHandlerTransactionLog
|
||||||
from ytdl_sub.utils.logger import Logger
|
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
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
logger = Logger.get("subscription")
|
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] = (
|
self._enhanced_download_archive: Optional[EnhancedDownloadArchive] = (
|
||||||
_initialize_download_archive(
|
_initialize_download_archive(
|
||||||
output_options=self.output_options,
|
output_options=self.output_options,
|
||||||
|
|
@ -86,14 +99,20 @@ class BaseSubscription(ABC):
|
||||||
# Add post-archive variables
|
# Add post-archive variables
|
||||||
self.overrides.add(
|
self.overrides.add(
|
||||||
{
|
{
|
||||||
SubscriptionVariables.subscription_has_download_archive(): f"""{{
|
SubscriptionVariables.subscription_has_download_archive(): (
|
||||||
%bool({self.download_archive.num_entries > 0})
|
f"{{%bool({self.download_archive.num_entries > 0})}}"
|
||||||
}}""",
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self._exception: Optional[Exception] = None
|
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
|
@property
|
||||||
def download_archive(self) -> EnhancedDownloadArchive:
|
def download_archive(self) -> EnhancedDownloadArchive:
|
||||||
"""
|
"""
|
||||||
|
|
@ -236,4 +255,22 @@ class BaseSubscription(ABC):
|
||||||
-------
|
-------
|
||||||
Subscription in yaml format
|
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)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
file_metadata=entry_metadata,
|
file_metadata=entry_metadata,
|
||||||
output_file_name=output_file_name,
|
output_file_name=output_file_name,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
|
preserve_mtime=self.output_options.preserve_mtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Always pretend to include the thumbnail in a dry-run
|
# Always pretend to include the thumbnail in a dry-run
|
||||||
|
|
@ -87,6 +88,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
output_file_name=output_thumbnail_name,
|
output_file_name=output_thumbnail_name,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
copy_file=True,
|
copy_file=True,
|
||||||
|
preserve_mtime=self.output_options.preserve_mtime,
|
||||||
)
|
)
|
||||||
elif not entry.is_thumbnail_downloaded():
|
elif not entry.is_thumbnail_downloaded():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -106,6 +108,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
file_name=entry.get_download_info_json_name(),
|
file_name=entry.get_download_info_json_name(),
|
||||||
output_file_name=output_info_json_name,
|
output_file_name=output_info_json_name,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
|
preserve_mtime=self.output_options.preserve_mtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _delete_working_directory(self, is_error: bool = False) -> None:
|
def _delete_working_directory(self, is_error: bool = False) -> None:
|
||||||
|
|
@ -152,8 +155,8 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
keep_max_files: Optional[int] = None
|
keep_max_files: Optional[int] = None
|
||||||
if self.output_options.keep_max_files:
|
if self.output_options.keep_max_files:
|
||||||
# validated it can be cast to int within the validator
|
# validated it can be cast to int within the validator
|
||||||
keep_max_files = int(
|
keep_max_files = self.overrides.apply_formatter(
|
||||||
self.overrides.apply_formatter(self.output_options.keep_max_files)
|
self.output_options.keep_max_files, expected_type=int
|
||||||
)
|
)
|
||||||
|
|
||||||
if date_range_to_keep or self.output_options.keep_max_files is not None:
|
if date_range_to_keep or self.output_options.keep_max_files is not None:
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,8 @@ class SubscriptionYTDLOptions:
|
||||||
self._enhanced_download_archive.working_ytdl_file_path
|
self._enhanced_download_archive.working_ytdl_file_path
|
||||||
)
|
)
|
||||||
if self._preset.output_options.keep_max_files:
|
if self._preset.output_options.keep_max_files:
|
||||||
keep_max_files = int(
|
keep_max_files = self._overrides.apply_formatter(
|
||||||
self._overrides.apply_formatter(self._preset.output_options.keep_max_files)
|
self._preset.output_options.keep_max_files, expected_type=int
|
||||||
)
|
)
|
||||||
if keep_max_files > 0:
|
if keep_max_files > 0:
|
||||||
# yt-dlp has a weird bug with max_downloads=1, set to 2 for safe measure
|
# yt-dlp has a weird bug with max_downloads=1, set to 2 for safe measure
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,7 @@ class FileNotDownloadedException(ValueError):
|
||||||
|
|
||||||
class ExperimentalFeatureNotEnabled(ValidationException):
|
class ExperimentalFeatureNotEnabled(ValidationException):
|
||||||
"""Feature is not enabled for usage"""
|
"""Feature is not enabled for usage"""
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPermissionError(ValidationException):
|
||||||
|
"""Early-caught permission error"""
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,11 @@ def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -
|
||||||
"-dn", # ignore data streams
|
"-dn", # ignore data streams
|
||||||
]
|
]
|
||||||
for key, value in key_values.items():
|
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_args.extend(["-codec", "copy", "-bitexact", tmp_file_path])
|
||||||
|
|
||||||
FFMPEG.run(ffmpeg_args)
|
FFMPEG.run(ffmpeg_args)
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,36 @@ class FileHandler:
|
||||||
"""
|
"""
|
||||||
return self._file_handler_transaction_log
|
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
|
@classmethod
|
||||||
def copy(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]):
|
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):
|
if os.path.isfile(file_path):
|
||||||
os.remove(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(
|
def move_file_to_output_directory(
|
||||||
self,
|
self,
|
||||||
file_name: str,
|
file_name: str,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import json
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from ytdl_sub.script.parser import parse
|
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.array import UnresolvedArray
|
||||||
from ytdl_sub.script.types.function import BuiltInFunction
|
from ytdl_sub.script.types.function import BuiltInFunction
|
||||||
from ytdl_sub.script.types.function import Function
|
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.map import UnresolvedMap
|
||||||
from ytdl_sub.script.types.resolvable import Argument
|
from ytdl_sub.script.types.resolvable import Argument
|
||||||
from ytdl_sub.script.types.resolvable import Boolean
|
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 Integer
|
||||||
from ytdl_sub.script.types.resolvable import Lambda
|
from ytdl_sub.script.types.resolvable import Lambda
|
||||||
from ytdl_sub.script.types.resolvable import String
|
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.types.variable import Variable
|
||||||
from ytdl_sub.script.utils.exceptions import UNREACHABLE
|
from ytdl_sub.script.utils.exceptions import UNREACHABLE
|
||||||
|
from ytdl_sub.script.utils.name_validation import is_function
|
||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
|
|
||||||
|
|
@ -30,10 +34,26 @@ class ScriptUtils:
|
||||||
sanitized_variables = {
|
sanitized_variables = {
|
||||||
f"{name}_sanitized": f"{{%sanitize({name})}}"
|
f"{name}_sanitized": f"{{%sanitize({name})}}"
|
||||||
for name in variables.keys()
|
for name in variables.keys()
|
||||||
if not _is_function(name)
|
if not is_function(name)
|
||||||
}
|
}
|
||||||
return dict(variables, **sanitized_variables)
|
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
|
@classmethod
|
||||||
def to_script(cls, value: Any, sort_keys: bool = True) -> str:
|
def to_script(cls, value: Any, sort_keys: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -67,9 +87,8 @@ class ScriptUtils:
|
||||||
ast = parse(text=value).ast
|
ast = parse(text=value).ast
|
||||||
if len(ast) == 1:
|
if len(ast) == 1:
|
||||||
return ast[0]
|
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):
|
if isinstance(value, bool):
|
||||||
return Boolean(value)
|
return Boolean(value)
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
|
|
@ -88,6 +107,19 @@ class ScriptUtils:
|
||||||
|
|
||||||
raise UNREACHABLE
|
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
|
@classmethod
|
||||||
def _to_script_code(cls, arg: Argument, top_level: bool = False) -> str:
|
def _to_script_code(cls, arg: Argument, top_level: bool = False) -> str:
|
||||||
if not top_level and isinstance(arg, (Integer, Boolean, Float)):
|
if not top_level and isinstance(arg, (Integer, Boolean, Float)):
|
||||||
|
|
@ -96,7 +128,10 @@ class ScriptUtils:
|
||||||
if isinstance(arg, String):
|
if isinstance(arg, String):
|
||||||
if arg.native == "":
|
if arg.native == "":
|
||||||
return "" if top_level else "''"
|
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):
|
if isinstance(arg, Integer):
|
||||||
out = f"%int({arg.native})"
|
out = f"%int({arg.native})"
|
||||||
|
|
@ -104,9 +139,9 @@ class ScriptUtils:
|
||||||
out = f"%bool({arg.native})"
|
out = f"%bool({arg.native})"
|
||||||
elif isinstance(arg, Float):
|
elif isinstance(arg, Float):
|
||||||
out = f"%float({arg.native})"
|
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)} ]"
|
out = f"[ {', '.join(cls._to_script_code(val) for val in arg.value)} ]"
|
||||||
elif isinstance(arg, UnresolvedMap):
|
elif isinstance(arg, (Map, UnresolvedMap)):
|
||||||
kv_list = (
|
kv_list = (
|
||||||
f"{cls._to_script_code(key)}: {cls._to_script_code(val)}"
|
f"{cls._to_script_code(key)}: {cls._to_script_code(val)}"
|
||||||
for key, val in arg.value.items()
|
for key, val in arg.value.items()
|
||||||
|
|
@ -122,11 +157,43 @@ class ScriptUtils:
|
||||||
raise UNREACHABLE
|
raise UNREACHABLE
|
||||||
return f"{{ {out} }}" if top_level else out
|
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
|
@classmethod
|
||||||
def to_native_script(cls, value: Any) -> str:
|
def to_native_script(cls, value: Any) -> str:
|
||||||
"""
|
"""
|
||||||
Converts any JSON-compatible value into equivalent script syntax
|
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)
|
return cls._to_script_code(cls._to_script_argument(value), top_level=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Set
|
from typing import Set
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from typing import final
|
from typing import final
|
||||||
|
|
||||||
from ytdl_sub.entries.script.variable_definitions import VARIABLES
|
|
||||||
from ytdl_sub.script.parser import parse
|
from ytdl_sub.script.parser import parse
|
||||||
from ytdl_sub.script.script import Script
|
from ytdl_sub.script.script import Script
|
||||||
from ytdl_sub.script.types.syntax_tree import SyntaxTree
|
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 StringValidator
|
||||||
from ytdl_sub.validators.validators import Validator
|
from ytdl_sub.validators.validators import Validator
|
||||||
|
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
|
||||||
|
|
||||||
class StringFormatterValidator(StringValidator):
|
class StringFormatterValidator(StringValidator):
|
||||||
"""
|
"""
|
||||||
|
|
@ -51,7 +53,10 @@ class StringFormatterValidator(StringValidator):
|
||||||
def __init__(self, name, value: str):
|
def __init__(self, name, value: str):
|
||||||
super().__init__(name=name, value=value)
|
super().__init__(name=name, value=value)
|
||||||
try:
|
try:
|
||||||
_ = parse(str(value))
|
self._parsed = parse(
|
||||||
|
text=str(value),
|
||||||
|
name=self.leaf_name,
|
||||||
|
)
|
||||||
except UserException as exc:
|
except UserException as exc:
|
||||||
raise self._validation_exception(exc) from exc
|
raise self._validation_exception(exc) from exc
|
||||||
|
|
||||||
|
|
@ -65,27 +70,37 @@ class StringFormatterValidator(StringValidator):
|
||||||
"""
|
"""
|
||||||
return self._value
|
return self._value
|
||||||
|
|
||||||
def post_process(self, resolved: str) -> str:
|
@property
|
||||||
|
@final
|
||||||
|
def parsed(self) -> SyntaxTree:
|
||||||
"""
|
"""
|
||||||
Returns
|
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):
|
class FloatFormatterValidator(StringFormatterValidator):
|
||||||
_expected_value_type_name = "float"
|
_expected_value_type_name = "float"
|
||||||
|
|
||||||
def post_process(self, resolved: str) -> str:
|
def post_process(self, resolved: str) -> float:
|
||||||
try:
|
try:
|
||||||
float(resolved)
|
out = float(resolved)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
f"Expected a float, but received '{resolved}'"
|
f"Expected a float, but received '{resolved}'"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return resolved
|
return out
|
||||||
|
|
||||||
|
|
||||||
class StandardizedDateValidator(StringFormatterValidator):
|
class StandardizedDateValidator(StringFormatterValidator):
|
||||||
|
|
@ -102,6 +117,13 @@ class StandardizedDateValidator(StringFormatterValidator):
|
||||||
return resolved
|
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
|
# pylint: disable=line-too-long
|
||||||
class OverridesStringFormatterValidator(StringFormatterValidator):
|
class OverridesStringFormatterValidator(StringFormatterValidator):
|
||||||
"""
|
"""
|
||||||
|
|
@ -121,15 +143,14 @@ class OverridesStringFormatterValidator(StringFormatterValidator):
|
||||||
class OverridesIntegerFormatterValidator(OverridesStringFormatterValidator):
|
class OverridesIntegerFormatterValidator(OverridesStringFormatterValidator):
|
||||||
_expected_value_type_name = "integer"
|
_expected_value_type_name = "integer"
|
||||||
|
|
||||||
def post_process(self, resolved: str) -> str:
|
def post_process(self, resolved: str) -> int:
|
||||||
try:
|
try:
|
||||||
int(resolved)
|
out = int(resolved)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
f"Expected an integer, but received '{resolved}'"
|
f"Expected an integer, but received '{resolved}'"
|
||||||
) from exc
|
) from exc
|
||||||
|
return out
|
||||||
return resolved
|
|
||||||
|
|
||||||
|
|
||||||
class OverridesFloatFormatterValidator(FloatFormatterValidator, OverridesStringFormatterValidator):
|
class OverridesFloatFormatterValidator(FloatFormatterValidator, OverridesStringFormatterValidator):
|
||||||
|
|
@ -138,9 +159,14 @@ class OverridesFloatFormatterValidator(FloatFormatterValidator, OverridesStringF
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OverridesBooleanFormatterValidator(OverridesStringFormatterValidator):
|
class OverridesBooleanFormatterValidator(
|
||||||
|
BooleanFormatterValidator, OverridesStringFormatterValidator
|
||||||
|
):
|
||||||
_expected_value_type_name = "boolean"
|
_expected_value_type_name = "boolean"
|
||||||
|
|
||||||
|
def post_process(self, resolved: Any) -> bool:
|
||||||
|
return ScriptUtils.bool_formatter_output(output=str(resolved))
|
||||||
|
|
||||||
|
|
||||||
class ListFormatterValidator(ListValidator[StringFormatterValidator]):
|
class ListFormatterValidator(ListValidator[StringFormatterValidator]):
|
||||||
_inner_list_type = StringFormatterValidator
|
_inner_list_type = StringFormatterValidator
|
||||||
|
|
@ -158,18 +184,24 @@ class DictFormatterValidator(LiteralDictValidator):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
for key in self._keys:
|
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
|
@property
|
||||||
def dict(self) -> Dict[str, StringFormatterValidator]:
|
def dict(self) -> Dict[str, StringFormatterValidator]:
|
||||||
"""Returns dict with string formatter values"""
|
"""Returns dict with string formatter values"""
|
||||||
return self._value
|
return self._validator_dict
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dict_with_format_strings(self) -> Dict[str, str]:
|
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()}
|
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):
|
class OverridesDictFormatterValidator(DictFormatterValidator):
|
||||||
"""
|
"""
|
||||||
|
|
@ -180,7 +212,22 @@ class OverridesDictFormatterValidator(DictFormatterValidator):
|
||||||
_key_validator = OverridesStringFormatterValidator
|
_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):
|
class UnstructuredDictFormatterValidator(DictFormatterValidator):
|
||||||
|
_key_validator = AnyFormatterValidator
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
# Convert the unstructured-ness into a script
|
# Convert the unstructured-ness into a script
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
|
|
@ -189,40 +236,30 @@ class UnstructuredDictFormatterValidator(DictFormatterValidator):
|
||||||
|
|
||||||
|
|
||||||
class UnstructuredOverridesDictFormatterValidator(UnstructuredDictFormatterValidator):
|
class UnstructuredOverridesDictFormatterValidator(UnstructuredDictFormatterValidator):
|
||||||
_key_validator = OverridesStringFormatterValidator
|
_key_validator = AnyOverridesFormatterValidator
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_formatter(
|
def _validate_formatter(
|
||||||
mock_script: Script,
|
mock_script: Script,
|
||||||
unresolved_variables: Set[str],
|
unresolved_variables: Set[str],
|
||||||
|
unresolved_runtime_variables: Set[str],
|
||||||
formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator],
|
formatter_validator: Union[StringFormatterValidator, OverridesStringFormatterValidator],
|
||||||
) -> None:
|
partial_resolve_entry_formatters: bool,
|
||||||
is_static_formatter = False
|
) -> Any:
|
||||||
unresolvable = unresolved_variables
|
parsed = formatter_validator.parsed
|
||||||
if isinstance(formatter_validator, OverridesStringFormatterValidator):
|
if resolved := parsed.maybe_resolvable:
|
||||||
is_static_formatter = True
|
return formatter_validator.post_process(resolved.native)
|
||||||
unresolvable = unresolved_variables.union({VARIABLES.entry_metadata.variable_name})
|
|
||||||
|
is_static_formatter = isinstance(formatter_validator, OverridesStringFormatterValidator)
|
||||||
|
|
||||||
parsed = parse(
|
|
||||||
text=formatter_validator.format_string,
|
|
||||||
)
|
|
||||||
variable_names = {var.name for var in parsed.variables}
|
variable_names = {var.name for var in parsed.variables}
|
||||||
custom_function_names = {f"%{func.name}" for func in parsed.custom_functions}
|
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):
|
if not variable_names.issubset(mock_script.variable_names):
|
||||||
raise StringFormattingVariableNotFoundException(
|
raise StringFormattingVariableNotFoundException(
|
||||||
"contains the following variables that do not exist: "
|
"contains the following variables that do not exist: "
|
||||||
|
|
@ -233,20 +270,32 @@ def _validate_formatter(
|
||||||
"contains the following custom functions that do not exist: "
|
"contains the following custom functions that do not exist: "
|
||||||
f"{', '.join(sorted(custom_function_names - mock_script.function_names))}"
|
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(
|
raise StringFormattingVariableNotFoundException(
|
||||||
"contains the following variables that are unresolved when executing this "
|
"contains the following variables that are unresolved when executing this "
|
||||||
f"formatter: {', '.join(sorted(unresolved))}"
|
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:
|
try:
|
||||||
mock_script.resolve_once(
|
if is_static_formatter:
|
||||||
{
|
return formatter_validator.post_process(
|
||||||
"tmp_var": to_variable_dependency_format_string(
|
mock_script.resolve_once_parsed(
|
||||||
script=mock_script, parsed_format_string=parsed
|
{"tmp_var": formatter_validator.parsed},
|
||||||
)
|
unresolvable=unresolved_variables,
|
||||||
},
|
update=True,
|
||||||
unresolvable=unresolvable,
|
)["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:
|
except RuntimeException as exc:
|
||||||
if isinstance(exc, ScriptVariableNotResolved) and is_static_formatter:
|
if isinstance(exc, ScriptVariableNotResolved) and is_static_formatter:
|
||||||
raise StringFormattingVariableNotFoundException(
|
raise StringFormattingVariableNotFoundException(
|
||||||
|
|
@ -259,40 +308,59 @@ def _validate_formatter(
|
||||||
def validate_formatters(
|
def validate_formatters(
|
||||||
script: Script,
|
script: Script,
|
||||||
unresolved_variables: Set[str],
|
unresolved_variables: Set[str],
|
||||||
|
unresolved_runtime_variables: Set[str],
|
||||||
validator: Validator,
|
validator: Validator,
|
||||||
) -> None:
|
partial_resolve_formatters: bool,
|
||||||
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Ensure all OverridesStringFormatterValidator's only contain variables from the overrides
|
Ensure all OverridesStringFormatterValidator's only contain variables from the overrides
|
||||||
and resolve.
|
and resolve.
|
||||||
"""
|
"""
|
||||||
|
resolved_dict: Dict = {}
|
||||||
|
|
||||||
if isinstance(validator, DictValidator):
|
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
|
# Usage of protected variables in other validators is fine. The reason to keep
|
||||||
# them protected is for readability when using them in subscriptions.
|
# them protected is for readability when using them in subscriptions.
|
||||||
for validator_value in validator._validator_dict.values():
|
for validator_value in validator._validator_dict.values():
|
||||||
validate_formatters(
|
resolved_dict[validator.leaf_name] |= validate_formatters(
|
||||||
script=script,
|
script=script,
|
||||||
unresolved_variables=unresolved_variables,
|
unresolved_variables=unresolved_variables,
|
||||||
|
unresolved_runtime_variables=unresolved_runtime_variables,
|
||||||
validator=validator_value,
|
validator=validator_value,
|
||||||
|
partial_resolve_formatters=partial_resolve_formatters,
|
||||||
)
|
)
|
||||||
# pylint: enable=protected-access
|
|
||||||
elif isinstance(validator, ListValidator):
|
elif isinstance(validator, ListValidator):
|
||||||
|
resolved_dict[validator.leaf_name] = []
|
||||||
for list_value in validator.list:
|
for list_value in validator.list:
|
||||||
validate_formatters(
|
list_output = validate_formatters(
|
||||||
script=script,
|
script=script,
|
||||||
unresolved_variables=unresolved_variables,
|
unresolved_variables=unresolved_variables,
|
||||||
|
unresolved_runtime_variables=unresolved_runtime_variables,
|
||||||
validator=list_value,
|
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)):
|
elif isinstance(validator, (StringFormatterValidator, OverridesStringFormatterValidator)):
|
||||||
_validate_formatter(
|
resolved_dict[validator.leaf_name] = _validate_formatter(
|
||||||
mock_script=script,
|
mock_script=script,
|
||||||
unresolved_variables=unresolved_variables,
|
unresolved_variables=unresolved_variables,
|
||||||
|
unresolved_runtime_variables=unresolved_runtime_variables,
|
||||||
formatter_validator=validator,
|
formatter_validator=validator,
|
||||||
|
partial_resolve_entry_formatters=partial_resolve_formatters,
|
||||||
)
|
)
|
||||||
elif isinstance(validator, (DictFormatterValidator, OverridesDictFormatterValidator)):
|
elif isinstance(validator, (DictFormatterValidator, OverridesDictFormatterValidator)):
|
||||||
|
resolved_dict[validator.leaf_name] = {}
|
||||||
for validator_value in validator.dict.values():
|
for validator_value in validator.dict.values():
|
||||||
_validate_formatter(
|
resolved_dict[validator.leaf_name] |= _validate_formatter(
|
||||||
mock_script=script,
|
mock_script=script,
|
||||||
unresolved_variables=unresolved_variables,
|
unresolved_variables=unresolved_variables,
|
||||||
|
unresolved_runtime_variables=unresolved_runtime_variables,
|
||||||
formatter_validator=validator_value,
|
formatter_validator=validator_value,
|
||||||
|
partial_resolve_entry_formatters=partial_resolve_formatters,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
resolved_dict[validator.leaf_name] = validator._value
|
||||||
|
|
||||||
|
return resolved_dict
|
||||||
|
|
|
||||||
|
|
@ -95,21 +95,11 @@ class Validator(ABC):
|
||||||
|
|
||||||
@final
|
@final
|
||||||
@property
|
@property
|
||||||
def _root_name(self) -> str:
|
def leaf_name(self) -> str:
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
"first" from the first.element.of.the.name
|
"name" 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
|
|
||||||
"""
|
"""
|
||||||
return self._name.split(".")[-1]
|
return self._name.split(".")[-1]
|
||||||
|
|
||||||
|
|
@ -274,7 +264,7 @@ class DictValidator(Validator):
|
||||||
value=self._dict.get(key, default),
|
value=self._dict.get(key, default),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.__validator_dict[validator_name] = validator_instance
|
self.__validator_dict[key] = validator_instance
|
||||||
return validator_instance
|
return validator_instance
|
||||||
|
|
||||||
@final
|
@final
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os.path
|
import os.path
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -642,6 +643,7 @@ class EnhancedDownloadArchive:
|
||||||
output_file_name: Optional[str] = None,
|
output_file_name: Optional[str] = None,
|
||||||
entry: Optional[Entry] = None,
|
entry: Optional[Entry] = None,
|
||||||
copy_file: bool = False,
|
copy_file: bool = False,
|
||||||
|
preserve_mtime: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Saves a file from the working directory to the output directory and record it in the
|
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
|
Optional. Entry that this file belongs to
|
||||||
copy_file
|
copy_file
|
||||||
Optional. If True, copy the file. Move otherwise
|
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:
|
if output_file_name is None:
|
||||||
output_file_name = file_name
|
output_file_name = file_name
|
||||||
|
|
@ -674,6 +678,22 @@ class EnhancedDownloadArchive:
|
||||||
copy_file=copy_file,
|
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
|
# Determine if it's the entry file by seeing if the file_name to move matches the entry
|
||||||
# download file name
|
# download file name
|
||||||
is_entry_file = entry and entry.get_download_file_name() == file_name
|
is_entry_file = entry and entry.get_download_file_name() == file_name
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
from typing import Union
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -116,7 +117,8 @@ def assert_logs(
|
||||||
yield
|
yield
|
||||||
|
|
||||||
for call_args in patched_debug.call_args_list:
|
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:
|
if expected_occurrences is not None:
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -165,19 +167,18 @@ def preset_dict_to_dl_args(preset_dict: Dict) -> str:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def preset_dict_to_subscription_yaml_generator() -> Callable:
|
def subscription_yaml_file_generator() -> Callable:
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict):
|
def _subscription_yaml_file_generator(yaml_dict: Dict):
|
||||||
subscription_dict = {subscription_name: preset_dict}
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
|
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:
|
try:
|
||||||
yield tmp_file.name
|
yield tmp_file.name
|
||||||
finally:
|
finally:
|
||||||
FileHandler.delete(tmp_file.name)
|
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()
|
@pytest.fixture()
|
||||||
def music_video_subscription_path() -> Path:
|
def music_video_subscription_path(
|
||||||
return Path("examples/music_video_subscriptions.yaml")
|
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()
|
@pytest.fixture()
|
||||||
|
|
@ -239,13 +246,25 @@ def tv_show_config(working_directory, tv_show_config_path) -> ConfigFile:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def tv_show_subscriptions_path() -> Path:
|
def tv_show_subscriptions_path(
|
||||||
return Path("examples/tv_show_subscriptions.yaml")
|
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()
|
@pytest.fixture()
|
||||||
def advanced_tv_show_subscriptions_path() -> Path:
|
def advanced_tv_show_subscriptions_path(
|
||||||
return Path("examples/advanced/tv_show_subscriptions.yaml")
|
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()
|
@pytest.fixture()
|
||||||
|
|
@ -265,8 +284,25 @@ def default_config_path(default_config) -> str:
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def music_subscriptions_path() -> Path:
|
def music_subscriptions_path(
|
||||||
return Path("examples/music_subscriptions.yaml")
|
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]:
|
def mock_run_from_cli(args: str) -> List[Subscription]:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import pytest
|
||||||
|
|
||||||
from ytdl_sub.cli.entrypoint import _download_subscriptions_from_yaml_files
|
from ytdl_sub.cli.entrypoint import _download_subscriptions_from_yaml_files
|
||||||
from ytdl_sub.cli.entrypoint import main
|
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.subscriptions.subscription import Subscription
|
||||||
from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled
|
from ytdl_sub.utils.exceptions import ExperimentalFeatureNotEnabled
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ def test_subscription_logs_write_to_file(
|
||||||
subscription_override_dict={},
|
subscription_override_dict={},
|
||||||
update_with_info_json=False,
|
update_with_info_json=False,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
|
shuffle=False,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
assert not mock_success_output
|
assert not mock_success_output
|
||||||
|
|
@ -120,3 +122,40 @@ def test_update_with_info_json_requires_experimental_flag(
|
||||||
pytest.raises(ExperimentalFeatureNotEnabled),
|
pytest.raises(ExperimentalFeatureNotEnabled),
|
||||||
):
|
):
|
||||||
_ = main()
|
_ = 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]
|
||||||
|
|
|
||||||
|
|
@ -236,24 +236,14 @@ class TestOutputOptions:
|
||||||
):
|
):
|
||||||
output_options_subscription_dict["output_options"]["keep_files_date_eval"] = "nope"
|
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 = (
|
expected_error_msg = (
|
||||||
"Validation error in subscription_test.output_options.keep_files_date_eval: "
|
"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'"
|
"Expected a standardized date in the form of YYYY-MM-DD, but received 'nope'"
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with pytest.raises(ValidationException, match=re.escape(expected_error_msg)):
|
||||||
mock_download_collection_entries(
|
_ = Subscription.from_dict(
|
||||||
is_youtube_channel=False,
|
config=config,
|
||||||
num_urls=1,
|
preset_name=subscription_name,
|
||||||
is_extracted_audio=False,
|
preset_dict=output_options_subscription_dict,
|
||||||
is_dry_run=True,
|
)
|
||||||
),
|
|
||||||
pytest.raises(ValidationException, match=re.escape(expected_error_msg)),
|
|
||||||
):
|
|
||||||
subscription.download(dry_run=True)
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ class TestThrottleProtectionPlugin:
|
||||||
),
|
),
|
||||||
assert_logs(
|
assert_logs(
|
||||||
logger=throttle_protection_logger,
|
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",
|
log_level="info",
|
||||||
expected_occurrences=1,
|
expected_occurrences=1,
|
||||||
),
|
),
|
||||||
|
|
@ -139,7 +139,7 @@ class TestThrottleProtectionPlugin:
|
||||||
),
|
),
|
||||||
assert_logs(
|
assert_logs(
|
||||||
logger=throttle_protection_logger,
|
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",
|
log_level="info",
|
||||||
expected_occurrences=1,
|
expected_occurrences=1,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,37 @@
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
DISABLE_YOUTUBE_TESTS: bool = True
|
DISABLE_YOUTUBE_TESTS: bool = True
|
||||||
REGENERATE_FIXTURES: bool = False
|
REGENERATE_FIXTURES: bool = False
|
||||||
|
|
||||||
RESOURCE_PATH: Path = Path("tests") / "resources"
|
RESOURCE_PATH: Path = Path("tests") / "resources"
|
||||||
_FILE_FIXTURE_PATH: Path = RESOURCE_PATH / "file_fixtures"
|
_FILE_FIXTURE_PATH: Path = RESOURCE_PATH / "file_fixtures"
|
||||||
|
_EXPECTED_JSON_PATH: Path = RESOURCE_PATH / "expected_json"
|
||||||
|
|
||||||
|
|
||||||
def file_fixture_path(fixture_name: str) -> Path:
|
def file_fixture_path(fixture_name: str) -> Path:
|
||||||
return _FILE_FIXTURE_PATH / fixture_name
|
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:
|
def copy_file_fixture(fixture_name: str, output_file_path: Path) -> None:
|
||||||
os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
|
||||||
shutil.copy(file_fixture_path(fixture_name), output_file_path)
|
shutil.copy(file_fixture_path(fixture_name), output_file_path)
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@
|
||||||
".ytdl-sub-subscription_test-download-archive.json": "19cf39d57914ba9cbd1e57ba6f1e0683",
|
".ytdl-sub-subscription_test-download-archive.json": "19cf39d57914ba9cbd1e57ba6f1e0683",
|
||||||
"JMC/Mock Entry 20-1.info.json": "INFO_JSON",
|
"JMC/Mock Entry 20-1.info.json": "INFO_JSON",
|
||||||
"JMC/Mock Entry 20-1.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
"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-1.nfo": "fefcf0b3e4f4ff80ad636584d50dadec",
|
||||||
"JMC/Mock Entry 20-2.info.json": "INFO_JSON",
|
"JMC/Mock Entry 20-2.info.json": "INFO_JSON",
|
||||||
"JMC/Mock Entry 20-2.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
"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-2.nfo": "025c0b631da5ff5470382b38fce78d2d",
|
||||||
"JMC/Mock Entry 20-3.info.json": "INFO_JSON",
|
"JMC/Mock Entry 20-3.info.json": "INFO_JSON",
|
||||||
"JMC/Mock Entry 20-3.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
"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 20-3.nfo": "618b0ff948d9de2e10cf1da8c0dd6615",
|
||||||
"JMC/Mock Entry 21-1.info.json": "INFO_JSON",
|
"JMC/Mock Entry 21-1.info.json": "INFO_JSON",
|
||||||
"JMC/Mock Entry 21-1.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
"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"
|
"JMC/Mock Entry 21-1.nfo": "e5c715749efc1603a6e2f59244d87aba"
|
||||||
}
|
}
|
||||||
242
tests/resources/expected_json/music/inspect_sub_fill.json
Normal file
242
tests/resources/expected_json/music/inspect_sub_fill.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
tests/resources/expected_json/music/inspect_sub_internal.json
Normal file
242
tests/resources/expected_json/music/inspect_sub_internal.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
tests/resources/expected_json/music/inspect_sub_original.json
Normal file
227
tests/resources/expected_json/music/inspect_sub_original.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
242
tests/resources/expected_json/music/inspect_sub_resolve.json
Normal file
242
tests/resources/expected_json/music/inspect_sub_resolve.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
314
tests/resources/expected_json/music_video/inspect_sub_fill.json
Normal file
314
tests/resources/expected_json/music_video/inspect_sub_fill.json
Normal 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 }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ) ) }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ) ) }"
|
||||||
|
}
|
||||||
|
}
|
||||||
250
tests/resources/expected_json/tv_show/inspect_sub_fill.json
Normal file
250
tests/resources/expected_json/tv_show/inspect_sub_fill.json
Normal 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 }"
|
||||||
|
}
|
||||||
|
}
|
||||||
250
tests/resources/expected_json/tv_show/inspect_sub_internal.json
Normal file
250
tests/resources/expected_json/tv_show/inspect_sub_internal.json
Normal 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 ) }"
|
||||||
|
}
|
||||||
|
}
|
||||||
255
tests/resources/expected_json/tv_show/inspect_sub_original.json
Normal file
255
tests/resources/expected_json/tv_show/inspect_sub_original.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
250
tests/resources/expected_json/tv_show/inspect_sub_resolve.json
Normal file
250
tests/resources/expected_json/tv_show/inspect_sub_resolve.json
Normal 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 ) }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import pytest
|
||||||
|
|
||||||
from ytdl_sub.config.preset import Preset
|
from ytdl_sub.config.preset import Preset
|
||||||
from ytdl_sub.plugins.nfo_tags import NfoTagsOptions
|
from ytdl_sub.plugins.nfo_tags import NfoTagsOptions
|
||||||
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
|
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -120,6 +119,26 @@ class TestPreset:
|
||||||
== "today-2months"
|
== "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(
|
@pytest.mark.parametrize(
|
||||||
"parent_preset", ["preset_self_loop", "preset_loop_0", "preset_loop_1"]
|
"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):
|
def test_preset_with_multi_url__contains_empty_url(self, config_file, output_options):
|
||||||
_ = Preset(
|
_ = Preset(
|
||||||
config=config_file,
|
config=config_file,
|
||||||
|
|
@ -392,26 +317,3 @@ class TestPreset:
|
||||||
"overrides": {name: "ack"},
|
"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"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -6,11 +5,15 @@ from typing import Dict
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
from ytdl_sub.config.config_file import ConfigFile
|
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.plugins.nfo_tags import NfoTagsOptions
|
||||||
from ytdl_sub.subscriptions.subscription import Subscription
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
from ytdl_sub.utils.exceptions import ValidationException
|
from ytdl_sub.utils.exceptions import ValidationException
|
||||||
|
from ytdl_sub.utils.script import ScriptUtils
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -472,23 +475,6 @@ def test_advanced_tv_show_subscriptions(
|
||||||
overrides = subs[5].overrides
|
overrides = subs[5].overrides
|
||||||
|
|
||||||
assert overrides.script.get("subscription_name").native == "Gardening with Ciscoe"
|
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):
|
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("subscription_indent_1").native == "Pop"
|
||||||
assert (
|
assert (
|
||||||
jackson.get("url").native
|
jackson.get("urls").native[0]
|
||||||
== "https://www.youtube.com/playlist?list=OLAK5uy_mnY03zP6abNWH929q2XhGzWD_2uKJ_n8E"
|
== "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
|
gnr = subs[3].overrides.script
|
||||||
|
|
||||||
assert gnr.get("subscription_name").native == "Guns N' Roses"
|
assert gnr.get("subscription_name").native == "Guns N' Roses"
|
||||||
assert (
|
gnr_urls = gnr.get("urls").native
|
||||||
gnr.get("url").native
|
assert gnr_urls[0] == "https://www.youtube.com/playlist?list=PLOTK54q5K4INNXaHKtmXYr6J7CajWjqeJ"
|
||||||
== "https://www.youtube.com/playlist?list=PLOTK54q5K4INNXaHKtmXYr6J7CajWjqeJ"
|
|
||||||
)
|
|
||||||
assert gnr.get("subscription_indent_1").native == "Rock"
|
assert gnr.get("subscription_indent_1").native == "Rock"
|
||||||
assert gnr.get("url2").native == "https://www.youtube.com/watch?v=OldpIhHPsbs"
|
assert gnr_urls[1] == "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
|
|
||||||
|
|
|
||||||
92
tests/unit/config/test_subscription_resolution.py
Normal file
92
tests/unit/config/test_subscription_resolution.py
Normal 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,
|
||||||
|
)
|
||||||
172
tests/unit/config/test_subscription_validation.py
Normal file
172
tests/unit/config/test_subscription_validation.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
|
from tools.docgen.configuration import ConfigurationDocGen
|
||||||
from tools.docgen.docgen import DocGen
|
from tools.docgen.docgen import DocGen
|
||||||
from tools.docgen.entry_variables import EntryVariablesDocGen
|
from tools.docgen.entry_variables import EntryVariablesDocGen
|
||||||
from tools.docgen.plugins import PluginsDocGen
|
from tools.docgen.plugins import PluginsDocGen
|
||||||
|
|
@ -28,3 +29,6 @@ class TestDocGen:
|
||||||
|
|
||||||
def test_plugins_generated(self):
|
def test_plugins_generated(self):
|
||||||
_test_doc_gen(PluginsDocGen)
|
_test_doc_gen(PluginsDocGen)
|
||||||
|
|
||||||
|
def test_configuration_generated(self):
|
||||||
|
_test_doc_gen(ConfigurationDocGen)
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ def mock_entry_to_dict(
|
||||||
"epoch": 1596878400,
|
"epoch": 1596878400,
|
||||||
"epoch_date": "20200808",
|
"epoch_date": "20200808",
|
||||||
"epoch_hour": "09",
|
"epoch_hour": "09",
|
||||||
"title": "entry {title}",
|
"title": "entry {title}",
|
||||||
"title_sanitized": "entry {title}",
|
"title_sanitized": "entry {title}",
|
||||||
"ext": ext,
|
"ext": ext,
|
||||||
"description": "",
|
"description": "",
|
||||||
"creator": "abc123",
|
"creator": "abc123",
|
||||||
|
|
@ -113,8 +113,8 @@ def mock_entry_to_dict(
|
||||||
"playlist_count": 1,
|
"playlist_count": 1,
|
||||||
"playlist_max_upload_year": 2021,
|
"playlist_max_upload_year": 2021,
|
||||||
"playlist_max_upload_year_truncated": 21,
|
"playlist_max_upload_year_truncated": 21,
|
||||||
"playlist_title": "entry {title}",
|
"playlist_title": "entry {title}",
|
||||||
"playlist_title_sanitized": "entry {title}",
|
"playlist_title_sanitized": "entry {title}",
|
||||||
"playlist_description": "",
|
"playlist_description": "",
|
||||||
"playlist_webpage_url": "https://yourname.here",
|
"playlist_webpage_url": "https://yourname.here",
|
||||||
"playlist_uid": "abc123",
|
"playlist_uid": "abc123",
|
||||||
|
|
@ -126,15 +126,15 @@ def mock_entry_to_dict(
|
||||||
"source_description": "",
|
"source_description": "",
|
||||||
"source_index": 1,
|
"source_index": 1,
|
||||||
"source_index_padded": "01",
|
"source_index_padded": "01",
|
||||||
"source_title": "entry {title}",
|
"source_title": "entry {title}",
|
||||||
"source_title_sanitized": "entry {title}",
|
"source_title_sanitized": "entry {title}",
|
||||||
"source_webpage_url": "https://yourname.here",
|
"source_webpage_url": "https://yourname.here",
|
||||||
"source_uid": "abc123",
|
"source_uid": "abc123",
|
||||||
"source_uploader": "abc123",
|
"source_uploader": "abc123",
|
||||||
"source_uploader_id": "abc123",
|
"source_uploader_id": "abc123",
|
||||||
"source_uploader_url": "https://yourname.here",
|
"source_uploader_url": "https://yourname.here",
|
||||||
"uid_sanitized_plex": "abc123",
|
"uid_sanitized_plex": "abc123",
|
||||||
"title_sanitized_plex": "entry {title}",
|
"title_sanitized_plex": "entry {title}",
|
||||||
"release_date": upload_date,
|
"release_date": upload_date,
|
||||||
"release_date_standardized": "2021-01-12",
|
"release_date_standardized": "2021-01-12",
|
||||||
"release_year": 2021,
|
"release_year": 2021,
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,9 @@ def test_no_positional_arg_command(mock_sys_exit, tv_show_config_path):
|
||||||
main()
|
main()
|
||||||
|
|
||||||
assert mock_error.call_count == 1
|
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):
|
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. "
|
"The config file 'does_not_exist.yaml' could not be found. "
|
||||||
"Did you set --config correctly?"
|
"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")
|
||||||
|
|
|
||||||
111
tests/unit/plugins/test_ytdl_options.py
Normal file
111
tests/unit/plugins/test_ytdl_options.py
Normal 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
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
|
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
|
||||||
from ytdl_sub.subscriptions.subscription import Subscription
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
|
|
@ -24,3 +25,68 @@ class TestTvShowByDatePreset:
|
||||||
"overrides": {"tv_show_directory": "abc", "s01_url": "test"},
|
"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
Loading…
Reference in a new issue