Merge branch 'master' into j/music-extras

This commit is contained in:
Jesse Bannon 2024-06-02 22:50:34 -07:00
commit d2e915f089
204 changed files with 4204 additions and 1251 deletions

View file

@ -22,7 +22,8 @@ check_lint:
&& black . --check \ && black . --check \
&& pylint src/ && pylint src/
wheel: clean wheel: clean
$(shell echo "__pypi_version__ = \"$(PYPI_VERSION)\"\n__local_version__ = \"$(LOCAL_VERSION)\"" > src/ytdl_sub/__init__.py) $(shell echo "__pypi_version__ = \"$(PYPI_VERSION)\"" > src/ytdl_sub/__init__.py)
$(shell echo "__local_version__ = \"$(LOCAL_VERSION)\"" >> src/ytdl_sub/__init__.py)
cat src/ytdl_sub/__init__.py cat src/ytdl_sub/__init__.py
pip3 install build pip3 install build
python3 -m build python3 -m build
@ -39,6 +40,7 @@ executable: clean
pyinstaller ytdl-sub.spec pyinstaller ytdl-sub.spec
mv dist/ytdl-sub dist/ytdl-sub${EXEC_SUFFIX} mv dist/ytdl-sub dist/ytdl-sub${EXEC_SUFFIX}
docs: docs:
REGENERATE_DOCS=1 pytest tests/unit/docgen/test_docgen.py
sphinx-build -M html docs/source/ docs/build/ sphinx-build -M html docs/source/ docs/build/
clean: clean:
rm -rf \ rm -rf \

View file

@ -71,13 +71,8 @@ __preset__:
cookiefile: "/config/cookie.txt" cookiefile: "/config/cookie.txt"
################################################################### ###################################################################
# Subscriptions nested under this will use the # TV Show Presets. Can replace Plex with Plex/Jellyfin/Kodi
# `Plex TV Show by Date` preset.
#
# Can choose between:
# - Plex TV Show by Date:
# - Jellyfin TV Show by Date:
# - Kodi TV Show by Date:
Plex TV Show by Date: Plex TV Show by Date:
# Sets genre tag to "Documentaries" # Sets genre tag to "Documentaries"
@ -101,9 +96,18 @@ Plex TV Show by Date:
= News | Only Recent: = News | Only Recent:
"BBC News": "https://www.youtube.com/@BBCNews" "BBC News": "https://www.youtube.com/@BBCNews"
Plex TV Show Collection:
= Music:
# Prefix with ~ to set specific override variables
"~Beyond the Guitar":
s01_name: "Videos"
s01_url: "https://www.youtube.com/c/BeyondTheGuitar"
s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
################################################################### ###################################################################
# Subscriptions nested under these will use the various prebuilt # Music Presets. Can replace Plex with Plex/Jellyfin/Kodi
# music presets
YouTube Releases: YouTube Releases:
= Jazz: # Sets genre tag to "Jazz" = Jazz: # Sets genre tag to "Jazz"
"Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases" "Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases"
@ -124,10 +128,7 @@ Bandcamp:
"Emily Hopkins": "https://emilyharpist.bandcamp.com/" "Emily Hopkins": "https://emilyharpist.bandcamp.com/"
################################################################### ###################################################################
# Can choose between: # Music Video Presets
# - Plex Music Videos:
# - Jellyfin Music Videos:
# - Kodi Music Videos:
"Plex Music Videos": "Plex Music Videos":
= Pop: # Sets genre tag to "Pop" = Pop: # Sets genre tag to "Pop"
"Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc" "Rick Astley": "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
@ -182,7 +183,7 @@ music_videos/
## Custom Configs ## Custom Configs
Any part of this process is modifiable by using custom configs. See our Any part of this process is modifiable by using custom configs. See our
[walk-through guide](https://github.com/jmbannon/ytdl-sub/wiki) [walk-through guide](https://ytdl-sub.readthedocs.io/en/latest/guides/index.html)
on how to build your first config from scratch. Ready-to-use on how to build your first config from scratch. Ready-to-use
[example configurations](https://github.com/jmbannon/ytdl-sub/tree/master/examples) [example configurations](https://github.com/jmbannon/ytdl-sub/tree/master/examples)
can be found here alongside our can be found here alongside our

View file

@ -1,8 +1,8 @@
# Bare-bones config. Here are some useful links to get started: # Bare-bones config. Here are some useful links to get started:
# Walk-through Guide: https://github.com/jmbannon/ytdl-sub/wiki/1.-Introduction # Walk-through Guide: https://ytdl-sub.readthedocs.io/en/latest/guides/index.html
# Config Examples: https://github.com/jmbannon/ytdl-sub/tree/master/examples # Config Examples: https://github.com/jmbannon/ytdl-sub/tree/master/examples
# Prebuilt Presets: https://ytdl-sub.readthedocs.io/en/latest/presets.html # Prebuilt Presets: https://ytdl-sub.readthedocs.io/en/latest/presets.html
# Config Docs: https://ytdl-sub.readthedocs.io/en/latest/config.html # Config Reference: https://ytdl-sub.readthedocs.io/en/latest/config_reference/index.html
# #
# The subscriptions in `subscriptions.yaml` uses prebuilt presets which do not require # The subscriptions in `subscriptions.yaml` uses prebuilt presets which do not require
# any additions to this config. They can be downloaded using the command: # any additions to this config. They can be downloaded using the command:

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View file

@ -7,9 +7,9 @@
# 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 = "2023, Jesse Bannon" copyright = "2024, Jesse Bannon"
author = "Jesse Bannon" author = "Jesse Bannon"
release = "2023.12.15" release = ""
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -47,7 +47,7 @@ html_theme_options = {
"type": "url", "type": "url",
}, },
], ],
"announcement": ("Please excuse our mess as we update these documents"), "announcement": "",
"navigation_depth": 10, "navigation_depth": 10,
"show_toc_level": 10, "show_toc_level": 10,
} }

View file

@ -156,7 +156,7 @@ granularity possible.
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
:description: :description:
Only download videos before this datetime. Only download videos after this datetime.
``before`` ``before``
@ -231,6 +231,8 @@ URL.
variables: variables:
season_index: "2" season_index: "2"
season_name: "Playlist as Season" season_name: "Playlist as Season"
ytdl_options:
break_on_existing: False
playlist_thumbnails: playlist_thumbnails:
- name: "season{season_index}-poster.jpg" - name: "season{season_index}-poster.jpg"
uid: "latest_entry" uid: "latest_entry"
@ -402,6 +404,9 @@ It supports basic tags like ``title``, ``album``, ``artist`` and ``albumartist``
a full list of tags for various file types in MediaFile's a full list of tags for various file types in MediaFile's
`source code <https://github.com/beetbox/mediafile/blob/v0.9.0/mediafile.py#L1770>`_. `source code <https://github.com/beetbox/mediafile/blob/v0.9.0/mediafile.py#L1770>`_.
Note that the date fields ``date`` and ``original_date`` expected a standardized date in the
form of YYYY-MM-DD. The variable ``upload_date_standardized`` returns a compatible format.
:Usage: :Usage:
.. code-block:: yaml .. code-block:: yaml
@ -418,6 +423,7 @@ a full list of tags for various file types in MediaFile's
albumartists: albumartists:
- "{artist}" - "{artist}"
- "ytdl-sub" - "ytdl-sub"
date: "{upload_date_standardized}"
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------

View file

@ -0,0 +1,15 @@
=======================
Common
=======================
.. highlight:: yaml
Media Quality
-------------
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/media_quality.yaml
Only Recent Videos
------------------
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/download_deletion_options.yaml

View file

@ -1,7 +0,0 @@
=======================
Common Preset Reference
=======================
.. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/common.yaml

View file

@ -1,7 +0,0 @@
========================
Players Preset Reference
========================
.. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/players.yaml

View file

@ -1,7 +0,0 @@
====================
URL Preset Reference
====================
.. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/helpers/url.yaml

View file

@ -7,6 +7,6 @@ This section contains the code for the prebuilt presets. If you just want to und
.. toctree:: .. toctree::
helpers_common common
helpers_players tv_show
helpers_url music

View file

@ -0,0 +1,9 @@
=====
Music
=====
All audio music based presets inherit from ``_music_base``.
.. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/music/singles.yaml

View file

@ -0,0 +1,9 @@
========================
TV Show
========================
All TV show based presets inherit from ``_episode_base``.
.. highlight:: yaml
.. literalinclude:: /../../src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml

View file

@ -647,3 +647,15 @@ ytdl_sub_input_url
:type: ``String`` :type: ``String``
:description: :description:
The input URL used in ytdl-sub to create this entry. The input URL used in ytdl-sub to create this entry.
ytdl_sub_input_url_count
~~~~~~~~~~~~~~~~~~~~~~~~
:type: ``Integer``
:description:
The total number of input URLs as defined in the subscription.
ytdl_sub_input_url_index
~~~~~~~~~~~~~~~~~~~~~~~~
:type: ``Integer``
:description:
The index of the input URL as defined in the subscription, top-most being the 0th index.

View file

@ -172,6 +172,41 @@ gte
:description: :description:
``>=`` operator. Returns True if left >= right. False otherwise. ``>=`` operator. Returns True if left >= right. False otherwise.
is_array
~~~~~~~~
:spec: ``is_array(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is a Map. False otherwise.
is_bool
~~~~~~~
:spec: ``is_bool(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is a Float. False otherwise.
is_float
~~~~~~~~
:spec: ``is_float(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is a Float. False otherwise.
is_int
~~~~~~
:spec: ``is_int(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is an Integer. False otherwise.
is_map
~~~~~~
:spec: ``is_map(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is a Map. False otherwise.
is_null is_null
~~~~~~~ ~~~~~~~
:spec: ``is_null(value: AnyArgument) -> Boolean`` :spec: ``is_null(value: AnyArgument) -> Boolean``
@ -179,6 +214,20 @@ is_null
:description: :description:
Returns True if a value is null (i.e. an empty string). False otherwise. Returns True if a value is null (i.e. an empty string). False otherwise.
is_numeric
~~~~~~~~~~
:spec: ``is_numeric(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is either an Integer or Float. False otherwise.
is_string
~~~~~~~~~
:spec: ``is_string(value: AnyArgument) -> Boolean``
:description:
Returns True if a value is a String. False otherwise.
lt lt
~~ ~~
:spec: ``lt(left: AnyArgument, right: AnyArgument) -> Boolean`` :spec: ``lt(left: AnyArgument, right: AnyArgument) -> Boolean``
@ -366,6 +415,14 @@ map_enumerate
Apply a lambda function on the Map, where each arg Apply a lambda function on the Map, where each arg
passed to the lambda function is ``idx, key, value`` as three separate args. passed to the lambda function is ``idx, key, value`` as three separate args.
map_extend
~~~~~~~~~~
:spec: ``map_extend(maps: Map, ...) -> Map``
:description:
Return maps combined in the order from left-to-right. Duplicate keys will use the
right-most map's value.
map_get map_get
~~~~~~~ ~~~~~~~
:spec: ``map_get(mapping: Map, key: AnyArgument, default: Optional[AnyArgument]) -> AnyArgument`` :spec: ``map_get(mapping: Map, key: AnyArgument, default: Optional[AnyArgument]) -> AnyArgument``
@ -503,6 +560,15 @@ regex_search
the string as the first element of the Array. If there are capture groups, returns each the string as the first element of the Array. If there are capture groups, returns each
group as a subsequent element in the Array. group as a subsequent element in the Array.
regex_sub
~~~~~~~~~
:spec: ``regex_sub(regex: String, replacement: String, string: String) -> String``
:description:
Returns the string obtained by replacing the leftmost non-overlapping occurrences of the
pattern in string by the replacement string. The replacement string can reference the
match groups via backslash escapes. Callables as replacement argument are not supported.
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
String Functions String Functions
@ -650,7 +716,7 @@ to_native_filepath
:spec: ``to_native_filepath(filepath: String) -> String`` :spec: ``to_native_filepath(filepath: String) -> String``
Convert any unix-based path separators ('/') with the OS's native Convert any unix-based path separators ('/') with the OS's native
separator. separator. In addition, expand ~ to absolute directories.
truncate_filepath_if_too_long truncate_filepath_if_too_long
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -88,6 +88,12 @@ triple-quotes can be used to avoid *closing* the String.
%string("""This has both " and ' in it.""") %string("""This has both " and ' in it.""")
} }
If you want a plain string that contains literal curly braces, you can escape them like so:
.. code-block:: yaml
string_variable: "This contains \\{ literal curly braces \\}"
Integer Integer
~~~~~~~ ~~~~~~~

View file

@ -5,6 +5,11 @@ Static Variables
Subscription Variables Subscription Variables
---------------------- ----------------------
subscription_has_download_archive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Returns True if the subscription has any entries recorded in a download archive. False
otherwise.
subscription_indent_i subscription_indent_i
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~
For subscriptions in the form of For subscriptions in the form of

View file

@ -19,9 +19,7 @@ Below is an example that downloads a YouTube playlist:
presets: presets:
playlist_preset_ex: playlist_preset_ex:
download: download: "{url}"
download_strategy: "url"
url: "{url}"
output_options: output_options:
output_directory: "{output_directory}/{playlist_name}" output_directory: "{output_directory}/{playlist_name}"
file_name: "{playlist_name}.{title}.{ext}" file_name: "{playlist_name}.{title}.{ext}"

View file

@ -10,6 +10,18 @@ Since ytdl-sub is relatively new to the public, there has not been many question
How do I... How do I...
----------- -----------
...remove the date in the video title?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The :ref:`config_reference/prebuilt_presets/tv_show:TV Show` presets by default include the upload date in the ``episode_title``
override variable. This variable is used to set the title in things like the video metadata, NFO file, etc, which is
subsequently read by media players. This can be overwritten as you see fit by redefining it:
.. code-block:: yaml
overrides:
episode_title: "{title}" # Only sets the video title
...get support or reach out to contribute? ...get support or reach out to contribute?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -71,17 +83,18 @@ Most likely the video has a non-English language set to its 'native' language. Y
...Plex is not showing my TV shows correctly ...Plex is not showing my TV shows correctly
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Set the following for your ytdl-sub library that has been added to Plex. 1. Set the following for your ytdl-sub library that has been added to Plex.
.. figure:: ../../images/plex_scanner_agent.png .. figure:: ../../images/plex_scanner_agent.png
:alt: The Plex library editor, under the advanced settings, showing the required options for Plex to show the TV shows correctly. :alt: The Plex library editor, under the advanced settings, showing the required options for Plex to show the TV shows correctly.
**Scanner:** Plex Series Scanner - **Scanner:** Plex Series Scanner
- **Agent:** Personal Media shows
- **Visibility:** Exclude from home screen and global search
- **Episode sorting:** Library default
- **YES** Enable video preview thumbnails
**Agent:** Personal Media shows 2. Under **Settings** > **Agents**, confirm Plex Personal Media Shows/Movies scanner has **Local Media Assets** enabled.
**Visibility:** Exclude from home screen and global search .. figure:: ../../images/plex_agent_sources.png
:alt: The Plex Agents settings page has Local Media Assets enabled for Personal Media Shows and Movies tabs.
**Episode sorting:** Library default
**YES** Enable video preview thumbnails

View file

@ -16,6 +16,41 @@ The layout of the ``config.yaml`` file is relatively straightforward:
plugin1: plugin1:
plugin1_option1: value1 plugin1_option1: value1
This creates a preset named ``preset_name``, which contains the made-up
:doc:`plugin </config_reference/plugins>` ``plugin1``. Under each plugin are its settings.
A preset can contain multiple plugins.
Preset Inheritance Preset Inheritance
------------------ ------------------
You can modularize your presets via preset inheritance. For example,
.. code-block:: yaml
presets:
TV Show:
presets:
- "Jellyfin TV Show by Date"
overrides:
tv_show_directory: "/ytdl_sub_tv_shows"
TV Show Only Recent:
presets:
- "TV Show"
- "Only Recent"
overrides:
only_recent_date_range: "3weeks"
This creates two presets:
* ``TV Show``
* Inherits the :doc:`prebuilt </prebuilt_presets/index>` ``Jellyfin TV Show by Date`` preset
* Sets the output tv show directory
* ``TV Show Only Recent``
* Inherits the ``TV Show`` preset made above it and the ``Only Recent`` prebuilt preset
* Sets only_recent preset to only keep the last 3 weeks worth of videos
Inheritance makes it easy to extend existing presets to include logic for your specific needs.

View file

@ -9,6 +9,14 @@ Your first configuration will look pretty simple:
configuration: configuration:
working_directory: '.ytdl-sub-downloads' working_directory: '.ytdl-sub-downloads'
presets:
TV Show:
preset:
- "Jellyfin TV Show by Date"
- "Only Recent"
overrides:
tv_show_directory: "/ytdl_sub_tv_shows"
The first two lines in this ``config.yaml`` file are the ``configuration``, and define the ``working_directory``, which is described near the bottom of :ref:`this section <guides/getting_started/index:quick overview of \`\`ytdl-sub\`\`>` The first two lines in this ``config.yaml`` file are the ``configuration``, and define the ``working_directory``, which is described near the bottom of :ref:`this section <guides/getting_started/index:quick overview of \`\`ytdl-sub\`\`>`

View file

@ -1,7 +1,9 @@
Initial Download Initial Download
================ ================
Once you have the ``config.yaml`` and ``subscriptions.yaml`` files created and filled out, you can perform your first download. Access ``ytdl-sub``, navigate to the directory containing your ``config.yaml`` and ``subscriptions.yaml`` files, then run the below command: Once you have a ``subscriptions.yaml`` file created and filled out, you can perform your first
download. Access ``ytdl-sub``, navigate to the directory containing your ``subscriptions.yaml``
file, then run the below command:
.. tab-set:: .. tab-set::
@ -27,4 +29,4 @@ Once you have the ``config.yaml`` and ``subscriptions.yaml`` files created and f
.. code-block:: shell .. code-block:: shell
ytdl-sub dl --preset "My Favorite YouTube Channels" --overrides.subscription_name "Rick Astley" --overrides.subscription_value "https://www.youtube.com/@RickAstleyYT/videos" ytdl-sub dl --preset "Jellyfin TV Show by Date" --overrides.subscription_name "NOVA PBS" --overrides.subscription_value "https://www.youtube.com/@novapbs" --overrides.tv_show_genre "Documentaries"

View file

@ -1,7 +1,7 @@
Initial Subscription Initial Subscription
==================== ====================
Your first subscription should look similar to the below: Your first subscription should look something like this:
.. code-block:: yaml .. code-block:: yaml
:linenos: :linenos:
@ -9,13 +9,124 @@ Your first subscription should look similar to the below:
__preset__: __preset__:
overrides: overrides:
tv_show_directory: "/tv_shows" tv_show_directory: "/tv_shows"
music_directory: "/music"
"My Favorite YouTube Channels": # Can choose between:
"Rick Astley": "https://www.youtube.com/@RickAstleyYT/videos" # - Plex TV Show by Date:
# - Jellyfin TV Show by Date:
# - Kodi TV Show by Date:
#
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
YouTube Releases:
= Jazz: # Sets genre tag to "Jazz"
"Thelonious Monk": "https://www.youtube.com/@theloniousmonk3870/releases"
YouTube Full Albums:
= Lofi:
"Game Chops": "https://www.youtube.com/playlist?list=PLBsm_SagFMmdWnCnrNtLjA9kzfrRkto4i"
Lets break this down:
.. code-block:: yaml
:lineno-start: 1
__preset__:
overrides:
tv_show_directory: "/tv_shows"
music_directory: "/music"
The first three lines in this subscription file define where to save the downloaded files associated with all subscriptions in this file. The first :ref:`__preset__ <config_reference/subscriptions_yaml:File Preset>` section is where we
can set modifications that apply to every subscription in this file.
The fifth line in this subscription file is the ``preset``, which provides the "definitions" for the subscription as listed in :doc:`/guides/getting_started/first_config`. -------------------------------------
The sixth line is the actual ``subscription``, named ``Rick Astley``, with a link to a :yt-dlp:`yt-dlp supported site <blob/master/supportedsites.md>`, in this case a YouTube channel. .. code-block:: yaml
:lineno-start: 6
# Can choose between:
# - Plex TV Show by Date:
# - Jellyfin TV Show by Date:
# - Kodi TV Show by Date:
#
Lines 6-10 are comments that get ignored when parsing YAML since they are prefixed with ``#``.
It is good practice to leave informative comments in your config or subscription files to remind
yourself of various things.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
On line 11, we set the key to ``Jellyfin TV Show by Date``. This is a
:ref:`prebuilt preset <prebuilt_presets/index:prebuilt presets>` that configures
subscriptions to look like TV shows in the Jellyfin media player (can be changed to
one of the presets outlined in the comment above). Setting it as a YAML key implies that all
subscriptions underneath it will *inherit* this preset.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
Line 12 sets the key to ``= Documentaries``. When keys are prefixed with ``=``, it means we are
setting the
:ref:`subscription indent variable <config_reference/subscriptions_yaml:Beautifying Subscriptions>`.
For TV Show presets, the first subscription indent variable maps to the TV show's genre.
Setting subscription indent variables as a key implies all subscriptions underneath it will
have this variable set.
To better understand what variables are used in prebuilt presets, refer to the
:ref:`prebuilt preset reference <config_reference/prebuilt_presets/index:Prebuilt Preset Reference>`.
Here you will see the underlying variables used in prebuilt presets that can be overwritten.
We already overwrote a few of the variables in the ``__preset__`` section above to define our
output directory.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
Line 13 is where we define our first subscription. We set the subscription name to ``NOVA PBS``,
and the subscription value to ``https://www.youtube.com/@novapbs``. Referring to the
:ref:`TV show preset reference <config_reference/prebuilt_presets/tv_show:TV Show>`,
we can see that ``{subscription_name}`` is used to set the ``tv_show_name`` variable.
-------------------------------------
.. code-block:: yaml
:lineno-start: 11
Jellyfin TV Show by Date:
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
Line 15 underneath ``Jellyfin TV Show by Date``, but at the same level as ``= Documentaries``.
This means we'll inherit the TV show preset, but not the documentaries indent variable. We instead
set the indent variables to ``= Kids | = TV-Y``. This sets two indent variables. We can set
multiple presets and/or indent variables on the same key by using ``|`` as a separator.
Referring to the
:ref:`TV show preset reference <config_reference/prebuilt_presets/tv_show:TV Show>`, the first
two indent variables map to the TV show genre and TV show content rating.
The above info should be enough to understand the rest of the subscription file.

View file

@ -29,28 +29,44 @@ If you plan on using the headless image of ``ytdl-sub``, you:
Additional useful (but not required) knowledge: Additional useful (but not required) knowledge:
☑ Understanding how :yt-dlp:`\ ` works ☑ Understanding how :yt-dlp:`\ ` works
Overview
Quick Overview of ``ytdl-sub`` --------
------------------------------
``ytdl-sub`` uses two types of YAML files: ``ytdl-sub`` uses two types of YAML files:
- ``config.yaml`` defines ``presets``, which are the "definitions" of your media. ``presets`` "define" how you want your media downloaded, which formats, naming conventions to follow when saving them, etc. These ``presets`` can also inherit other ``presets``, so that you can easily modify an existing ``preset``. subscriptions.yaml
- ``subscriptions.yaml`` defines ``subscriptions``, which specify the media we want to recurrently download, like YouTube channels and playlists, SoundCloud artists, or any :yt-dlp:`yt-dlp supported site <blob/master/supportedsites.md>`. ``subscriptions`` use ``presets`` to define how ``ytdl-sub`` should handle downloading, processing, and saving them. ~~~~~~~~~~~~~~~~~~
Defines ``subscriptions``, which specify the media we want to recurrently download, like YouTube
channels and playlists, SoundCloud artists, or any
:yt-dlp:`yt-dlp supported site <blob/master/supportedsites.md>`. ``subscriptions`` use ``presets``
to define how ``ytdl-sub`` should handle downloading, processing, and saving them.
When ``ytdl-sub`` is run, in its most basic form: ``ytdl-sub`` comes packaged with many
:ref:`prebuilt presets <prebuilt_presets/index:Prebuilt Presets>`
that will play nicely with well-known media players.
config.yaml
~~~~~~~~~~~
To customize ``ytdl-sub`` to beyond the prebuilt presets, you will need a ``config.yaml`` file. This
file is where custom ``presets`` can be defined to orchestrate ``ytdl-sub`` to your very specific needs.
Running ytdl-sub
~~~~~~~~~~~~~~~~
To invoke ``ytdl-sub`` to download subscriptions, use the following command:
.. tab-set-code:: .. tab-set-code::
.. code-block:: shell .. code-block:: shell
ytdl-sub sub ytdl-sub sub subscriptions.yaml
.. code-block:: powershell .. code-block:: powershell
ytdl-sub.exe sub ytdl-sub.exe sub subscriptions.yaml
``ytdl-sub`` initially downloads all files to a defined ``working_directory``. This is a temporary storage spot for metadata and media files so that errors during processing- if they occur- don't affect your existing media library. Once all file processing is complete, your media files are moved to the ``output_directory``. ``ytdl-sub`` initially downloads all files to a defined ``working_directory``. This is a temporary
storage spot for metadata and media files so that errors during processing- if they occur- don't
affect your existing media library. Once all file processing is complete, your media files are
moved to the ``output_directory``.
Ready to Start? Ready to Start?
--------------- ---------------

View file

@ -11,17 +11,17 @@ The following actions are taken based on the indicated player:
Jellyfin Jellyfin
~~~~~~~~ --------
* Places any season-specific poster art in the main show folder * Places any season-specific poster art in the main show folder
* Generates NFO tags * Generates NFO tags
Kodi Kodi
~~~~ --------
* Everything that the Jellyfin version does * Everything that the Jellyfin version does
* Enables ``kodi_safe`` NFOs, replacing 4-byte unicode characters that break kodi with ```` * Enables ``kodi_safe`` NFOs, replacing 4-byte unicode characters that break kodi with ````
Plex Plex
~~~~ --------
* :ref:`Special sanitization <config_reference/scripting/entry_variables:title_sanitized_plex>` of numbers so Plex doesn't recognize numbers that are part of the title as the episode number * :ref:`Special sanitization <config_reference/scripting/entry_variables:title_sanitized_plex>` of numbers so Plex doesn't recognize numbers that are part of the title as the episode number
* Converts all downloaded videos to the mp4 format * Converts all downloaded videos to the mp4 format
* Places any season-specific poster art into the season folder * Places any season-specific poster art into the season folder
@ -38,17 +38,39 @@ TV Show by Date
TV Show by Date will organize something like a YouTube channel or playlist into a tv show, where seasons and episodes are organized using upload date. TV Show by Date will organize something like a YouTube channel or playlist into a tv show, where seasons and episodes are organized using upload date.
Plug and Play Presets Example
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~
Must define ``tv_show_directory``. Available presets:
You can use any of these presets in your ``subscriptions.yaml`` as a "One size fits all" solution- they should set all appropriate values. These will organize seasons by year and episodes by month, then day.
Must define ``tv_show_directory``
* ``"Kodi TV Show by Date"`` * ``"Kodi TV Show by Date"``
* ``"Jellyfin TV Show by Date"`` * ``"Jellyfin TV Show by Date"``
* ``"Plex TV Show by Date"`` * ``"Plex TV Show by Date"``
.. code-block:: yaml
__preset__:
overrides:
tv_show_directory: "/tv_shows"
Plex TV Show by Date:
# Sets genre tag to "Documentaries"
= Documentaries:
"NOVA PBS": "https://www.youtube.com/@novapbs"
"National Geographic": "https://www.youtube.com/@NatGeo"
"Cosmos - What If": "https://www.youtube.com/playlist?list=PLZdXRHYAVxTJno6oFF9nLGuwXNGYHmE8U"
# Sets genre tag to "Kids", "TV-Y" for content rating
= Kids | = TV-Y:
"Jake Trains": "https://www.youtube.com/@JakeTrains"
"Kids Toys Play": "https://www.youtube.com/@KidsToysPlayChannel"
= Music:
# TV show subscriptions can support multiple urls and store in the same TV Show
"Rick Beato":
- "https://www.youtube.com/@RickBeato"
- "https://www.youtube.com/@rickbeato240"
Advanced Usage Advanced Usage
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
@ -71,43 +93,13 @@ And then add one of these:
* Episodes are numbered by the download order. NOTE that this is fetched using the length of the download archive. Do not use if you intend to remove old videos. * Episodes are numbered by the download order. NOTE that this is fetched using the length of the download archive. Do not use if you intend to remove old videos.
An example of a subscription that will be played on Kodi, organized by year with the most recent episode at the top (having a lower episode number), with a genre of "Pop":
.. code-block:: yaml
:caption: subscriptions.yaml
__preset__:
overrides:
tv_show_directory: "/tv_shows"
kodi_tv_show_by_date:
season_by_year_episode_by_month_day_reversed:
= Pop:
"Rick A": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
You can also choose to combine multiple URLs into one show. This will result in your videos being downloaded to the same folder, and the episode numbers being shared between them (so you won't have two episode 10's, for example). Note that you may :ytdl-sub-gh:`experience issues <issues/833>` if you use more than 20 URLs at this time.
.. code-block:: yaml
:caption: subscriptions.yaml
__preset__:
overrides:
tv_show_directory: "/tv_shows"
kodi_tv_show_by_date:
season_by_year_episode_by_month_day_reversed:
= Pop:
"~Rick A":
url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
url2: "https://www.youtube.com/@just.rick_6"
TV Show Collection TV Show Collection
------------------ ------------------
TV Show Collections are made up of multiple URLs, where each URL is a season. TV Show Collections set each URL as its own season. If a video belongs to multiple URLs
If a video belongs to multiple URLs (i.e. a channel and a channel's playlist), (i.e. a channel and a channel's playlist), the video will only download once and reside in
it will resolve to the bottom-most season, as defined in the subscription. the higher-numbered season.
Two main use cases of a collection are: Two main use cases of a collection are:
1. Organize a YouTube channel TV show where Season 1 contains any video 1. Organize a YouTube channel TV show where Season 1 contains any video
@ -116,15 +108,41 @@ Two main use cases of a collection are:
2. Organize one or more YouTube channels/playlists, where each season 2. Organize one or more YouTube channels/playlists, where each season
represents a separate channel/playlist. represents a separate channel/playlist.
Player Presets Example
~~~~~~~
Must define ``tv_show_directory``. Available presets:
* ``"Kodi TV Show Collection"``
* ``"Jellyfin TV Show Collection"``
* ``"Plex TV Show Collection"``
.. code-block:: yaml
__preset__:
overrides:
tv_show_directory: "/tv_shows"
Plex TV Show Collection:
= Music:
# Prefix with ~ to set specific override variables
"~Beyond the Guitar":
s01_name: "Videos"
s01_url: "https://www.youtube.com/c/BeyondTheGuitar"
s02_name: "Covers"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"
Advanced Usage
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
If you prefer a different organization method, you can instead apply multiple presets to your subscriptions.
You will need a base of one of the below:
* ``kodi_tv_show_collection`` * ``kodi_tv_show_collection``
* ``jellyfin_tv_show_collection`` * ``jellyfin_tv_show_collection``
* ``plex_tv_show_collection`` * ``plex_tv_show_collection``
Episode Formatting Presets And then add one of these:
~~~~~~~~~~~~~~~~~~~~~~~~~~
* ``season_by_collection__episode_by_year_month_day`` * ``season_by_collection__episode_by_year_month_day``
* ``season_by_collection__episode_by_year_month_day_reversed`` * ``season_by_collection__episode_by_year_month_day_reversed``
@ -132,40 +150,3 @@ Episode Formatting Presets
* Only use playlist_index episode formatting for playlists that will be fully downloaded once and never again. Otherwise, indices can change. * Only use playlist_index episode formatting for playlists that will be fully downloaded once and never again. Otherwise, indices can change.
* ``season_by_collection__episode_by_playlist_index_reversed`` * ``season_by_collection__episode_by_playlist_index_reversed``
Season Presets
~~~~~~~~~~~~~~
* ``collection_season_1``
* ``collection_season_2``
* ``collection_season_3``
* ``collection_season_4``
* ``...``
* ``collection_season_40``
Example
~~~~~~~
A preset/subscription requires specifying a player, episode formatting, and
one or more season presets, with the following override variables:
.. code-block:: yaml
rick_a_tv_show_collection:
preset:
- "jellyfin_tv_show_collection"
- "season_by_collection__episode_by_year_month_day_reversed"
- "collection_season_1"
- "collection_season_2"
overrides:
# required
tv_show_name: "Rick A"
tv_show_directory: "/path/to/youtube_shows"
collection_season_1_url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
collection_season_1_name: "All Videos"
collection_season_2_url: "https://www.youtube.com/playlist?list=PLlaN88a7y2_plecYoJxvRFTLHVbIVAOoc"
collection_season_2_name: "Official Music Videos"
# can be modified from their default value
# tv_show_genre: "ytdl-sub"
# episode_title: "{upload_date_standardized} - {title}"
# episode_description: "{webpage_url}"

View file

@ -56,3 +56,20 @@ Plex TV Show by Date:
# Set "News" for genre, use `Only Recent` preset to only store videos uploaded recently # Set "News" for genre, use `Only Recent` preset to only store videos uploaded recently
= News | Only Recent: = News | Only Recent:
"BBC News": "https://www.youtube.com/@BBCNews" "BBC News": "https://www.youtube.com/@BBCNews"
# Sets URLs to be explicit seasons. If a video resides in multiple URLs, it will
# only appear once in the higher-numbered season. This is how you can separate a channel's
# videos and playlists you are interested in.
#
# Choose the player you intend to use by setting the top-level key to be either:
# - Plex TV Show Collection:
# - Jellyfin TV Show Collection:
# - Kodi TV Show Collection:
Plex TV Show Collection:
= Music:
"~Beyond the Guitar":
s01_name: "Videos"
s01_url: "https://www.youtube.com/c/BeyondTheGuitar"
s02_name: "Music Videos"
s02_url: "https://www.youtube.com/playlist?list=PLE62gWlWZk5NWVAVuf0Lm9jdv_-_KXs0W"

View file

@ -1,3 +1,69 @@
[project]
name ="ytdl-sub"
dynamic = [ "version" ]
authors = [ { name = "Jesse Bannon" } ]
description = "Automate downloading metadata generation with YoutubeDL"
readme = "README.md"
requires-python = ">=3.10"
license = { file = "LICENSE" }
classifiers = [
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Video",
"License :: Public Domain",
"Environment :: Console",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
]
dependencies = [
"yt-dlp==2024.5.27",
"colorama~=0.4",
"mergedeep~=1.3",
"mediafile~=0.12",
"PyYAML~=6.0",
]
urls = { Homepage = "https://github.com/jmbannon/ytdl-sub" }
[build-system]
requires = [ "setuptools >= 67.0" ]
build-backend = "setuptools.build_meta"
[tool.setuptools]
platforms = [ "Unix" ]
[tool.setuptools.dynamic]
version = { attr = "ytdl_sub.__pypi_version__" }
[tool.setuptools.package-dir]
"" = "src"
[tool.setuptools.package-data]
"*" = ["*.yaml"]
[tool.setuptools.packages.find]
where = ["src"]
[project.optional-dependencies]
test = [
"coverage[toml]>=6.3,<8.0",
"pytest>=7.2,<9.0",
"pytest-rerunfailures~=14.0",
]
lint = [
"black==24.4.2",
"isort==5.13.2",
"pylint==3.2.2",
]
docs = [
"sphinx~=7.0",
"sphinx-rtd-theme~=2.0",
"sphinx-book-theme~=1.0",
]
build = [
"build~=1.2",
"twine~=5.0",
"pyinstaller~=6.5",
]
[project.scripts]
ytdl-sub = "ytdl_sub.main:main"
[tool.isort] [tool.isort]
profile = "black" profile = "black"
line_length = 100 line_length = 100

View file

@ -1,60 +0,0 @@
[metadata]
name = ytdl-sub
version = attr:ytdl_sub.__pypi_version__
author = Jesse Bannon
description = Automate downloading and metadata generation with YoutubeDL
long_description = file: README.md
long_description_content_type= text/markdown
author_email = use_github_issues@nope.com
url = https://github.com/jmbannon/ytdl-sub
license = GNUv3
platforms = Unix
classifiers =
Topic :: Multimedia :: Sound/Audio
Topic :: Multimedia :: Video
License :: Public Domain
Environment :: Console
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
[options.entry_points]
console_scripts =
ytdl-sub = ytdl_sub.main:main
[options]
package_dir =
= src
packages=find:
install_requires =
yt-dlp==2023.12.30
argparse==1.4.0
colorama==0.4.6
mergedeep==1.3.4
mediafile==0.12.0
PyYAML==5.3.1
[options.package_data]
* = *.yaml
[options.packages.find]
where=src
[options.extras_require]
test =
coverage[toml]==6.3.2
pytest==7.1.1
pytest-rerunfailures==12.0
lint =
black==22.3.0
isort==5.10.1
pylint==2.13.5
docs =
sphinx==7.2.6
sphinx-rtd-theme==2.0.0
sphinx-book-theme==1.1.0
build =
build
twine
pyinstaller

View file

@ -150,6 +150,8 @@ parser.add_argument("-v", "--version", action="version", version="%(prog)s " + _
_add_shared_arguments(parser, suppress_defaults=False) _add_shared_arguments(parser, suppress_defaults=False)
subparsers = parser.add_subparsers(dest="subparser") subparsers = parser.add_subparsers(dest="subparser")
################################################################################################### ###################################################################################################
# SUBSCRIPTION PARSER # SUBSCRIPTION PARSER
class SubArguments: class SubArguments:

View file

@ -1,3 +1,5 @@
import os
import posixpath
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Optional from typing import Optional
@ -147,7 +149,8 @@ class ConfigOptions(StrictDictValidator):
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. Defaults to .ytdl-sub-working-directory directory. Defaults to .ytdl-sub-working-directory
""" """
return self._working_directory.value # Expands tildas to actual paths, use native os sep
return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
@property @property
def umask(self) -> Optional[str]: def umask(self) -> Optional[str]:

View file

@ -7,8 +7,8 @@ import mergedeep
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
from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES
from ytdl_sub.entries.variables.override_variables import OverrideHelpers from ytdl_sub.entries.variables.override_variables import OverrideHelpers
from ytdl_sub.entries.variables.override_variables import SubscriptionVariables
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.utils.exceptions import ScriptVariableNotResolved from ytdl_sub.script.utils.exceptions import ScriptVariableNotResolved
@ -17,11 +17,11 @@ 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 DictFormatterValidator
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
class Overrides(DictFormatterValidator, Scriptable): class Overrides(UnstructuredDictFormatterValidator, Scriptable):
""" """
Allows you to define variables that can be used in any EntryFormatter or OverridesFormatter. Allows you to define variables that can be used in any EntryFormatter or OverridesFormatter.
@ -51,17 +51,18 @@ class Overrides(DictFormatterValidator, Scriptable):
@classmethod @classmethod
def partial_validate(cls, name: str, value: Any) -> None: def partial_validate(cls, name: str, value: Any) -> None:
dict_formatter = DictFormatterValidator(name=name, value=value) dict_formatter = UnstructuredDictFormatterValidator(name=name, value=value)
_ = [parse(format_string) for format_string in dict_formatter.dict_with_format_strings] _ = [parse(format_string) for format_string in dict_formatter.dict_with_format_strings]
def __init__(self, name, value): def __init__(self, name, value):
DictFormatterValidator.__init__(self, name, value) UnstructuredDictFormatterValidator.__init__(self, name, value)
Scriptable.__init__(self, initialize_base_script=True) Scriptable.__init__(self, initialize_base_script=True)
for key in self._keys: for key in self._keys:
self.ensure_variable_name_valid(key) self.ensure_variable_name_valid(key)
self.unresolvable.add(VARIABLES.entry_metadata.variable_name) self.unresolvable.add(VARIABLES.entry_metadata.variable_name)
self.unresolvable.update(REQUIRED_OVERRIDE_VARIABLE_NAMES)
def ensure_added_plugin_variable_valid(self, added_variable: str) -> bool: def ensure_added_plugin_variable_valid(self, added_variable: str) -> bool:
""" """
@ -127,17 +128,10 @@ class Overrides(DictFormatterValidator, Scriptable):
) )
return ScriptUtils.add_sanitized_variables(initial_variables) return ScriptUtils.add_sanitized_variables(initial_variables)
def initialize_script( def initialize_script(self, unresolved_variables: Set[str]) -> "Overrides":
self, subscription_name: str, unresolved_variables: Set[str]
) -> "Overrides":
""" """
Initialize the override script with override variables + any unresolved variables Initialize the override script with any unresolved variables
""" """
self.script.add(
ScriptUtils.add_sanitized_variables(
{SubscriptionVariables.subscription_name(): subscription_name}
)
)
self.script.add( self.script.add(
self.initial_variables( self.initial_variables(
unresolved_variables={ unresolved_variables={

View file

@ -9,27 +9,27 @@ from typing import Tuple
from typing import Type from typing import Type
from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.validators.options import OptionsValidatorT
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.config.validators.options import TOptionsValidator
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
# pylint: disable=no-self-use,unused-argument # pylint: disable=unused-argument
class BasePlugin(DownloadArchiver, Generic[TOptionsValidator], ABC): class BasePlugin(DownloadArchiver, Generic[OptionsValidatorT], ABC):
""" """
Shared code amongst all SourcePlugins (downloaders) and Plugins (post-download modification) Shared code amongst all SourcePlugins (downloaders) and Plugins (post-download modification)
""" """
plugin_options_type: Type[TOptionsValidator] plugin_options_type: Type[OptionsValidatorT]
def __init__( def __init__(
self, self,
options: TOptionsValidator, options: OptionsValidatorT,
overrides: Overrides, overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
): ):
@ -38,7 +38,7 @@ class BasePlugin(DownloadArchiver, Generic[TOptionsValidator], ABC):
self.overrides = overrides self.overrides = overrides
class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC): class Plugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
""" """
Class to define the new plugin functionality Class to define the new plugin functionality
""" """
@ -121,7 +121,7 @@ class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
""" """
class SplitPlugin(Plugin[TOptionsValidator], Generic[TOptionsValidator], ABC): class SplitPlugin(Plugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
""" """
Plugin that splits entries into zero or more entries Plugin that splits entries into zero or more entries
""" """

View file

@ -5,7 +5,7 @@ from typing import Type
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.config.validators.options import TOptionsValidator from ytdl_sub.config.validators.options import OptionsValidatorT
class PresetPlugins: class PresetPlugins:
@ -29,7 +29,7 @@ class PresetPlugins:
""" """
return list(zip(self.plugin_types, self.plugin_options)) return list(zip(self.plugin_types, self.plugin_options))
def get(self, plugin_type: Type[TOptionsValidator]) -> Optional[TOptionsValidator]: def get(self, plugin_type: Type[OptionsValidatorT]) -> Optional[OptionsValidatorT]:
""" """
Parameters Parameters
---------- ----------

View file

@ -198,9 +198,7 @@ class Preset(_PresetShell):
downloader_options=self.downloader_options, downloader_options=self.downloader_options,
output_options=self.output_options, output_options=self.output_options,
plugins=self.plugins, plugins=self.plugins,
).initialize_overrides( ).initialize_preset_overrides(overrides=self.overrides).ensure_proper_usage()
subscription_name=self.name, overrides=self.overrides
).ensure_proper_usage()
@property @property
def name(self) -> str: def name(self) -> str:

View file

@ -9,7 +9,6 @@ from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.validators import Validator from ytdl_sub.validators.validators import Validator
# pylint: disable=no-self-use
# pylint: disable=unused-argument # pylint: disable=unused-argument
@ -53,7 +52,7 @@ class OptionsValidator(Validator, ABC):
return {} return {}
TOptionsValidator = TypeVar("TOptionsValidator", bound=OptionsValidator) OptionsValidatorT = TypeVar("OptionsValidatorT", bound=OptionsValidator)
class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC): class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC):

View file

@ -13,12 +13,24 @@ 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 SubscriptionVariables from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_NAMES
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.utils.scriptable import BASE_SCRIPT from ytdl_sub.utils.scriptable import BASE_SCRIPT
from ytdl_sub.validators.string_formatter_validators import to_variable_dependency_format_string 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
}
def _add_dummy_variables(variables: Iterable[str]) -> Dict[str, str]: def _add_dummy_variables(variables: Iterable[str]) -> Dict[str, str]:
dummy_variables: Dict[str, str] = {} dummy_variables: Dict[str, str] = {}
@ -33,6 +45,9 @@ def _add_dummy_overrides(overrides: Overrides) -> Dict[str, str]:
# Have the dummy override variable contain all variable deps that it uses in the string # Have the dummy override variable contain all variable deps that it uses in the string
dummy_overrides: Dict[str, str] = {} dummy_overrides: Dict[str, str] = {}
for override_name in _override_variables(overrides): for override_name in _override_variables(overrides):
if _is_function(override_name):
continue
# pylint: disable=protected-access # pylint: disable=protected-access
dummy_overrides[override_name] = to_variable_dependency_format_string( dummy_overrides[override_name] = to_variable_dependency_format_string(
script=overrides.script, parsed_format_string=overrides.script._variables[override_name] script=overrides.script, parsed_format_string=overrides.script._variables[override_name]
@ -68,20 +83,7 @@ def _get_added_and_modified_variables(
def _override_variables(overrides: Overrides) -> Set[str]: def _override_variables(overrides: Overrides) -> Set[str]:
return set(list(overrides.initial_variables().keys())) | { return set(list(overrides.initial_variables().keys()))
SubscriptionVariables.subscription_name()
}
_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 VariableValidation: class VariableValidation:
@ -99,13 +101,11 @@ class VariableValidation:
self.resolved_variables: Set[str] = set() self.resolved_variables: Set[str] = set()
self.unresolved_variables: Set[str] = set() self.unresolved_variables: Set[str] = set()
def initialize_overrides( def initialize_preset_overrides(self, overrides: Overrides) -> "VariableValidation":
self, subscription_name: str, overrides: Overrides
) -> "VariableValidation":
""" """
Do some gymnastics to initialize the Overrides script. Do some gymnastics to initialize the Overrides script.
""" """
override_variables = _override_variables(overrides) override_variables = set(list(overrides.initial_variables().keys()))
# Set resolved variables as all entry + override variables # Set resolved variables as all entry + override variables
# at this point to generate every possible added/modified variable # at this point to generate every possible added/modified variable
@ -141,9 +141,7 @@ class VariableValidation:
# Initialize overrides with unresolved variables + modified variables to throw an error. # 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 # For modified variables, this is to prevent a resolve(update=True) to setting any
# dependencies until it has been explicitly added # dependencies until it has been explicitly added
overrides = overrides.initialize_script( overrides = overrides.initialize_script(unresolved_variables=self.unresolved_variables)
subscription_name=subscription_name, unresolved_variables=self.unresolved_variables
)
# copy the script and mock entry variables # copy the script and mock entry variables
self.script = copy.deepcopy(overrides.script) self.script = copy.deepcopy(overrides.script)
@ -158,7 +156,16 @@ class VariableValidation:
def _update_script(self) -> None: def _update_script(self) -> None:
_ = self.script.resolve(unresolvable=self.unresolved_variables, update=True) _ = self.script.resolve(unresolvable=self.unresolved_variables, update=True)
def _add_variables(self, plugin_op: PluginOperation, options: OptionsValidator) -> Set[str]: 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
"""
added_variables = options.added_variables( added_variables = options.added_variables(
resolved_variables=self.resolved_variables, resolved_variables=self.resolved_variables,
unresolved_variables=self.unresolved_variables, unresolved_variables=self.unresolved_variables,
@ -171,14 +178,14 @@ class VariableValidation:
self.resolved_variables |= resolved_variables self.resolved_variables |= resolved_variables
self.unresolved_variables -= resolved_variables self.unresolved_variables -= resolved_variables
return added_variables
def ensure_proper_usage(self) -> None: 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
""" """
self._add_variables(PluginOperation.DOWNLOADER, options=self.downloader_options) self._add_variables(PluginOperation.DOWNLOADER, options=self.downloader_options)
self._add_subscription_override_variables()
# 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(

View file

@ -118,6 +118,7 @@ class InfoJsonDownloader(SourcePlugin[InfoJsonDownloaderOptions]):
) )
entries.append(entry) entries.append(entry)
# TODO: MATCH A URL TO A URL_VALIDATOR !!!
for entry in sorted(entries, key=lambda ent: ent.get(v.download_index, int)): for entry in sorted(entries, key=lambda ent: ent.get(v.download_index, int)):
# Remove each entry from the live download archive since it will get re-added # Remove each entry from the live download archive since it will get re-added
# unless it is filtered # unless it is filtered

View file

@ -11,13 +11,13 @@ from typing import final
from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.overrides import Overrides
from ytdl_sub.config.plugin.plugin import BasePlugin from ytdl_sub.config.plugin.plugin import BasePlugin
from ytdl_sub.config.plugin.plugin import Plugin from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.validators.options import TOptionsValidator from ytdl_sub.config.validators.options import OptionsValidatorT
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
class SourcePluginExtension(Plugin[TOptionsValidator], ABC): class SourcePluginExtension(Plugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
""" """
Plugins that get added automatically by using a downloader. Downloader options Plugins that get added automatically by using a downloader. Downloader options
are the plugin options. are the plugin options.
@ -32,12 +32,12 @@ class SourcePluginExtension(Plugin[TOptionsValidator], ABC):
return None return None
class SourcePlugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC): class SourcePlugin(BasePlugin[OptionsValidatorT], Generic[OptionsValidatorT], ABC):
plugin_extensions: List[Type[SourcePluginExtension]] = [] plugin_extensions: List[Type[SourcePluginExtension]] = []
def __init__( def __init__(
self, self,
options: TOptionsValidator, options: OptionsValidatorT,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
download_ytdl_options: YTDLOptionsBuilder, download_ytdl_options: YTDLOptionsBuilder,
metadata_ytdl_options: YTDLOptionsBuilder, metadata_ytdl_options: YTDLOptionsBuilder,

View file

@ -25,6 +25,7 @@ from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.utils.thumbnail import ThumbnailTypes from ytdl_sub.utils.thumbnail import ThumbnailTypes
from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
@ -41,7 +42,30 @@ class URLDownloadState:
self.entries_downloaded = 0 self.entries_downloaded = 0
class UrlDownloaderThumbnailPlugin(SourcePluginExtension): class UrlDownloaderBasePluginExtension(SourcePluginExtension[MultiUrlValidator]):
def _match_entry_to_url_validator(self, entry: Entry) -> UrlValidator:
"""
Handle matching a URL to its original validator. This is for .info.json updates
when older entries have missing variables
"""
input_url_idx = entry.get(v.ytdl_sub_input_url_index, int)
entry_input_url = entry.get(v.ytdl_sub_input_url, str)
if 0 <= input_url_idx < len(self.plugin_options.urls.list):
validator = self.plugin_options.urls.list[input_url_idx]
if self.overrides.apply_formatter(validator.url) == entry_input_url:
return validator
# Match the first validator based on the URL, if one exists
for validator in self.plugin_options.urls.list:
if self.overrides.apply_formatter(validator.url) == entry_input_url:
return validator
# Return the first validator if none exist
return self.plugin_options.urls.list[0]
class UrlDownloaderThumbnailPlugin(UrlDownloaderBasePluginExtension):
def __init__( def __init__(
self, self,
options: MultiUrlValidator, options: MultiUrlValidator,
@ -54,10 +78,6 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
) )
self._thumbnails_downloaded: Set[str] = set() self._thumbnails_downloaded: Set[str] = set()
self._collection_url_mapping: Dict[str, UrlValidator] = {
self.overrides.apply_formatter(collection_url.url): collection_url
for collection_url in options.urls.list
}
def _download_parent_thumbnails( def _download_parent_thumbnails(
self, self,
@ -136,15 +156,14 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
if not self.is_dry_run: if not self.is_dry_run:
try_convert_download_thumbnail(entry=entry) try_convert_download_thumbnail(entry=entry)
if (input_url := entry.get(v.ytdl_sub_input_url, str)) in self._collection_url_mapping:
self._download_url_thumbnails( self._download_url_thumbnails(
collection_url=self._collection_url_mapping[input_url], collection_url=self._match_entry_to_url_validator(entry=entry),
entry=entry, entry=entry,
) )
return entry return entry
class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension): class UrlDownloaderCollectionVariablePlugin(UrlDownloaderBasePluginExtension):
def __init__( def __init__(
self, self,
options: MultiUrlValidator, options: MultiUrlValidator,
@ -157,25 +176,12 @@ class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension):
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
) )
self._thumbnails_downloaded: Set[str] = set() self._thumbnails_downloaded: Set[str] = set()
self._collection_url_mapping: Dict[str, UrlValidator] = {
self.overrides.apply_formatter(collection_url.url): collection_url
for collection_url in options.urls.list
}
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]: def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
""" """
Add collection variables to the entry Add collection variables to the entry
""" """
# COLLECTION_URL is a recent variable that may not exist for old entries when updating. collection_url = self._match_entry_to_url_validator(entry=entry)
# Try to use source_webpage_url if it does not exist
entry_collection_url = entry.get(v.ytdl_sub_input_url, str)
# If the collection URL cannot find its mapping, use the last URL
collection_url = (
self._collection_url_mapping.get(entry_collection_url)
or list(self._collection_url_mapping.values())[-1]
)
entry.add(collection_url.variables.dict_with_format_strings) entry.add(collection_url.variables.dict_with_format_strings)
return entry return entry
@ -232,8 +238,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
self._downloaded_entries: Set[str] = set() self._downloaded_entries: Set[str] = set()
self._url_state: Optional[URLDownloadState] = None self._url_state: Optional[URLDownloadState] = None
@property def download_ytdl_options(self, url_idx: Optional[int] = None) -> Dict:
def download_ytdl_options(self) -> Dict:
""" """
Returns Returns
------- -------
@ -242,19 +247,28 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
return ( return (
self._download_ytdl_options_builder.clone() self._download_ytdl_options_builder.clone()
.add(self.ytdl_option_defaults(), before=True) .add(self.ytdl_option_defaults(), before=True)
.add(
(
self.plugin_options.urls.list[url_idx].ytdl_options.dict
if url_idx is not None
else None
),
before=True,
)
.to_dict() .to_dict()
) )
@property def metadata_ytdl_options(self, ytdl_option_overrides: Dict) -> Dict:
def metadata_ytdl_options(self) -> Dict:
""" """
Returns Returns
------- -------
YTDL options dict for fetching metadata YTDL options dict for fetching metadata
""" """
return ( return (
self._metadata_ytdl_options_builder.clone() self._metadata_ytdl_options_builder.clone()
.add(self.ytdl_option_defaults(), before=True) .add(self.ytdl_option_defaults(), before=True)
.add(ytdl_option_overrides, before=True)
.to_dict() .to_dict()
) )
@ -265,7 +279,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
------- -------
True if dry-run is enabled. False otherwise. True if dry-run is enabled. False otherwise.
""" """
return self.download_ytdl_options.get("skip_download", False) return self.download_ytdl_options().get("skip_download", False)
@property @property
def is_entry_thumbnails_enabled(self) -> bool: def is_entry_thumbnails_enabled(self) -> bool:
@ -274,7 +288,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
------- -------
True if entry thumbnails should be downloaded. False otherwise. True if entry thumbnails should be downloaded. False otherwise.
""" """
return self.download_ytdl_options.get("writethumbnail", False) return self.download_ytdl_options().get("writethumbnail", False)
############################################################################################### ###############################################################################################
# DOWNLOAD FUNCTIONS # DOWNLOAD FUNCTIONS
@ -301,7 +315,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
clear_info_json_files clear_info_json_files
Whether to delete info.json files after yield Whether to delete info.json files after yield
""" """
archive_path = self.download_ytdl_options.get("download_archive", "") archive_path = self.download_ytdl_options().get("download_archive", "")
backup_archive_path = f"{archive_path}.backup" backup_archive_path = f"{archive_path}.backup"
# If archive path exists, maintain download archive is enable # If archive path exists, maintain download archive is enable
@ -336,11 +350,15 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
def _extract_entry_info_with_retry(self, entry: Entry) -> Entry: def _extract_entry_info_with_retry(self, entry: Entry) -> Entry:
download_entry_dict = YTDLP.extract_info_with_retry( download_entry_dict = YTDLP.extract_info_with_retry(
ytdl_options_overrides=self.download_ytdl_options, ytdl_options_overrides=self.download_ytdl_options(
url_idx=entry.get(v.ytdl_sub_input_url_index, int)
),
is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded, is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
is_thumbnail_downloaded_fn=None is_thumbnail_downloaded_fn=(
None
if (self.is_dry_run or not self.is_entry_thumbnails_enabled) if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
else entry.is_thumbnail_downloaded_via_ytdlp, else entry.is_thumbnail_downloaded_via_ytdlp
),
url=entry.webpage_url, url=entry.webpage_url,
) )
return Entry( return Entry(
@ -352,45 +370,45 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
self, entries: List[Entry], download_reversed: bool self, entries: List[Entry], download_reversed: bool
) -> Iterator[Entry]: ) -> Iterator[Entry]:
# Iterate a list of entries, and delete the entries after yielding # Iterate a list of entries, and delete the entries after yielding
indices = list(range(len(entries))) entries_to_iter: List[Optional[Entry]] = entries
indices = list(range(len(entries_to_iter)))
if download_reversed: if download_reversed:
indices = reversed(indices) indices = reversed(indices)
for idx in indices: for idx in indices:
self._url_state.entries_downloaded += 1 self._url_state.entries_downloaded += 1
if self._is_downloaded(entries[idx]): if self._is_downloaded(entries_to_iter[idx]):
download_logger.info( download_logger.info(
"Already downloaded entry %d/%d: %s", "Already downloaded entry %d/%d: %s",
self._url_state.entries_downloaded, self._url_state.entries_downloaded,
self._url_state.entries_total, self._url_state.entries_total,
entries[idx].title, entries_to_iter[idx].title,
) )
del entries[idx] entries_to_iter[idx] = None
continue continue
yield entries[idx] yield entries_to_iter[idx]
self._mark_downloaded(entries[idx]) self._mark_downloaded(entries_to_iter[idx])
del entries[idx] entries_to_iter[idx] = None
def _iterate_parent_entry( def _iterate_parent_entry(
self, parent: EntryParent, download_reversed: bool self, parent: EntryParent, download_reversed: bool
) -> Iterator[Entry]: ) -> Iterator[Entry]:
for entry_child in self._iterate_child_entries( yield from self._iterate_child_entries(
entries=parent.entry_children(), download_reversed=download_reversed entries=parent.entry_children(), download_reversed=download_reversed
): )
yield entry_child
# Recursion the parent's parent entries # Recursion the parent's parent entries
for parent_child in reversed(parent.parent_children()): for parent_child in reversed(parent.parent_children()):
for entry_child in self._iterate_parent_entry( yield from self._iterate_parent_entry(
parent=parent_child, download_reversed=download_reversed parent=parent_child, download_reversed=download_reversed
): )
yield entry_child
def _download_url_metadata( def _download_url_metadata(
self, url: str, include_sibling_metadata: bool self, url: str, include_sibling_metadata: bool, ytdl_options_overrides: Dict
) -> Tuple[List[EntryParent], List[Entry]]: ) -> Tuple[List[EntryParent], List[Entry]]:
""" """
Downloads only info.json files and forms EntryParent trees Downloads only info.json files and forms EntryParent trees
@ -398,7 +416,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
with self._separate_download_archives(): with self._separate_download_archives():
entry_dicts = YTDLP.extract_info_via_info_json( entry_dicts = YTDLP.extract_info_via_info_json(
working_directory=self.working_directory, working_directory=self.working_directory,
ytdl_options_overrides=self.metadata_ytdl_options, ytdl_options_overrides=ytdl_options_overrides,
log_prefix_on_info_json_dl="Downloading metadata for", log_prefix_on_info_json_dl="Downloading metadata for",
url=url, url=url,
) )
@ -429,26 +447,26 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
# Delete info json files afterwards so other collection URLs do not use them # Delete info json files afterwards so other collection URLs do not use them
with self._separate_download_archives(clear_info_json_files=True): with self._separate_download_archives(clear_info_json_files=True):
for parent in parents: for parent in parents:
for entry_child in self._iterate_parent_entry( yield from self._iterate_parent_entry(
parent=parent, download_reversed=download_reversed parent=parent, download_reversed=download_reversed
): )
yield entry_child
for orphan in self._iterate_child_entries( yield from self._iterate_child_entries(
entries=orphans, download_reversed=download_reversed entries=orphans, download_reversed=download_reversed
): )
yield orphan
def download_metadata(self) -> Iterable[Entry]: def _download_metadata(self, url: str, validator: UrlValidator) -> Iterable[Entry]:
"""The function to perform the download of all media entries""" metadata_ytdl_options = self.metadata_ytdl_options(
# download the bottom-most urls first since they are top-priority ytdl_option_overrides=validator.ytdl_options.dict
for collection_url in reversed(self.collection.urls.list): )
# URLs can be empty. If they are, then skip download_reversed = ScriptUtils.bool_formatter_output(
if not (url := self.overrides.apply_formatter(collection_url.url)): self.overrides.apply_formatter(validator.download_reverse)
continue )
parents, orphan_entries = self._download_url_metadata( parents, orphan_entries = self._download_url_metadata(
url=url, include_sibling_metadata=collection_url.include_sibling_metadata url=url,
include_sibling_metadata=validator.include_sibling_metadata,
ytdl_options_overrides=metadata_ytdl_options,
) )
# TODO: Encapsulate this logic into its own class # TODO: Encapsulate this logic into its own class
@ -456,17 +474,30 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries) entries_total=sum(parent.num_children() for parent in parents) + len(orphan_entries)
) )
download_logger.info( download_logger.info("Beginning downloads for %s", url)
"Beginning downloads for %s", self.overrides.apply_formatter(collection_url.url) yield from self._iterate_entries(
)
for entry in self._iterate_entries(
parents=parents, parents=parents,
orphans=orphan_entries, orphans=orphan_entries,
download_reversed=collection_url.download_reverse, download_reversed=download_reversed,
):
entry.initialize_script(self.overrides).add(
{v.ytdl_sub_input_url: self.overrides.apply_formatter(collection_url.url)}
) )
def download_metadata(self) -> Iterable[Entry]:
"""The function to perform the download of all media entries"""
# download the bottom-most urls first since they are top-priority
for idx, url_validator in reversed(list(enumerate(self.collection.urls.list))):
# URLs can be empty. If they are, then skip
if not (url := self.overrides.apply_formatter(url_validator.url)):
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 yield entry
def download(self, entry: Entry) -> Optional[Entry]: def download(self, entry: Entry) -> Optional[Entry]:

View file

@ -4,10 +4,12 @@ from typing import Optional
from typing import Set from typing import Set
from ytdl_sub.config.plugin.plugin_operation import PluginOperation from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.script.parser import parse from ytdl_sub.script.parser import parse
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.validators import BoolValidator
@ -49,6 +51,7 @@ class UrlValidator(StrictDictValidator):
"source_thumbnails", "source_thumbnails",
"playlist_thumbnails", "playlist_thumbnails",
"download_reverse", "download_reverse",
"ytdl_options",
"include_sibling_metadata", "include_sibling_metadata",
} }
@ -77,7 +80,10 @@ class UrlValidator(StrictDictValidator):
key="playlist_thumbnails", validator=UrlThumbnailListValidator, default=[] key="playlist_thumbnails", validator=UrlThumbnailListValidator, default=[]
) )
self._download_reverse = self._validate_key( self._download_reverse = self._validate_key(
key="download_reverse", validator=BoolValidator, default=True key="download_reverse", validator=OverridesBooleanFormatterValidator, default="True"
)
self._ytdl_options = self._validate_key(
key="ytdl_options", validator=YTDLOptions, default={}
) )
self._include_sibling_metadata = self._validate_key( self._include_sibling_metadata = self._validate_key(
key="include_sibling_metadata", validator=BoolValidator, default=False key="include_sibling_metadata", validator=BoolValidator, default=False
@ -148,12 +154,20 @@ class UrlValidator(StrictDictValidator):
return self._source_thumbnails return self._source_thumbnails
@property @property
def download_reverse(self) -> bool: def download_reverse(self) -> OverridesBooleanFormatterValidator:
""" """
Optional. Whether to download entries in the reverse order of the metadata downloaded. Optional. Whether to download entries in the reverse order of the metadata downloaded.
Defaults to True. Defaults to True.
""" """
return self._download_reverse.value return self._download_reverse
@property
def ytdl_options(self) -> YTDLOptions:
"""
Optional. ``ytdl_options`` that only apply to this URL. These take precedence
over the plugin ``ytdl_options``.
"""
return self._ytdl_options
@property @property
def include_sibling_metadata(self) -> bool: def include_sibling_metadata(self) -> bool:
@ -258,6 +272,8 @@ class MultiUrlValidator(OptionsValidator):
variables: variables:
season_index: "2" season_index: "2"
season_name: "Playlist as Season" season_name: "Playlist as Season"
ytdl_options:
break_on_existing: False
playlist_thumbnails: playlist_thumbnails:
- name: "season{season_index}-poster.jpg" - name: "season{season_index}-poster.jpg"
uid: "latest_entry" uid: "latest_entry"

View file

@ -248,20 +248,22 @@ class YTDLP:
continue continue
cls.logger.debug("Attempting to get parent metadata from URL %s", uploader_url) cls.logger.debug("Attempting to get parent metadata from URL %s", uploader_url)
parent_dict: Optional[Dict] = None
try: try:
parent_dict = cls.extract_info( parent_dict = cls.extract_info(
ytdl_options_overrides=ytdl_options_overrides | {"playlist_items": "0:0"}, ytdl_options_overrides=ytdl_options_overrides | {"playlist_items": "0:0"},
url=uploader_url, url=uploader_url,
) )
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
# Do not try this uploader_id again pass
entry_ids.add(uploader_id)
break
if isinstance(parent_dict, dict): parent_id = parent_dict.get("id") if isinstance(parent_dict, dict) else None
parent_id = parent_dict.get("id") if parent_id and parent_id not in entry_ids:
parent_dicts.append(parent_dict) parent_dicts.append(parent_dict)
entry_ids |= {uploader_id, parent_id} entry_ids.add(parent_id)
cls.logger.debug("Adding parent metadata with ids [%s, %s]", uploader_id, parent_id) cls.logger.debug("Adding parent metadata with ids [%s, %s]", uploader_id, parent_id)
# Always add the uploader_id since it has been tried
entry_ids.add(uploader_id)
return entry_dicts + parent_dicts return entry_dicts + parent_dicts

View file

@ -8,6 +8,7 @@ from typing import Type
from typing import TypeVar from typing import TypeVar
from typing import final from typing import final
from yt_dlp.utils import LazyList
from yt_dlp.utils import sanitize_filename from yt_dlp.utils import sanitize_filename
from ytdl_sub.entries.script.variable_definitions import VARIABLES from ytdl_sub.entries.script.variable_definitions import VARIABLES
@ -15,7 +16,7 @@ from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
v: VariableDefinitions = VARIABLES v: VariableDefinitions = VARIABLES
TBaseEntry = TypeVar("TBaseEntry", bound="BaseEntry") BaseEntryT = TypeVar("BaseEntryT", bound="BaseEntry")
class BaseEntry(ABC): class BaseEntry(ABC):
@ -37,6 +38,12 @@ class BaseEntry(ABC):
self._working_directory = working_directory self._working_directory = working_directory
self._kwargs = entry_dict self._kwargs = entry_dict
# Sometimes yt-dlp can return a LazyList which is not JSON serializable.
# Cast it to a native list here. (https://github.com/jmbannon/ytdl-sub/issues/910)
for key in self._kwargs.keys():
if isinstance(self._kwargs[key], LazyList):
self._kwargs[key] = list(self._kwargs[key])
@property @property
def uid(self) -> str: def uid(self) -> str:
""" """
@ -133,7 +140,7 @@ class BaseEntry(ABC):
return str(Path(self.working_directory()) / self.get_download_info_json_name()) return str(Path(self.working_directory()) / self.get_download_info_json_name())
@final @final
def to_type(self, entry_type: Type[TBaseEntry]) -> TBaseEntry: def to_type(self, entry_type: Type[BaseEntryT]) -> BaseEntryT:
""" """
Returns Returns
------- -------
@ -142,7 +149,7 @@ class BaseEntry(ABC):
return entry_type(entry_dict=self._kwargs, working_directory=self._working_directory) return entry_type(entry_dict=self._kwargs, working_directory=self._working_directory)
@classmethod @classmethod
def is_entry_parent(cls, entry_dict: Dict | TBaseEntry): def is_entry_parent(cls, entry_dict: Dict | BaseEntryT):
""" """
Returns Returns
------- -------
@ -157,7 +164,7 @@ class BaseEntry(ABC):
return entry_type == "playlist" return entry_type == "playlist"
@classmethod @classmethod
def is_entry(cls, entry_dict: Dict | TBaseEntry): def is_entry(cls, entry_dict: Dict | BaseEntryT):
""" """
Returns Returns
------- -------

View file

@ -4,9 +4,10 @@ from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Set from typing import Set
from urllib.parse import urlparse
from ytdl_sub.entries.base_entry import BaseEntry from ytdl_sub.entries.base_entry import BaseEntry
from ytdl_sub.entries.base_entry import TBaseEntry from ytdl_sub.entries.base_entry import BaseEntryT
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
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
@ -20,7 +21,7 @@ v: VariableDefinitions = VARIABLES
class EntryParent(BaseEntry): class EntryParent(BaseEntry):
@classmethod @classmethod
def _sort_entries(cls, entries: List[TBaseEntry]) -> List[TBaseEntry]: def _sort_entries(cls, entries: List[BaseEntryT]) -> List[BaseEntryT]:
"""Try sorting by playlist_id first, then fall back to uid""" """Try sorting by playlist_id first, then fall back to uid"""
return sorted( return sorted(
entries, entries,
@ -152,7 +153,12 @@ class EntryParent(BaseEntry):
""" """
def _url_matches(parent: "EntryParent"): def _url_matches(parent: "EntryParent"):
return parent.webpage_url in url or url in parent.webpage_url url_parsed = urlparse(url)
parent_parsed = urlparse(parent.webpage_url)
return (
url_parsed.hostname == parent_parsed.hostname
and url_parsed.path == parent_parsed.path
)
def _uid_is_uploader_id(parent: "EntryParent"): def _uid_is_uploader_id(parent: "EntryParent"):
return parent.uid == parent.uploader_id return parent.uid == parent.uploader_id
@ -168,18 +174,10 @@ class EntryParent(BaseEntry):
if len(top_level_parents) > 1: if len(top_level_parents) > 1:
top_level_parents = [parent for parent in top_level_parents if _url_matches(parent)] top_level_parents = [parent for parent in top_level_parents if _url_matches(parent)]
match len(top_level_parents): if len(top_level_parents) == 0:
case 0:
return None return None
case 1: if len(top_level_parents) == 1:
return top_level_parents[0] return top_level_parents[0]
case 2:
# Channels can have two of the same .info.json. Handle it here
top0 = top_level_parents[0]
top1 = top_level_parents[1]
if top0.uploader_id == top1.uploader_id:
return top0 if not top0.webpage_url.endswith("/videos") else top1
raise ValueError( raise ValueError(
"Detected multiple top-level parents. " "Detected multiple top-level parents. "
"Please file an issue on GitHub with the URLs used to produce this error" "Please file an issue on GitHub with the URLs used to produce this error"

View file

@ -36,9 +36,9 @@ class CustomFunctions:
def to_native_filepath(filepath: String) -> String: def to_native_filepath(filepath: String) -> String:
""" """
Convert any unix-based path separators ('/') with the OS's native Convert any unix-based path separators ('/') with the OS's native
separator. separator. In addition, expand ~ to absolute directories.
""" """
return String(filepath.value.replace(posixpath.sep, os.sep)) return String(os.path.expanduser(filepath.value.replace(posixpath.sep, os.sep)))
@staticmethod @staticmethod
def truncate_filepath_if_too_long(filepath: String) -> String: def truncate_filepath_if_too_long(filepath: String) -> String:

View file

@ -5,6 +5,7 @@ from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
v: VariableDefinitions = VARIABLES v: VariableDefinitions = VARIABLES
# TODO: Make this a proper class with docstrings
CUSTOM_FUNCTION_SCRIPTS: Dict[str, str] = { CUSTOM_FUNCTION_SCRIPTS: Dict[str, str] = {
############################################################################################# #############################################################################################
# SIBLING GETTER # SIBLING GETTER

View file

@ -21,6 +21,7 @@ from ytdl_sub.entries.script.variable_types import Variable
# pylint: disable=no-member # pylint: disable=no-member
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
# pylint: disable=method-cache-max-size-none
class MetadataVariableDefinitions(ABC): class MetadataVariableDefinitions(ABC):
@ -394,7 +395,9 @@ class UploadDateVariableDefinitions(ABC):
:description: :description:
The entrys uploaded date, in YYYYMMDD format. If not present, return todays date. The entrys uploaded date, in YYYYMMDD format. If not present, return todays date.
""" """
return StringDateMetadataVariable.from_entry(metadata_key="upload_date").as_date_variable() return StringDateMetadataVariable.from_entry(
metadata_key="upload_date", default=self.epoch_date
).as_date_variable()
@cached_property @cached_property
def upload_year(self: "VariableDefinitions") -> IntegerVariable: def upload_year(self: "VariableDefinitions") -> IntegerVariable:
@ -748,6 +751,24 @@ class YtdlSubVariableDefinitions(ABC):
""" """
return StringVariable(variable_name="ytdl_sub_input_url", definition="{ %string('') }") return StringVariable(variable_name="ytdl_sub_input_url", definition="{ %string('') }")
@cached_property
def ytdl_sub_input_url_index(self: "VariableDefinitions") -> IntegerVariable:
"""
:description:
The index of the input URL as defined in the subscription, top-most being the 0th index.
"""
# init as -1 so if prior downloaded entries are known when they do not have this value
# in their .info.json
return IntegerVariable(variable_name="ytdl_sub_input_url_index", definition="{ %int(-1) }")
@cached_property
def ytdl_sub_input_url_count(self: "VariableDefinitions") -> IntegerVariable:
"""
:description:
The total number of input URLs as defined in the subscription.
"""
return IntegerVariable(variable_name="ytdl_sub_input_url_count", definition="{ %int(0) }")
@cached_property @cached_property
def download_index(self: "VariableDefinitions") -> IntegerVariable: def download_index(self: "VariableDefinitions") -> IntegerVariable:
""" """
@ -1098,6 +1119,8 @@ class VariableDefinitions(
self.chapters, self.chapters,
self.sponsorblock_chapters, self.sponsorblock_chapters,
self.ytdl_sub_input_url, self.ytdl_sub_input_url,
self.ytdl_sub_input_url_index,
self.ytdl_sub_input_url_count,
} }
@cache @cache

View file

@ -9,6 +9,7 @@ from typing import TypeVar
from ytdl_sub.script.types.array import Array from ytdl_sub.script.types.array import Array
from ytdl_sub.script.types.map import Map from ytdl_sub.script.types.map import Map
from ytdl_sub.script.types.resolvable import Boolean
from ytdl_sub.script.types.resolvable import Integer from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.types.resolvable import String
@ -16,8 +17,8 @@ ENTRY_METADATA_VARIABLE_NAME = "entry_metadata"
PLAYLIST_METADATA_VARIABLE_NAME = "playlist_metadata" PLAYLIST_METADATA_VARIABLE_NAME = "playlist_metadata"
SOURCE_METADATA_VARIABLE_NAME = "source_metadata" SOURCE_METADATA_VARIABLE_NAME = "source_metadata"
TMetadataVariable = TypeVar("TMetadataVariable", bound="MetadataVariable") MetadataVariableT = TypeVar("MetadataVariableT", bound="MetadataVariable")
TVariable = TypeVar("TVariable", bound="Variable") VariableT = TypeVar("VariableT", bound="Variable")
def _get( def _get(
@ -25,9 +26,9 @@ def _get(
metadata_variable_name: str, metadata_variable_name: str,
metadata_key: str, metadata_key: str,
variable_name: Optional[str], variable_name: Optional[str],
default: Optional[TVariable | str | int | Dict | List], default: Optional[VariableT | str | int | Dict | List],
as_type: Type[TMetadataVariable], as_type: Type[MetadataVariableT],
) -> TMetadataVariable: ) -> MetadataVariableT:
if default is None: if default is None:
# TODO: assert with good error message if key DNE # TODO: assert with good error message if key DNE
out = f"%map_get({metadata_variable_name}, '{metadata_key}')" out = f"%map_get({metadata_variable_name}, '{metadata_key}')"
@ -63,6 +64,13 @@ class Variable(ABC):
""" """
@dataclass(frozen=True)
class BooleanVariable(Variable):
@classmethod
def human_readable_type(cls) -> str:
return Boolean.__name__
@dataclass(frozen=True) @dataclass(frozen=True)
class StringVariable(Variable): class StringVariable(Variable):
@classmethod @classmethod

View file

@ -1,5 +1,12 @@
from typing import Dict
from typing import Set
from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS
from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS
from ytdl_sub.entries.script.variable_types import BooleanVariable
from ytdl_sub.entries.script.variable_types import MapVariable
from ytdl_sub.entries.script.variable_types import StringVariable
from ytdl_sub.entries.script.variable_types import Variable
from ytdl_sub.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
@ -9,15 +16,15 @@ SUBSCRIPTION_ARRAY = "subscription_array"
class SubscriptionVariables: class SubscriptionVariables:
@staticmethod @staticmethod
def subscription_name() -> str: def subscription_name() -> StringVariable:
""" """
Name of the subscription. For subscriptions types that use a prefix (``~``, ``+``), Name of the subscription. For subscriptions types that use a prefix (``~``, ``+``),
the prefix and all whitespace afterwards is stripped from the subscription name. the prefix and all whitespace afterwards is stripped from the subscription name.
""" """
return "subscription_name" return StringVariable(variable_name="subscription_name", definition="{ %string('') }")
@staticmethod @staticmethod
def subscription_value() -> str: def subscription_value() -> StringVariable:
""" """
For subscriptions in the form of For subscriptions in the form of
@ -27,10 +34,10 @@ class SubscriptionVariables:
``subscription_value`` gets set to ``https://...``. ``subscription_value`` gets set to ``https://...``.
""" """
return "subscription_value" return StringVariable(variable_name="subscription_value", definition="{ %string('') }")
@staticmethod @staticmethod
def subscription_indent_i(index: int) -> str: def subscription_indent_i(index: int) -> StringVariable:
""" """
For subscriptions in the form of For subscriptions in the form of
@ -43,10 +50,12 @@ class SubscriptionVariables:
``subscription_indent_1`` and ``subscription_indent_2`` get set to ``subscription_indent_1`` and ``subscription_indent_2`` get set to
``Indent Value 1`` and ``Indent Value 2``. ``Indent Value 1`` and ``Indent Value 2``.
""" """
return f"subscription_indent_{index + 1}" return StringVariable(
variable_name=f"subscription_indent_{index + 1}", definition="{ %string('') }"
)
@staticmethod @staticmethod
def subscription_value_i(index: int) -> str: def subscription_value_i(index: int) -> StringVariable:
""" """
For subscriptions in the form of For subscriptions in the form of
@ -60,10 +69,12 @@ class SubscriptionVariables:
and ``https://url2.com/...``. Note that ``subscription_value_1`` also gets set to and ``https://url2.com/...``. Note that ``subscription_value_1`` also gets set to
``subscription_value``. ``subscription_value``.
""" """
return f"subscription_value_{index + 1}" return StringVariable(
variable_name=f"subscription_value_{index + 1}", definition="{ %string('') }"
)
@staticmethod @staticmethod
def subscription_map() -> str: def subscription_map() -> MapVariable:
""" """
For subscriptions in the form of For subscriptions in the form of
@ -89,7 +100,17 @@ class SubscriptionVariables:
] ]
} }
""" """
return "subscription_map" return MapVariable(variable_name="subscription_map", definition="{ {} }")
@staticmethod
def subscription_has_download_archive() -> BooleanVariable:
"""
Returns True if the subscription has any entries recorded in a download archive. False
otherwise.
"""
return BooleanVariable(
variable_name="subscription_has_download_archive", definition="{ %bool(True) }"
)
class OverrideHelpers: class OverrideHelpers:
@ -124,3 +145,17 @@ class OverrideHelpers:
return is_valid_name(name=name[1:]) return is_valid_name(name=name[1:])
return is_valid_name(name=name) return is_valid_name(name=name)
REQUIRED_OVERRIDE_VARIABLES: Set[Variable] = {
SubscriptionVariables.subscription_name(),
SubscriptionVariables.subscription_has_download_archive(),
}
REQUIRED_OVERRIDE_VARIABLE_DEFINITIONS: Dict[str, str] = {
var.variable_name: var.definition for var in REQUIRED_OVERRIDE_VARIABLES
}
REQUIRED_OVERRIDE_VARIABLE_NAMES: Set[str] = {
var.variable_name for var in REQUIRED_OVERRIDE_VARIABLES
}

View file

@ -263,13 +263,13 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
"force_keyframes": self.plugin_options.force_key_frames, "force_keyframes": self.plugin_options.force_key_frames,
} }
if self.plugin_options.remove_sponsorblock_categories is not None: if self.plugin_options.remove_sponsorblock_categories is not None:
remove_chapters_post_processor[ remove_chapters_post_processor["remove_sponsor_segments"] = (
"remove_sponsor_segments" self.plugin_options.remove_sponsorblock_categories
] = self.plugin_options.remove_sponsorblock_categories )
if self.plugin_options.remove_chapters_regex is not None: if self.plugin_options.remove_chapters_regex is not None:
remove_chapters_post_processor[ remove_chapters_post_processor["remove_chapters_patterns"] = (
"remove_chapters_patterns" self.plugin_options.remove_chapters_regex
] = self.plugin_options.remove_chapters_regex )
if self.plugin_options.embed_chapters: if self.plugin_options.embed_chapters:
builder.add( builder.add(

View file

@ -57,7 +57,7 @@ class DateRangeOptions(ToggleableOptionsDictValidator):
""" """
:expected type: Optional[OverridesFormatter] :expected type: Optional[OverridesFormatter]
:description: :description:
Only download videos before this datetime. Only download videos after this datetime.
""" """
return self._after return self._after

View file

@ -1,4 +1,6 @@
from collections import defaultdict from collections import defaultdict
from datetime import datetime
from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -9,6 +11,7 @@ from ytdl_sub.config.validators.options import OptionsDictValidator
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
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
@ -36,6 +39,17 @@ def _is_multi_field(tag_name: str) -> bool:
} }
def _is_date_field(tag_name: str) -> bool:
return tag_name in {
"date",
"original_date",
}
def _to_datetime(tag_value: str) -> Any:
return datetime.strptime(tag_value, "%Y-%m-%d")
class MusicTagsOptions(OptionsDictValidator): class MusicTagsOptions(OptionsDictValidator):
""" """
Adds tags to every download audio file using Adds tags to every download audio file using
@ -46,6 +60,9 @@ class MusicTagsOptions(OptionsDictValidator):
a full list of tags for various file types in MediaFile's a full list of tags for various file types in MediaFile's
`source code <https://github.com/beetbox/mediafile/blob/v0.9.0/mediafile.py#L1770>`_. `source code <https://github.com/beetbox/mediafile/blob/v0.9.0/mediafile.py#L1770>`_.
Note that the date fields ``date`` and ``original_date`` expected a standardized date in the
form of YYYY-MM-DD. The variable ``upload_date_standardized`` returns a compatible format.
:Usage: :Usage:
.. code-block:: yaml .. code-block:: yaml
@ -62,6 +79,7 @@ class MusicTagsOptions(OptionsDictValidator):
albumartists: albumartists:
- "{artist}" - "{artist}"
- "ytdl-sub" - "ytdl-sub"
date: "{upload_date_standardized}"
""" """
_optional_keys = set(list(mediafile.MediaFile.sorted_fields())) _optional_keys = set(list(mediafile.MediaFile.sorted_fields()))
@ -104,12 +122,26 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
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].append(tag_value) tags_to_write[tag_name].append(tag_value)
if _is_date_field(tag_name):
try:
if len(tags_to_write[tag_name]) != 1:
raise ValueError("caught below")
_ = _to_datetime(tags_to_write[tag_name][0])
except Exception as exc:
raise ValidationException(
"Date-based music tags must be a single tag in the form of YYYY-MM-DD"
) from exc
# write the actual tags if its not a dry run # write the actual tags if its not a dry run
if not self.is_dry_run: if not self.is_dry_run:
audio_file = mediafile.MediaFile(entry.get_download_file_path()) audio_file = mediafile.MediaFile(entry.get_download_file_path())
for tag_name, tag_value in tags_to_write.items(): for tag_name, tag_value in tags_to_write.items():
# If the attribute is a date-type, set it as a datetime type
if _is_date_field(tag_name):
setattr(audio_file, tag_name, _to_datetime(tag_value[0]))
# If the attribute is a multi-type, set it as the list type # If the attribute is a multi-type, set it as the list type
if _is_multi_field(tag_name): elif _is_multi_field(tag_name):
setattr(audio_file, tag_name, tag_value) setattr(audio_file, tag_name, tag_value)
# Otherwise, set as single value # Otherwise, set as single value
else: else:

View file

@ -89,7 +89,6 @@ class SplitByChaptersOptions(OptionsDictValidator):
return { return {
PluginOperation.MODIFY_ENTRY: { PluginOperation.MODIFY_ENTRY: {
"chapter_title", "chapter_title",
"chapter_title_sanitized",
"chapter_index", "chapter_index",
"chapter_index_padded", "chapter_index_padded",
"chapter_count", "chapter_count",
@ -160,9 +159,9 @@ class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]):
metadata_value_dict = {} metadata_value_dict = {}
if self.is_dry_run: if self.is_dry_run:
metadata_value_dict[ metadata_value_dict["Warning"] = (
"Warning" "Dry-run assumes embedded chapters with no modifications"
] = "Dry-run assumes embedded chapters with no modifications" )
metadata_value_dict["Source Title"] = new_entry.title metadata_value_dict["Source Title"] = new_entry.title
metadata_value_dict["Segment"] = f"{timestamp_begin} - {timestamp_end}" metadata_value_dict["Segment"] = f"{timestamp_begin} - {timestamp_end}"

View file

@ -1,18 +1,8 @@
presets: presets:
"Only Recent": #############################################################################
preset: # Only Recent Archive
- "Only Recent Archive" # Downloads only `date_range` amount of videos (no deletion)
# Only fetch videos after today minus date_range
# Only keep files uploaded after date_range
output_options:
keep_files_after: "today-{only_recent_date_range}"
keep_max_files: "{only_recent_max_files}"
overrides:
only_recent_max_files: 0
"Only Recent Archive": "Only Recent Archive":
# Only fetch videos after today minus date_range # Only fetch videos after today minus date_range
@ -24,8 +14,28 @@ presets:
date_range: "2months" # keep for legacy-reasons date_range: "2months" # keep for legacy-reasons
only_recent_date_range: "{date_range}" only_recent_date_range: "{date_range}"
#############################################################################
# Only Recent
# Downloads only `date_range` amount of videos and deletes older videos
# that fall out of that range
chunk_initial_download: "Only Recent":
preset:
- "Only Recent Archive"
output_options:
keep_files_after: "today-{only_recent_date_range}"
keep_max_files: "{only_recent_max_files}"
overrides:
only_recent_max_files: 0
#############################################################################
# Download in Chunks
# Will only download 20 videos per invocation of ytdl-sub, starting
# at the very beginning of the channel
chunk_initial_download: # legacy preset name
ytdl_options: ytdl_options:
max_downloads: 20 max_downloads: 20
playlistreverse: True playlistreverse: True

View file

@ -1,5 +1,9 @@
presets: presets:
#############################################################################
# Best Video Quality
# Gets the best available quality
best_video_quality: best_video_quality:
format: "bestvideo+bestaudio/best" format: "bestvideo+bestaudio/best"
ytdl_options: ytdl_options:
@ -9,18 +13,26 @@ presets:
preset: preset:
- best_video_quality - best_video_quality
#############################################################################
# Max 2160p
"Max 2160p": "Max 2160p":
format: "(bv*[height<=2160]+bestaudio/best[height<=2160])" format: "(bv*[height<=2160]+bestaudio/best[height<=2160])"
ytdl_options: ytdl_options:
merge_output_format: "mp4" merge_output_format: "mp4"
#############################################################################
# Max 1440p
"Max 1440p": "Max 1440p":
format: "(bv*[height<=1440]+bestaudio/best[height<=1440])" format: "(bv*[height<=1440]+bestaudio/best[height<=1440])"
ytdl_options: ytdl_options:
merge_output_format: "mp4" merge_output_format: "mp4"
max_1080p: #############################################################################
# Max 1080p
max_1080p: # legacy name
format: "(bv*[height<=1080]+bestaudio/best[height<=1080])" format: "(bv*[height<=1080]+bestaudio/best[height<=1080])"
ytdl_options: ytdl_options:
merge_output_format: "mp4" merge_output_format: "mp4"
@ -29,11 +41,17 @@ presets:
preset: preset:
- max_1080p - max_1080p
#############################################################################
# Max 720p
"Max 720p": "Max 720p":
format: "(bv*[height<=720]+bestaudio/best[height<=720])" format: "(bv*[height<=720]+bestaudio/best[height<=720])"
ytdl_options: ytdl_options:
merge_output_format: "mp4" merge_output_format: "mp4"
#############################################################################
# Max 480p
"Max 480p": "Max 480p":
format: "(bv*[height<=480]+bestaudio/best[height<=480])" format: "(bv*[height<=480]+bestaudio/best[height<=480])"
ytdl_options: ytdl_options:

View file

@ -330,3 +330,418 @@ presets:
url98: "{subscription_value_98}" url98: "{subscription_value_98}"
url99: "{subscription_value_99}" url99: "{subscription_value_99}"
url100: "{subscription_value_100}" url100: "{subscription_value_100}"
# multi-url with bilateral scraping built into it via
# inspection of the URL and conditionally adding another
# ytdl-sub url download with the scraping and download reversed
_multi_url_bilateral_inner:
preset:
- "_url_bilateral_overrides"
download:
- url: "{%bilateral_url(url) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url2) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url3) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url4) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url5) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url6) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url7) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url8) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url9) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url10) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url11) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url12) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url13) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url14) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url15) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url16) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url17) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url18) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url19) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url20) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url21) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url22) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url23) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url24) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url25) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url26) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url27) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url28) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url29) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url30) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url31) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url32) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url33) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url34) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url35) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url36) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url37) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url38) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url39) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url40) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url41) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url42) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url43) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url44) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url45) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url46) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url47) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url48) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url49) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url50) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url51) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url52) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url53) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url54) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url55) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url56) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url57) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url58) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url59) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url60) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url61) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url62) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url63) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url64) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url65) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url66) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url67) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url68) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url69) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url70) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url71) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url72) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url73) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url74) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url75) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url76) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url77) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url78) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url79) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url80) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url81) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url82) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url83) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url84) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url85) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url86) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url87) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url88) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url89) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url90) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url91) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url92) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url93) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url94) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url95) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url96) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url97) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url98) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{ %bilateral_url(url99) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
- url: "{%bilateral_url(url100) }"
download_reverse: False
ytdl_options:
playlist_items: "-1:0:-1"
_multi_url_bilateral:
preset:
- "_multi_url_bilateral_inner"
- "_multi_url"

View file

@ -0,0 +1,21 @@
presets:
# multi-url with bilateral scraping built into it via
# inspection of the URL and conditionally adding another
# ytdl-sub url download with the scraping and download reversed
_url_bilateral_overrides:
overrides:
enable_bilateral_scraping: True
"%is_bilateral_url": >-
{ %contains( $0, "youtube.com/playlist" ) }
"%bilateral_url": >-
{
%if(
%and(
enable_bilateral_scraping,
subscription_has_download_archive,
%is_bilateral_url($0)
),
$0,
""
)
}

View file

@ -5,7 +5,6 @@ presets:
# Download using the multi_url strategy # Download using the multi_url strategy
download: download:
download_strategy: "multi_url"
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

View file

@ -24,6 +24,8 @@ presets:
track: "{track_number}" track: "{track_number}"
tracktotal: "{track_total}" tracktotal: "{track_total}"
year: "{track_year}" year: "{track_year}"
date: "{track_date}"
original_date: "{track_original_date}"
# multi-tags # multi-tags
artists: artists:
- "{track_artist}" - "{track_artist}"
@ -53,6 +55,8 @@ presets:
track_number_padded: "01" track_number_padded: "01"
track_total: "1" track_total: "1"
track_year: "{upload_year}" track_year: "{upload_year}"
track_date: "{upload_date_standardized}"
track_original_date: "{track_date}"
track_genre: "{subscription_indent_1}" track_genre: "{subscription_indent_1}"
# Directory Overrides # Directory Overrides

View file

@ -4,6 +4,7 @@ presets:
output_directory: "{music_video_directory}" output_directory: "{music_video_directory}"
file_name: "{music_video_file_name}.{ext}" file_name: "{music_video_file_name}.{ext}"
thumbnail_name: "{music_video_file_name}.jpg" thumbnail_name: "{music_video_file_name}.jpg"
info_json_name: "{music_video_file_name}.{info_json_ext}"
maintain_download_archive: True maintain_download_archive: True
ytdl_options: ytdl_options:

View file

@ -52,6 +52,8 @@ class TvShowCollectionEpisodeFormattingPresets(PrebuiltPresets):
class TvShowCollectionSeasonPresets(PrebuiltPresets): class TvShowCollectionSeasonPresets(PrebuiltPresets):
"""Now Deprecated"""
preset_names = { preset_names = {
"collection_season_1", "collection_season_1",
"collection_season_2", "collection_season_2",

View file

@ -31,23 +31,6 @@ presets:
#################################################################################################### ####################################################################################################
# TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart
_tv_show_by_date:
preset: "_multi_url"
overrides:
avatar_uncropped_thumbnail_file_name: "{tv_show_poster_file_name}"
banner_uncropped_thumbnail_file_name: "{tv_show_fanart_file_name}"
####################################################################################################
# TV show from a collection. Must specify additional `tv_show_collection_season` presets in
# addition. Each season sets its own `collection_season_number/_padded`
_tv_show_collection:
overrides:
collection_season_number_padded: "{ %pad_zero(%int(collection_season_number), 2) }"
season_number: "{collection_season_number}"
season_number_padded: "{collection_season_number_padded}"
_episode_video_tags: _episode_video_tags:
video_tags: video_tags:
show: "{tv_show_name}" show: "{tv_show_name}"

View file

@ -31,6 +31,15 @@ presets:
- "plex_tv_show_by_date" - "plex_tv_show_by_date"
- "season_by_year__episode_by_month_day" - "season_by_year__episode_by_month_day"
####################################################################################################
# TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart
_tv_show_by_date:
preset: "_multi_url_bilateral"
overrides:
avatar_uncropped_thumbnail_file_name: "{tv_show_poster_file_name}"
banner_uncropped_thumbnail_file_name: "{tv_show_fanart_file_name}"
#################################################################################################### ####################################################################################################
_season_by_year: _season_by_year:

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,9 @@
from ytdl_sub.script.types.array import Array
from ytdl_sub.script.types.map import Map
from ytdl_sub.script.types.resolvable import AnyArgument from ytdl_sub.script.types.resolvable import AnyArgument
from ytdl_sub.script.types.resolvable import Boolean from ytdl_sub.script.types.resolvable import Boolean
from ytdl_sub.script.types.resolvable import Float
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import String from ytdl_sub.script.types.resolvable import String
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -107,3 +111,59 @@ class BooleanFunctions:
Returns True if a value is null (i.e. an empty string). False otherwise. Returns True if a value is null (i.e. an empty string). False otherwise.
""" """
return Boolean(isinstance(value, String) and value.value == "") return Boolean(isinstance(value, String) and value.value == "")
@staticmethod
def is_map(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is a Map. False otherwise.
"""
return Boolean(isinstance(value, Map))
@staticmethod
def is_array(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is a Map. False otherwise.
"""
return Boolean(isinstance(value, Array))
@staticmethod
def is_string(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is a String. False otherwise.
"""
return Boolean(isinstance(value, String))
@staticmethod
def is_numeric(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is either an Integer or Float. False otherwise.
"""
return Boolean(isinstance(value, (Integer, Float)))
@staticmethod
def is_int(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is an Integer. False otherwise.
"""
return Boolean(isinstance(value, Integer))
@staticmethod
def is_float(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is a Float. False otherwise.
"""
return Boolean(isinstance(value, Float))
@staticmethod
def is_bool(value: AnyArgument) -> Boolean:
"""
:description:
Returns True if a value is a Float. False otherwise.
"""
return Boolean(isinstance(value, Boolean))

View file

@ -1,3 +1,4 @@
from typing import Dict
from typing import Optional from typing import Optional
from ytdl_sub.script.types.array import Array from ytdl_sub.script.types.array import Array
@ -66,6 +67,19 @@ class MapFunctions:
) )
return mapping.value[key] return mapping.value[key]
@staticmethod
def map_extend(*maps: Map) -> Map:
"""
:description:
Return maps combined in the order from left-to-right. Duplicate keys will use the
right-most map's value.
"""
output_dict: Dict = {}
for map_i in maps:
output_dict |= map_i.value
return Map(output_dict)
@staticmethod @staticmethod
def map_get_non_empty(mapping: Map, key: AnyArgument, default: AnyArgument) -> AnyArgument: def map_get_non_empty(mapping: Map, key: AnyArgument, default: AnyArgument) -> AnyArgument:
""" """

View file

@ -52,3 +52,13 @@ class RegexFunctions:
Returns number of capture groups in regex Returns number of capture groups in regex
""" """
return Integer(re.compile(regex.value).groups) return Integer(re.compile(regex.value).groups)
@staticmethod
def regex_sub(regex: String, replacement: String, string: String) -> String:
"""
:description:
Returns the string obtained by replacing the leftmost non-overlapping occurrences of the
pattern in string by the replacement string. The replacement string can reference the
match groups via backslash escapes. Callables as replacement argument are not supported.
"""
return String(re.sub(regex.value, replacement.value, string.value))

View file

@ -73,6 +73,8 @@ CUSTOM_FUNCTION_ARGUMENTS_ONLY_ARGS = InvalidSyntaxException(
FUNCTION_INVALID_CHAR = InvalidSyntaxException("Invalid value when parsing a function") FUNCTION_INVALID_CHAR = InvalidSyntaxException("Invalid value when parsing a function")
BRACKET_INVALID_CHAR = InvalidSyntaxException("Invalid value within brackets")
def _UNEXPECTED_CHAR_ARGUMENT(arg_type: ParsedArgType): def _UNEXPECTED_CHAR_ARGUMENT(arg_type: ParsedArgType):
return InvalidSyntaxException(f"Unexpected character when parsing {arg_type.value} arguments") return InvalidSyntaxException(f"Unexpected character when parsing {arg_type.value} arguments")
@ -245,7 +247,7 @@ class _Parser:
if self._read(increment_pos=False) == "-": if self._read(increment_pos=False) == "-":
numeric_string += "-" numeric_string += "-"
self._pos += 1 self._pos += 1
if has_decimal := (self._read(increment_pos=False) == "."): if has_decimal := self._read(increment_pos=False) == ".":
numeric_string += "." numeric_string += "."
self._pos += 1 self._pos += 1
@ -496,15 +498,23 @@ class _Parser:
raise MAP_KEY_WITH_NO_VALUE raise MAP_KEY_WITH_NO_VALUE
if isinstance(key, NonHashable): if isinstance(key, NonHashable):
raise MAP_KEY_NOT_HASHABLE raise MAP_KEY_NOT_HASHABLE
if isinstance(key, BuiltInFunction) and issubclass(key.output_type(), NonHashable):
raise MAP_KEY_NOT_HASHABLE
if len(value_args) > 1: if len(value_args) > 1:
raise MAP_KEY_MULTIPLE_VALUES raise MAP_KEY_MULTIPLE_VALUES
output[key] = value_args[0] output[key] = value_args[0]
key = None key = None
else: else:
break
raise UNREACHABLE raise UNREACHABLE
def _parse_main_loop(self, ch: str) -> bool: def _parse_main_loop(self, ch: str) -> bool:
if ch == "\\" and self._read(increment_pos=False) in {"{", "}"}:
# Escape brackets are \{ and \}, only add the second char
self._literal_str += self._read()
return True
if ch == "}": if ch == "}":
if self._bracket_counter == 0: if self._bracket_counter == 0:
raise BRACKET_NOT_CLOSED raise BRACKET_NOT_CLOSED
@ -558,9 +568,9 @@ class _Parser:
elif self._bracket_counter == 0: elif self._bracket_counter == 0:
# Only accumulate literal str if not in brackets # Only accumulate literal str if not in brackets
self._literal_str += ch self._literal_str += ch
else: elif not ch.isspace():
# Should only be possible to get here if it's a space self._set_highlight_position(pos=self._pos - 1)
assert ch.isspace() raise BRACKET_INVALID_CHAR
return True return True

View file

@ -10,6 +10,7 @@ from ytdl_sub.script.script_output import ScriptOutput
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 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 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.exceptions import CycleDetected from ytdl_sub.script.utils.exceptions import CycleDetected
@ -257,12 +258,23 @@ class Script:
f"Output filter variable contains the variable {var_dep} " f"Output filter variable contains the variable {var_dep} "
f"which is set as unresolvable" f"which is set as unresolvable"
) )
# Do not recurse custom function arguments since they have no deps
if isinstance(var_dep, FunctionArgument):
continue
subset_to_resolve.add(var_dep.name) subset_to_resolve.add(var_dep.name)
subset_to_resolve |= self._recursive_get_unresolved_output_filter_variables( subset_to_resolve |= self._recursive_get_unresolved_output_filter_variables(
current_var=self._variables[var_dep.name], current_var=self._variables[var_dep.name],
subset_to_resolve=subset_to_resolve, subset_to_resolve=subset_to_resolve,
unresolvable=unresolvable, unresolvable=unresolvable,
) )
for custom_func_dep in current_var.custom_functions:
subset_to_resolve |= self._recursive_get_unresolved_output_filter_variables(
current_var=self._functions[custom_func_dep.name],
subset_to_resolve=subset_to_resolve,
unresolvable=unresolvable,
)
return subset_to_resolve return subset_to_resolve
@ -440,18 +452,34 @@ class Script:
self self
""" """
added_variables_to_validate: Set[str] = set() added_variables_to_validate: Set[str] = set()
for variable_name, variable_definition in variables.items():
self._variables[variable_name] = parse( functions_to_add = {
text=variable_definition, _function_name(name): definition
name=variable_name, 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, definition in definitions.items():
parsed = parse(
text=definition,
name=name,
custom_function_names=set(self._functions.keys()), custom_function_names=set(self._functions.keys()),
variable_names=set(self._variables.keys()) variable_names=set(self._variables.keys())
.union(variables.keys()) .union(variables.keys())
.union(unresolvable or set()), .union(unresolvable or set()),
) )
if self._variables[variable_name].maybe_resolvable is None: if parsed.maybe_resolvable is None:
added_variables_to_validate.add(variable_name) 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: if added_variables_to_validate:
self._validate(added_variables=added_variables_to_validate) self._validate(added_variables=added_variables_to_validate)

View file

@ -291,3 +291,6 @@ class BuiltInFunction(Function, BuiltInFunctionType):
raise FunctionRuntimeException( raise FunctionRuntimeException(
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 __hash__(self):
return hash((self.name, *self.args))

View file

@ -8,11 +8,11 @@ from ytdl_sub.script.utils.exceptions import UserException
from ytdl_sub.script.utils.type_checking import FunctionSpec from ytdl_sub.script.utils.type_checking import FunctionSpec
from ytdl_sub.script.utils.type_checking import is_union from ytdl_sub.script.utils.type_checking import is_union
TUserException = TypeVar("TUserException", bound=UserException) UserExceptionT = TypeVar("UserExceptionT", bound=UserException)
class ParserExceptionFormatter: class ParserExceptionFormatter:
def __init__(self, text: str, start: int, end: int, exception: TUserException): def __init__(self, text: str, start: int, end: int, exception: UserExceptionT):
self._text = text self._text = text
self._start = start self._start = start
self._end = end self._end = end
@ -78,7 +78,7 @@ class ParserExceptionFormatter:
return "\n" + "\n".join(to_return) return "\n" + "\n".join(to_return)
def highlight(self) -> TUserException: def highlight(self) -> UserExceptionT:
""" """
Returns Returns
------- -------

View file

@ -23,7 +23,7 @@ from ytdl_sub.script.types.resolvable import Resolvable
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
TLambda = TypeVar("TLambda", bound=Lambda) LambdaT = TypeVar("LambdaT", bound=Lambda)
def is_union(arg_type: Type) -> bool: def is_union(arg_type: Type) -> bool:
@ -210,7 +210,7 @@ class FunctionSpec:
return None return None
@property @property
def is_lambda_like(self) -> Optional[Type[TLambda]]: def is_lambda_like(self) -> Optional[Type[LambdaT]]:
""" """
Returns Returns
------- -------

View file

@ -9,6 +9,7 @@ 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.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.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.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -16,6 +17,24 @@ from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadAr
logger = Logger.get("subscription") logger = Logger.get("subscription")
def _initialize_download_archive(
output_options: OutputOptions,
overrides: Overrides,
working_directory: str,
output_directory: str,
) -> EnhancedDownloadArchive:
migrated_file_name: Optional[str] = None
if migrated_file_name_option := output_options.migrated_download_archive_name:
migrated_file_name = overrides.apply_formatter(migrated_file_name_option)
return EnhancedDownloadArchive(
file_name=overrides.apply_formatter(output_options.download_archive_name),
working_directory=working_directory,
output_directory=output_directory,
migrated_file_name=migrated_file_name,
).reinitialize(dry_run=True)
class BaseSubscription(ABC): class BaseSubscription(ABC):
""" """
Subscription classes are the 'controllers' that perform... Subscription classes are the 'controllers' that perform...
@ -48,20 +67,43 @@ class BaseSubscription(ABC):
self._config_options = config_options self._config_options = config_options
self._preset_options = preset_options self._preset_options = preset_options
migrated_file_name: Optional[str] = None # Add overrides pre-archive
if migrated_file_name_option := self.output_options.migrated_download_archive_name: self.overrides.add(
migrated_file_name = self.overrides.apply_formatter(migrated_file_name_option) {
SubscriptionVariables.subscription_name(): self.name,
}
)
# TODO: Do not include this as part of the subscription self._enhanced_download_archive: Optional[EnhancedDownloadArchive] = (
self._enhanced_download_archive = EnhancedDownloadArchive( _initialize_download_archive(
file_name=self.overrides.apply_formatter(self.output_options.download_archive_name), output_options=self.output_options,
overrides=self.overrides,
working_directory=self.working_directory, working_directory=self.working_directory,
output_directory=self.output_directory, output_directory=self.output_directory,
migrated_file_name=migrated_file_name, )
)
# Add post-archive variables
self.overrides.add(
{
SubscriptionVariables.subscription_has_download_archive(): f"""{{
%bool({self.download_archive.num_entries > 0})
}}""",
}
) )
self._exception: Optional[Exception] = None self._exception: Optional[Exception] = None
@property
def download_archive(self) -> EnhancedDownloadArchive:
"""
Returns
-------
Initialized download archive
"""
assert self._enhanced_download_archive is not None
return self._enhanced_download_archive
@property @property
def downloader_options(self) -> MultiUrlValidator: def downloader_options(self) -> MultiUrlValidator:
""" """
@ -141,7 +183,7 @@ class BaseSubscription(ABC):
------- -------
Number of entries added Number of entries added
""" """
return self._enhanced_download_archive.num_entries_added return self.download_archive.num_entries_added
@property @property
def num_entries_modified(self) -> int: def num_entries_modified(self) -> int:
@ -150,7 +192,7 @@ class BaseSubscription(ABC):
------- -------
Number of entries modified Number of entries modified
""" """
return self._enhanced_download_archive.num_entries_modified return self.download_archive.num_entries_modified
@property @property
def num_entries_removed(self) -> int: def num_entries_removed(self) -> int:
@ -159,7 +201,7 @@ class BaseSubscription(ABC):
------- -------
Number of entries removed Number of entries removed
""" """
return self._enhanced_download_archive.num_entries_removed return self.download_archive.num_entries_removed
@property @property
def num_entries(self) -> int: def num_entries(self) -> int:
@ -168,7 +210,7 @@ class BaseSubscription(ABC):
------- -------
The number of entries The number of entries
""" """
return self._enhanced_download_archive.num_entries return self.download_archive.num_entries
@property @property
def transaction_log(self) -> FileHandlerTransactionLog: def transaction_log(self) -> FileHandlerTransactionLog:
@ -177,7 +219,7 @@ class BaseSubscription(ABC):
------- -------
Transaction log from the subscription Transaction log from the subscription
""" """
return self._enhanced_download_archive.get_file_handler_transaction_log() return self.download_archive.get_file_handler_transaction_log()
@property @property
def exception(self) -> Optional[Exception]: def exception(self) -> Optional[Exception]:

View file

@ -67,7 +67,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
output_file_name = self.overrides.apply_formatter( output_file_name = self.overrides.apply_formatter(
formatter=self.output_options.file_name, entry=entry formatter=self.output_options.file_name, entry=entry
) )
self._enhanced_download_archive.save_file_to_output_directory( self.download_archive.save_file_to_output_directory(
file_name=entry.get_download_file_name(), file_name=entry.get_download_file_name(),
file_metadata=entry_metadata, file_metadata=entry_metadata,
output_file_name=output_file_name, output_file_name=output_file_name,
@ -81,7 +81,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
) )
# Copy the thumbnails since they could be used later for other things # Copy the thumbnails since they could be used later for other things
self._enhanced_download_archive.save_file_to_output_directory( self.download_archive.save_file_to_output_directory(
file_name=entry.get_download_thumbnail_name(), file_name=entry.get_download_thumbnail_name(),
output_file_name=output_thumbnail_name, output_file_name=output_thumbnail_name,
entry=entry, entry=entry,
@ -101,7 +101,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
if not dry_run: if not dry_run:
entry.write_info_json() entry.write_info_json()
self._enhanced_download_archive.save_file_to_output_directory( self.download_archive.save_file_to_output_directory(
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,
@ -126,7 +126,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
except Exception as exc: except Exception as exc:
self._delete_working_directory(is_error=True) self._delete_working_directory(is_error=True)
raise exc raise exc
else:
self._delete_working_directory() self._delete_working_directory()
@contextlib.contextmanager @contextlib.contextmanager
@ -135,7 +135,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
Context manager to initialize the enhanced download archive Context manager to initialize the enhanced download archive
""" """
if self.maintain_download_archive: if self.maintain_download_archive:
self._enhanced_download_archive.prepare_download_archive() self.download_archive.prepare_download_archive()
yield yield
@ -156,19 +156,19 @@ class SubscriptionDownload(BaseSubscription, ABC):
) )
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:
self._enhanced_download_archive.remove_stale_files( self.download_archive.remove_stale_files(
date_range=date_range_to_keep, keep_max_files=keep_max_files date_range=date_range_to_keep, keep_max_files=keep_max_files
) )
self._enhanced_download_archive.save_download_mappings() self.download_archive.save_download_mappings()
FileHandler.delete(self._enhanced_download_archive.working_file_path) FileHandler.delete(self.download_archive.working_ytdl_file_path)
@contextlib.contextmanager @contextlib.contextmanager
def _remove_empty_directories_in_output_directory(self): def _remove_empty_directories_in_output_directory(self):
try: try:
yield yield
finally: finally:
if not self._enhanced_download_archive.is_dry_run: if not self.download_archive.is_dry_run:
for root, dir_names, _ in os.walk(Path(self.output_directory), topdown=False): for root, dir_names, _ in os.walk(Path(self.output_directory), topdown=False):
for dir_name in dir_names: for dir_name in dir_names:
dir_path = Path(root) / dir_name dir_path = Path(root) / dir_name
@ -194,7 +194,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
plugin_type( plugin_type(
options=plugin_options, options=plugin_options,
overrides=self.overrides, overrides=self.overrides,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self.download_archive,
) )
for plugin_type, plugin_options in self.plugins.zipped() for plugin_type, plugin_options in self.plugins.zipped()
] ]
@ -233,7 +233,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
# Re-save the download archive after each entry is moved to the output directory # Re-save the download archive after each entry is moved to the output directory
if self.maintain_download_archive: if self.maintain_download_archive:
self._enhanced_download_archive.save_download_mappings() self.download_archive.save_download_mappings()
def _process_entry( def _process_entry(
self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata
@ -323,7 +323,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
for plugin in plugins: for plugin in plugins:
plugin.post_process_subscription() plugin.post_process_subscription()
return self._enhanced_download_archive.get_file_handler_transaction_log() return self.download_archive.get_file_handler_transaction_log()
def download(self, dry_run: bool = False) -> FileHandlerTransactionLog: def download(self, dry_run: bool = False) -> FileHandlerTransactionLog:
""" """
@ -336,14 +336,14 @@ class SubscriptionDownload(BaseSubscription, ABC):
directory. directory.
""" """
self._exception = None self._exception = None
self._enhanced_download_archive.reinitialize(dry_run=dry_run) self.download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins() plugins = self._initialize_plugins()
subscription_ytdl_options = SubscriptionYTDLOptions( subscription_ytdl_options = SubscriptionYTDLOptions(
preset=self._preset_options, preset=self._preset_options,
plugins=plugins, plugins=plugins,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self.download_archive,
overrides=self.overrides, overrides=self.overrides,
working_directory=self.working_directory, working_directory=self.working_directory,
dry_run=dry_run, dry_run=dry_run,
@ -351,7 +351,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
downloader = MultiUrlDownloader( downloader = MultiUrlDownloader(
options=self.downloader_options, options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self.download_archive,
download_ytdl_options=subscription_ytdl_options.download_builder(), download_ytdl_options=subscription_ytdl_options.download_builder(),
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(), metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
overrides=self.overrides, overrides=self.overrides,
@ -389,14 +389,14 @@ class SubscriptionDownload(BaseSubscription, ABC):
If true, do not modify any video/audio files or move anything to the output directory. If true, do not modify any video/audio files or move anything to the output directory.
""" """
self._exception = None self._exception = None
self._enhanced_download_archive.reinitialize(dry_run=dry_run) self.download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins() plugins = self._initialize_plugins()
subscription_ytdl_options = SubscriptionYTDLOptions( subscription_ytdl_options = SubscriptionYTDLOptions(
preset=self._preset_options, preset=self._preset_options,
plugins=plugins, plugins=plugins,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self.download_archive,
overrides=self.overrides, overrides=self.overrides,
working_directory=self.working_directory, working_directory=self.working_directory,
dry_run=dry_run, dry_run=dry_run,
@ -406,7 +406,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
plugins.extend( plugins.extend(
MultiUrlDownloader( MultiUrlDownloader(
options=self.downloader_options, options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self.download_archive,
download_ytdl_options=subscription_ytdl_options.download_builder(), download_ytdl_options=subscription_ytdl_options.download_builder(),
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(), metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
overrides=self.overrides, overrides=self.overrides,
@ -415,7 +415,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
downloader = InfoJsonDownloader( downloader = InfoJsonDownloader(
options=InfoJsonDownloaderOptions(name="no-op", value={}), options=InfoJsonDownloaderOptions(name="no-op", value={}),
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self.download_archive,
download_ytdl_options=YTDLOptionsBuilder(), download_ytdl_options=YTDLOptionsBuilder(),
metadata_ytdl_options=YTDLOptionsBuilder(), metadata_ytdl_options=YTDLOptionsBuilder(),
overrides=self.overrides, overrides=self.overrides,

View file

@ -32,7 +32,7 @@ class SubscriptionOutput(Validator, ABC):
indent overrides to merge with the preset dict's overrides indent overrides to merge with the preset dict's overrides
""" """
return { return {
SubscriptionVariables.subscription_indent_i(i): self._indent_overrides[i] SubscriptionVariables.subscription_indent_i(i).variable_name: self._indent_overrides[i]
for i in range(len(self._indent_overrides)) for i in range(len(self._indent_overrides))
} }
@ -143,7 +143,9 @@ class SubscriptionValueValidator(SubscriptionLeafValidator, StringValidator):
presets=presets, presets=presets,
indent_overrides=indent_overrides, indent_overrides=indent_overrides,
) )
self._overrides_to_add[SubscriptionVariables.subscription_value()] = self.value self._overrides_to_add[SubscriptionVariables.subscription_value().variable_name] = (
self.value
)
class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValidator): class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValidator):
@ -168,12 +170,12 @@ class SubscriptionListValuesValidator(SubscriptionLeafValidator, StringListValid
for idx, list_value in enumerate(self.list): for idx, list_value in enumerate(self.list):
# Write the first list value into subscription_value as well # Write the first list value into subscription_value as well
if idx == 0: if idx == 0:
self._overrides_to_add[ self._overrides_to_add[SubscriptionVariables.subscription_value().variable_name] = (
SubscriptionVariables.subscription_value() list_value.value
] = list_value.value )
self._overrides_to_add[ self._overrides_to_add[
SubscriptionVariables.subscription_value_i(index=idx) SubscriptionVariables.subscription_value_i(index=idx).variable_name
] = list_value.value ] = list_value.value
@ -217,8 +219,8 @@ class SubscriptionMapValidator(SubscriptionLeafValidator, LiteralDictValidator):
presets=presets, presets=presets,
indent_overrides=indent_overrides, indent_overrides=indent_overrides,
) )
self._overrides_to_add[SubscriptionVariables.subscription_map()] = ScriptUtils.to_script( self._overrides_to_add[SubscriptionVariables.subscription_map().variable_name] = (
self.dict ScriptUtils.to_script(self.dict)
) )

View file

@ -89,7 +89,9 @@ class SubscriptionYTDLOptions:
ytdl_options = {} ytdl_options = {}
if self._preset.output_options.maintain_download_archive: if self._preset.output_options.maintain_download_archive:
ytdl_options["download_archive"] = self._enhanced_download_archive.working_file_path ytdl_options["download_archive"] = (
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 = int(
self._overrides.apply_formatter(self._preset.output_options.keep_max_files) self._overrides.apply_formatter(self._preset.output_options.keep_max_files)

View file

@ -385,7 +385,11 @@ class FileHandler:
dst_file_path dst_file_path
Destination file Destination file
""" """
shutil.copyfile(src=src_file_path, dst=dst_file_path) # Perform the copy by first writing to a temp file, then moving it.
# This tries to prevent corrupted writes if the processed dies mid-write,
atomic_dst = f"{dst_file_path}-ytdl-sub-incomplete"
shutil.copyfile(src=src_file_path, dst=atomic_dst)
shutil.move(src=atomic_dst, dst=dst_file_path)
@classmethod @classmethod
def move(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]): def move(cls, src_file_path: Union[str, Path], dst_file_path: Union[str, Path]):

View file

@ -213,9 +213,10 @@ class Logger:
@classmethod @classmethod
def _append_to_error_log(cls): def _append_to_error_log(cls):
# Any time an exception occurs, dump all debug logs into the error log # Any time an exception occurs, dump all debug logs into the error log
with open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs, open( with (
cls.error_log_filename(), mode="a", encoding="utf-8" open(cls.debug_log_filename(), mode="r", encoding="utf-8") as debug_logs,
) as error_logs: open(cls.error_log_filename(), mode="a", encoding="utf-8") as error_logs,
):
error_logs.writelines(debug_logs.readlines()) error_logs.writelines(debug_logs.readlines())
@classmethod @classmethod

View file

@ -34,12 +34,14 @@ def retry(times: int, exceptions: Tuple[Type[Exception], ...], wait_sec: int = 5
while attempt < times: while attempt < times:
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except exceptions: except exceptions as exc:
logger.debug( logger.debug(
"Exception thrown when attempting to run %s, attempt %d of %d", "Exception thrown when attempting to run %s, attempt %d of %d\n"
"Exception:\n%s",
func.__name__, func.__name__,
attempt + 1, attempt + 1,
times, times,
str(exc),
) )
attempt += 1 attempt += 1
sleep(wait_sec) sleep(wait_sec)

View file

@ -3,6 +3,22 @@ import re
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from ytdl_sub.script.parser import parse
from ytdl_sub.script.script import _is_function
from ytdl_sub.script.types.array import UnresolvedArray
from ytdl_sub.script.types.function import BuiltInFunction
from ytdl_sub.script.types.function import Function
from ytdl_sub.script.types.map import UnresolvedMap
from ytdl_sub.script.types.resolvable import Argument
from ytdl_sub.script.types.resolvable import Boolean
from ytdl_sub.script.types.resolvable import Float
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import String
from ytdl_sub.script.types.variable import Variable
from ytdl_sub.script.utils.exceptions import UNREACHABLE
# pylint: disable=too-many-return-statements
class ScriptUtils: class ScriptUtils:
@classmethod @classmethod
@ -11,7 +27,9 @@ class ScriptUtils:
Helper to add sanitized variables to a Script Helper to add sanitized variables to a Script
""" """
sanitized_variables = { sanitized_variables = {
f"{name}_sanitized": f"{{%sanitize({name})}}" for name in variables.keys() f"{name}_sanitized": f"{{%sanitize({name})}}"
for name in variables.keys()
if not _is_function(name)
} }
return dict(variables, **sanitized_variables) return dict(variables, **sanitized_variables)
@ -24,12 +42,12 @@ class ScriptUtils:
out = "" out = ""
elif isinstance(value, str): elif isinstance(value, str):
out = value out = value
elif isinstance(value, bool):
out = f"{{%bool({value})}}"
elif isinstance(value, int): elif isinstance(value, int):
out = f"{{%int({value})}}" out = f"{{%int({value})}}"
elif isinstance(value, float): elif isinstance(value, float):
out = f"{{%float({value})}}" out = f"{{%float({value})}}"
elif isinstance(value, bool):
out = f"{{%bool({value})}}"
else: else:
dumped_json = json.dumps(value, ensure_ascii=False, sort_keys=True) dumped_json = json.dumps(value, ensure_ascii=False, sort_keys=True)
# Remove triple-single-quotes from JSON to avoid parsing issues # Remove triple-single-quotes from JSON to avoid parsing issues
@ -39,6 +57,75 @@ class ScriptUtils:
return out return out
@classmethod
def _to_script_argument(cls, value: Any) -> Argument:
# Handle simple types as above
if value is None or (isinstance(value, str) and value == ""):
return String("")
if isinstance(value, str):
ast = parse(text=value).ast
if len(ast) == 1:
return ast[0]
return BuiltInFunction(
name="concat", args=[BuiltInFunction(name="string", args=[arg]) for arg in ast]
)
if isinstance(value, bool):
return Boolean(value)
if isinstance(value, int):
return Integer(value)
if isinstance(value, float):
return Float(value)
if isinstance(value, list):
return UnresolvedArray([cls._to_script_argument(val) for val in value])
if isinstance(value, dict):
return UnresolvedMap(
{
cls._to_script_argument(key): cls._to_script_argument(val)
for key, val in value.items()
}
)
raise UNREACHABLE
@classmethod
def _to_script_code(cls, arg: Argument, top_level: bool = False) -> str:
if not top_level and isinstance(arg, (Integer, Boolean, Float)):
return str(arg.native)
if isinstance(arg, String):
if arg.native == "":
return "" if top_level else "''"
return arg.native if top_level else f"'''{arg.native}'''"
if isinstance(arg, Integer):
out = f"%int({arg.native})"
elif isinstance(arg, Boolean):
out = f"%bool({arg.native})"
elif isinstance(arg, Float):
out = f"%float({arg.native})"
elif isinstance(arg, UnresolvedArray):
out = f"[ {', '.join(cls._to_script_code(val) for val in arg.value)} ]"
elif isinstance(arg, UnresolvedMap):
kv_list = (
f"{cls._to_script_code(key)}: {cls._to_script_code(val)}"
for key, val in arg.value.items()
)
out = f"{{ {', '.join(kv_list)} }}"
elif isinstance(arg, Variable):
out = arg.name
elif isinstance(arg, Function):
out = f"%{arg.name}( {', '.join(cls._to_script_code(val) for val in arg.args)} )"
else:
raise UNREACHABLE
return f"{{ {out} }}" if top_level else out
@classmethod
def to_native_script(cls, value: Any) -> str:
"""
Converts any JSON-compatible value into equivalent script syntax
"""
return cls._to_script_code(cls._to_script_argument(value), top_level=True)
@classmethod @classmethod
def bool_formatter_output(cls, output: str) -> bool: def bool_formatter_output(cls, output: str) -> bool:
""" """

View file

@ -9,13 +9,16 @@ from ytdl_sub.entries.script.function_scripts import CUSTOM_FUNCTION_SCRIPTS
from ytdl_sub.entries.script.variable_definitions import UNRESOLVED_VARIABLES from ytdl_sub.entries.script.variable_definitions import UNRESOLVED_VARIABLES
from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS from ytdl_sub.entries.script.variable_definitions import VARIABLE_SCRIPTS
from ytdl_sub.entries.script.variable_types import Variable from ytdl_sub.entries.script.variable_types import Variable
from ytdl_sub.entries.variables.override_variables import REQUIRED_OVERRIDE_VARIABLE_DEFINITIONS
from ytdl_sub.script.script import Script from ytdl_sub.script.script import Script
from ytdl_sub.script.utils.exceptions import RuntimeException from ytdl_sub.script.utils.exceptions import RuntimeException
from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.utils.script import ScriptUtils
BASE_SCRIPT: Script = Script( BASE_SCRIPT: Script = Script(
dict(ScriptUtils.add_sanitized_variables(VARIABLE_SCRIPTS), **CUSTOM_FUNCTION_SCRIPTS) ScriptUtils.add_sanitized_variables(VARIABLE_SCRIPTS)
| ScriptUtils.add_sanitized_variables(REQUIRED_OVERRIDE_VARIABLE_DEFINITIONS)
| CUSTOM_FUNCTION_SCRIPTS
) )

View file

@ -72,8 +72,7 @@ def download_and_convert_url_thumbnail(
if not thumbnail_url: if not thumbnail_url:
return None return None
# timeout after 8 seconds with urlopen(thumbnail_url, timeout=7.0) as file:
with urlopen(thumbnail_url, timeout=1.0) as file:
with tempfile.NamedTemporaryFile(delete=False) as thumbnail: with tempfile.NamedTemporaryFile(delete=False) as thumbnail:
thumbnail.write(file.read()) thumbnail.write(file.read())

View file

@ -11,6 +11,7 @@ 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.exceptions import UserException from ytdl_sub.script.utils.exceptions import UserException
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
from ytdl_sub.utils.script import ScriptUtils
from ytdl_sub.validators.validators import DictValidator from ytdl_sub.validators.validators import DictValidator
from ytdl_sub.validators.validators import ListValidator from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import LiteralDictValidator
@ -63,8 +64,6 @@ class StringFormatterValidator(StringValidator):
""" """
return self._value return self._value
# pylint: disable=no-self-use
def post_process(self, resolved: str) -> str: def post_process(self, resolved: str) -> str:
""" """
Returns Returns
@ -73,8 +72,6 @@ class StringFormatterValidator(StringValidator):
""" """
return resolved return resolved
# pylint: enable=no-self-use
# pylint: disable=line-too-long # pylint: disable=line-too-long
class OverridesStringFormatterValidator(StringFormatterValidator): class OverridesStringFormatterValidator(StringFormatterValidator):
@ -148,6 +145,18 @@ class OverridesDictFormatterValidator(DictFormatterValidator):
_key_validator = OverridesStringFormatterValidator _key_validator = OverridesStringFormatterValidator
class UnstructuredDictFormatterValidator(DictFormatterValidator):
def __init__(self, name, value):
# Convert the unstructured-ness into a script
if isinstance(value, dict):
value = {key: ScriptUtils.to_native_script(val) for key, val in value.items()}
super().__init__(name, value)
class UnstructuredOverridesDictFormatterValidator(UnstructuredDictFormatterValidator):
_key_validator = OverridesStringFormatterValidator
def to_variable_dependency_format_string(script: Script, parsed_format_string: SyntaxTree) -> str: 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. Create a dummy format string that contains all variable deps as a string.

View file

@ -169,6 +169,7 @@ class ListValidator(Validator, ABC, Generic[ValidatorT]):
Validates a list of objects to validate Validates a list of objects to validate
""" """
# pylint: disable=used-before-assignment
_expected_value_type = list _expected_value_type = list
_expected_value_type_name = "list" _expected_value_type_name = "list"

View file

@ -506,10 +506,19 @@ class EnhancedDownloadArchive:
""" """
Returns Returns
------- -------
The download mapping's file path in the working directory. The download mapping's file path in the working directory for ytdl usage
""" """
return str(Path(self.working_directory) / self.file_name) return str(Path(self.working_directory) / self.file_name)
@property
def working_ytdl_file_path(self) -> str:
"""
Returns
-------
The download mapping's file path in the working directory for ytdl usage
"""
return f"{self.working_file_path}-ytdl-archive"
@property @property
def mapping(self) -> DownloadMappings: def mapping(self) -> DownloadMappings:
""" """
@ -541,7 +550,7 @@ class EnhancedDownloadArchive:
return self return self
# Otherwise, create a ytdl download archive file in the working directory. # Otherwise, create a ytdl download archive file in the working directory.
self.mapping.to_download_archive().to_file(self.working_file_path) self.mapping.to_download_archive().to_file(self.working_ytdl_file_path)
return self return self
@ -603,15 +612,18 @@ class EnhancedDownloadArchive:
if self._migrated_file_name: if self._migrated_file_name:
self._download_mapping.to_file(output_json_file=self.working_file_path) self._download_mapping.to_file(output_json_file=self.working_file_path)
self.save_file_to_output_directory( self.save_file_to_output_directory(
file_name=self.file_name, output_file_name=self._migrated_file_name file_name=self.file_name, output_file_name=self._migrated_file_name, copy_file=True
) )
FileHandler.delete(file_path=self.working_file_path)
# and delete the old one if the name differs # and delete the old one if the name differs
if self._file_name != self._migrated_file_name: if self._file_name != self._migrated_file_name:
self.delete_file_from_output_directory(file_name=self.file_name) self.delete_file_from_output_directory(file_name=self.file_name)
# Otherwise, only save if there are changes to the transaction log # Otherwise, only save if there are changes to the transaction log
elif not self.get_file_handler_transaction_log().is_empty: elif not self.get_file_handler_transaction_log().is_empty:
self._download_mapping.to_file(output_json_file=self.working_file_path) self._download_mapping.to_file(output_json_file=self.working_file_path)
self.save_file_to_output_directory(file_name=self.file_name) self.save_file_to_output_directory(file_name=self.file_name, copy_file=True)
FileHandler.delete(file_path=self.working_file_path)
return self return self
def delete_file_from_output_directory(self, file_name: str): def delete_file_from_output_directory(self, file_name: str):

View file

View file

View file

@ -0,0 +1,22 @@
from unit.script.conftest import single_variable_output
class TestCustomFunctions:
def test_is_playlist_ordered_by_newest_true(self):
assert (
single_variable_output(
"{%is_playlist_ordered_by_newest('https://www.youtube.com/playlist?list=PL5BC0FC26BECA5A35')}"
)
is True
)
def test_is_playlist_ordered_by_newest_false(self):
assert (
single_variable_output(
"{%is_playlist_ordered_by_newest('https://www.youtube.com/playlist?list=PL2KvlCGf4yFftX466OnFS8wvuSosBfUgm')}"
)
is False
)
def test_is_playlist_ordered_by_newest_defaults_false(self):
assert single_variable_output("{%is_playlist_ordered_by_newest('aaaaaa')}") is False

View file

@ -51,6 +51,49 @@ def playlist_preset_dict(output_directory):
} }
@pytest.fixture
def tv_show_by_date_bilateral_dict(output_directory):
return {
"preset": [
"Jellyfin TV Show by Date",
],
"format": "worst[ext=mp4]",
"match_filters": {"filters": ["title *= Feb.1"]},
"overrides": {
"url": "https://www.youtube.com/playlist?list=PLd4Q7G88JqoekF0b30NYQcOTnTiIe9Ali",
"tv_show_directory": output_directory,
},
"nfo_tags": {
"tags": {
"subscription_has_download_archive": "{subscription_has_download_archive}",
"download_index": "{download_index}",
}
},
}
@pytest.fixture
def tv_show_collection_bilateral_dict(output_directory):
return {
"preset": [
"Jellyfin TV Show Collection",
],
"format": "worst[ext=mp4]",
"match_filters": {"filters": ["title *= Feb.1"]},
"overrides": {
"s01_url": "https://www.youtube.com/playlist?list=PLd4Q7G88JqoekF0b30NYQcOTnTiIe9Ali",
"s01_name": "bilateral test",
"tv_show_directory": output_directory,
},
"nfo_tags": {
"tags": {
"subscription_has_download_archive": "{subscription_has_download_archive}",
"download_index": "{download_index}",
}
},
}
class TestPlaylist: class TestPlaylist:
""" """
Downloads my old minecraft youtube channel, pretends they are music videos. Ensure the above Downloads my old minecraft youtube channel, pretends they are music videos. Ensure the above
@ -222,3 +265,71 @@ class TestPlaylist:
assert len(subscriptions) == 1 assert len(subscriptions) == 1
assert subscriptions[0].transaction_log.is_empty assert subscriptions[0].transaction_log.is_empty
def test_tv_show_by_date_downloads_bilateral(
self,
tv_show_by_date_bilateral_dict: Dict,
output_directory: str,
default_config: ConfigFile,
):
playlist_subscription = Subscription.from_dict(
config=default_config,
preset_name="bilateral_test",
preset_dict=tv_show_by_date_bilateral_dict,
)
transaction_log = playlist_subscription.download(dry_run=False)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="youtube/test_playlist_bilateral_p1.txt",
)
# Now that one vid is downloaded, attempt to download all and see if bilateral
# logic kicks in
del tv_show_by_date_bilateral_dict["match_filters"]
playlist_subscription = Subscription.from_dict(
config=default_config,
preset_name="bilateral_test",
preset_dict=tv_show_by_date_bilateral_dict,
)
transaction_log = playlist_subscription.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="youtube/test_playlist_bilateral_p2.txt",
)
def test_tv_show_collection_downloads_bilateral(
self,
tv_show_collection_bilateral_dict: Dict,
output_directory: str,
default_config: ConfigFile,
):
playlist_subscription = Subscription.from_dict(
config=default_config,
preset_name="bilateral_test",
preset_dict=tv_show_collection_bilateral_dict,
)
transaction_log = playlist_subscription.download(dry_run=False)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="youtube/test_playlist_bilateral_collection_p1.txt",
)
# Now that one vid is downloaded, attempt to download all and see if bilateral
# logic kicks in
del tv_show_collection_bilateral_dict["match_filters"]
playlist_subscription = Subscription.from_dict(
config=default_config,
preset_name="bilateral_test",
preset_dict=tv_show_collection_bilateral_dict,
)
transaction_log = playlist_subscription.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="youtube/test_playlist_bilateral_collection_p2.txt",
)

View file

@ -34,6 +34,8 @@ def single_video_preset_dict(output_directory):
"overrides": { "overrides": {
"music_video_artist": "JMC", "music_video_artist": "JMC",
"music_video_directory": output_directory, "music_video_directory": output_directory,
"test_override_map": {"{music_video_artist}": "{music_video_directory}"},
"test_override_map_get": "{ %map_get(test_override_map, music_video_artist) }",
}, },
} }
@ -120,11 +122,13 @@ class TestYoutubeVideo:
try_convert_download_thumbnail(entry=entry) try_convert_download_thumbnail(entry=entry)
# Pretend the thumbnail did not download via returning nothing for its downloaded path # Pretend the thumbnail did not download via returning nothing for its downloaded path
with patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1), patch.object( with (
Entry, "try_get_ytdlp_download_thumbnail_path" patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1),
) as mock_ytdlp_path, patch( patch.object(Entry, "try_get_ytdlp_download_thumbnail_path") as mock_ytdlp_path,
patch(
"ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail", "ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail",
side_effect=delete_entry_thumb, side_effect=delete_entry_thumb,
),
): ):
mock_ytdlp_path.return_value = None mock_ytdlp_path.return_value = None
transaction_log = single_video_subscription.download(dry_run=False) transaction_log = single_video_subscription.download(dry_run=False)

View file

@ -1,20 +1,20 @@
{ {
".ytdl-sub-Sithu Aye-download-archive.json": "ca37a404a860a2a6b6b0f38b659c9f17", ".ytdl-sub-Sithu Aye-download-archive.json": "93ad50c4f4cca753f40c46bc6e31cabf",
"Sithu Aye/[2021] 10 Years Remixes and Reimaginings/01 - Double Helix Reimagined.mp3": "56f7ee579031f4795230e68b63b15f6b", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/01 - Invent the Universe.mp3": "482e97a4f9a30aa4413f45f5f3f5dbba",
"Sithu Aye/[2021] 10 Years Remixes and Reimaginings/02 - Skye Reimagined.mp3": "dfb24e0ef03e203d471bb81f854f5cd3", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/02 - Grand Unification (feat. David Maxim Micic).mp3": "34e43fd80b4c61295abbb8b794e523a7",
"Sithu Aye/[2021] 10 Years Remixes and Reimaginings/03 - Baryofusion.mp3": "95bd9ab2238e5372f445c59daafd0138", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/03 - Expansion.mp3": "0ba4b8ef65e274cbf065b6f0a5f444e5",
"Sithu Aye/[2021] 10 Years Remixes and Reimaginings/04 - Mandalay Reimagined.mp3": "7d2b38559b4c66a2e4841a6976f726af", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/04 - Baryogenesis.mp3": "ebde7ae4e211f10f2b25a46f2b9483c7",
"Sithu Aye/[2021] 10 Years Remixes and Reimaginings/05 - Messenger EDM Remix.mp3": "971ed99fa1d80ad53dd15069459576ba", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/05 - Particles Collide (feat. Plini).mp3": "7df698f89a55fb21a2f07ffd8d2bfb75",
"Sithu Aye/[2021] 10 Years Remixes and Reimaginings/folder.jpg": "bf6f70d51557a71b69fed85b2cb476f0", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/06 - Nucleosynthesis.mp3": "9a861212acf25a8ff801833f82ade55e",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/01 - Invent the Universe.mp3": "14fe5186fe68eacef265b77c4653ff73", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/07 - Recombination.mp3": "3abbfe126fbdd830195dc2cac223ea10",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/02 - Grand Unification (feat. David Maxim Micic).mp3": "839b79a68464246f19b4701c4daec3cb", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/08 - Dark Ages.mp3": "28e6a73bea1cd881e5ad38d57d1426c5",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/03 - Expansion.mp3": "f44bd1b6b2db8ab29987f47045f1acca", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/09 - Formation.mp3": "d588ed8edc324ea657abb2be96d557a1",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/04 - Baryogenesis.mp3": "a6d5a0f1026ca759c30f69a8d07d64b8", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/10 - Pale Blue Dot.mp3": "8245d285ba2403bf512f83c4a585eb81",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/05 - Particles Collide (feat. Plini).mp3": "9f41e0823c252b9c238e4ce565c78c6e", "Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/folder.jpg": "d8cffeca026afaa619f641a95143f803",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/06 - Nucleosynthesis.mp3": "26e7c844ea45e60ff906f3f4280c991c", "Sithu Aye/[2024] Kindness/01 - Run it Down.mp3": "227c366845913f3476ae83fce0b72c56",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/07 - Recombination.mp3": "8544b560ea220bed0ede54fe73e80bec", "Sithu Aye/[2024] Kindness/02 - Zero Sum Groove.mp3": "0bd3b51acefc2a363309a8f94dd146d7",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/08 - Dark Ages.mp3": "8d209b11a038fb870f5a275c6304159c", "Sithu Aye/[2024] Kindness/03 - Obsidian.mp3": "2a6b87e1ab5a69fc6c4503dc0dd03623",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/09 - Formation.mp3": "48b148d54eed289fd5aef6898f8b72df", "Sithu Aye/[2024] Kindness/04 - Fear is the Kindness Killer.mp3": "4593d02a83f722d481846552554d13ba",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/10 - Pale Blue Dot.mp3": "f506f2d69d03430a2ab0a6890184c162", "Sithu Aye/[2024] Kindness/05 - Ghost;Cleanse.mp3": "f89156a892b597be9b594ce10131ab60",
"Sithu Aye/[2022] ReInvent the Universe (10th Anniversary Remaster)/folder.jpg": "d8cffeca026afaa619f641a95143f803" "Sithu Aye/[2024] Kindness/folder.jpg": "8f72c9acb1a2e49fcbdc2624a46b229c"
} }

View file

@ -1,6 +1,7 @@
{ {
".ytdl-sub-chapters_from_comments-download-archive.json": "2510b2ff3c54aa4813a4f23ea079e1ec", ".ytdl-sub-chapters_from_comments-download-archive.json": "70bc3bb62af3a344b0256e1d3b98dcb0",
"JMC/Move 78 - Automated Improvisation [Full Album].info.json": "INFO_JSON",
"JMC/Move 78 - Automated Improvisation [Full Album].jpg": "c12e6a6f242680d1096a1a99d74a62c6", "JMC/Move 78 - Automated Improvisation [Full Album].jpg": "c12e6a6f242680d1096a1a99d74a62c6",
"JMC/Move 78 - Automated Improvisation [Full Album].mp4": "068526b2d8f85fdcf914df3e23d0b1fa", "JMC/Move 78 - Automated Improvisation [Full Album].mp4": "fbe825bf5fb8ce193d47c3da3540e815",
"JMC/Move 78 - Automated Improvisation [Full Album].nfo": "039268e97673a6f2b391772ec3b52fac" "JMC/Move 78 - Automated Improvisation [Full Album].nfo": "039268e97673a6f2b391772ec3b52fac"
} }

View file

@ -1,6 +1,7 @@
{ {
".ytdl-sub-file_convert_test-download-archive.json": "f720289a704349fbe38ef5ed451af724", ".ytdl-sub-file_convert_test-download-archive.json": "36ac81b6a021c1479a588477d74afd38",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "INFO_JSON",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg": "662fcaadf6e80d63591bac19a5fdffb0", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg": "662fcaadf6e80d63591bac19a5fdffb0",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4": "24f5057254471bc6ecc3056e91af1444", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4": "7609c4f53565aa8f9eac50ad755b5c47",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "752e6b6eea853c8a1f62faa4b841b292" "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "752e6b6eea853c8a1f62faa4b841b292"
} }

View file

@ -1,5 +1,6 @@
{ {
".ytdl-sub-file_convert_test-download-archive.json": "8fa4bf42c9686f8520ce107e48b73215", ".ytdl-sub-file_convert_test-download-archive.json": "83e57376a260ab3a58978dee4a06df4a",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "INFO_JSON",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg": "662fcaadf6e80d63591bac19a5fdffb0", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.jpg": "662fcaadf6e80d63591bac19a5fdffb0",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv": "175320a51dc3efcea84daebec3c1d7e1", "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv": "175320a51dc3efcea84daebec3c1d7e1",
"file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "752e6b6eea853c8a1f62faa4b841b292" "file_convert_test/When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "752e6b6eea853c8a1f62faa4b841b292"

View file

@ -1,6 +1,7 @@
{ {
".ytdl-sub-match_filter_test-download-archive.json": "30f10646149d9eea4eb970749f352f7d", ".ytdl-sub-match_filter_test-download-archive.json": "c2154694d771b47e7878622851b379b7",
"match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].info.json": "INFO_JSON",
"match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
"match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].mp4": "f17a540070964a199b35f981561d94e4", "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].mp4": "a0dc417dbf369c47bbac571bdb18e0e4",
"match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].nfo": "d85f4500bb5d8a2425d734a23b5a944c" "match_filter_test/Jesse's Minecraft Server [Trailer - Mar.21].nfo": "d85f4500bb5d8a2425d734a23b5a944c"
} }

View file

@ -1,15 +1,15 @@
{ {
".ytdl-sub-Proved Records-download-archive.json": "c3fb0b4f31caaa10ac7954ea93da33c4", ".ytdl-sub-Proved Records-download-archive.json": "c3fb0b4f31caaa10ac7954ea93da33c4",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "1e3583c9c1dc166b7baf98b81b4ca106", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "20fec748a00e37dc3b3868797e37fbbf",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "bb4c9085a4345515e6dffcf2c09f0f0f", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "edfbc8621ddfb163d30fee36d7ad902e",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "c6766f80d0f9993ec6051555cd37dc32", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "45a81d4ad17a4b80e916919656a7772e",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "2139e6eedc4d1bc2025f8ef78b0bd2af", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "b536cdb69653303b8eff9b2ae7352b7f",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "8194d6a4018121e40b1733a0fa7a382e", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "f8f93a483b7945feccbb48b89c608e46",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "e244183723e23c65156076b5f7ffcf56", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "4405d980fdb78a453d681054f8a5c603",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "692224c169be4735c0892e05726cb335", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "804f26a8ae8dcaa25b5882dd0051553f",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "561c339d4d1232c15ad38a5afe8d61c3", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "b0f96e5ed99e04064c3537966d332326",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "bdef7ad26848b45552891b9ea722659e", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "83d1e1acbe08b97ca30ab0c29b6473e2",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "ffff4506702e708288eb54eff8fbaca4", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "bbc6ddf2c4f0dbce33e5173d04976e6a",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "41a88d6c07047bb4215fed0e71443f17", "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "e2bdef2556deeec7a7149948c4ffcaec",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/folder.jpg": "bd3685acc53072e591bae2505ecb0648" "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
} }

View file

@ -1,5 +1,5 @@
{ {
".ytdl-sub-split_by_chapters_with_regex_video_no_chapters-download-archive.json": "4008e43668447f1a3a6a55520a6ff475", ".ytdl-sub-split_by_chapters_with_regex_video_no_chapters-download-archive.json": "4008e43668447f1a3a6a55520a6ff475",
"Project Zombie/[2010] Oblivion Mod Falcor p.1/01 - Oblivion Mod Falcor p.1.mp3": "b886f268a2a9b3b62f528fcf46699082", "Project Zombie/[2010] Oblivion Mod Falcor p.1/01 - Oblivion Mod Falcor p.1.mp3": "f1c2e04fb84768fed69ed981d9a2dbcd",
"Project Zombie/[2010] Oblivion Mod Falcor p.1/folder.jpg": "fb95b510681676e81c321171fc23143e" "Project Zombie/[2010] Oblivion Mod Falcor p.1/folder.jpg": "fb95b510681676e81c321171fc23143e"
} }

View file

@ -1,15 +1,15 @@
{ {
".ytdl-sub-split_by_chapters_with_regex_video_preset-download-archive.json": "9798e8289742586d0efd295a97c6c906", ".ytdl-sub-split_by_chapters_with_regex_video_preset-download-archive.json": "9798e8289742586d0efd295a97c6c906",
"Alfa Mist/[2017] Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "d07f90d214416a11736e3f73c0955208", "Alfa Mist/[2017] Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "2311726ea7c5e6c4858810544635d8c8",
"Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "9057d3d7fcd98701e06d399a507c0e5b", "Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "679c3893251b934830855f5f09c35d76",
"Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "4cc4e7066112388e83bdd9e803e8051a", "Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "c7438762ed13c7768e5177aca7feced2",
"Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "8d0f04c04f8c2b0565954dac66ee29a3", "Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "b69cbec7d90a7a28e4684d9f37c2dd01",
"Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "e8166110c85e6b9af8d1067b8ffbb428", "Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "18772abf8868c43b4ffffaaa904ba277",
"Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "64e39199ddcd05b96c15c82a3c695d25", "Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "e9d2cd658fd16deaca41fb491fcb28f6",
"Alfa Mist/[2017] Nocturne/07 - Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "9a53bee41d1985e48e771a02b052986b", "Alfa Mist/[2017] Nocturne/07 - Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "4db7f44cc632fd6e572f844ef7075d2d",
"Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "19c50e84c08eacdd1095e461306d9077", "Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "9687d7dd7543a9f99c5837aea86daf25",
"Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "f28df20fdbadf0ec4985b5f1f30c4e5d", "Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "41e4b7db91f7148b86df33f0968ce6a2",
"Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "d7261e48713426ad4905919915a9b103", "Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "024fd4477a38f915721ebfcf81f9b1c3",
"Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "5d51491d0999e1f29918d4db4ed796d3", "Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "d5459648255b4aa22d88e1b2d2411ffb",
"Alfa Mist/[2017] Nocturne/folder.jpg": "bd3685acc53072e591bae2505ecb0648" "Alfa Mist/[2017] Nocturne/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75", ".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75",
"Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "e7a8a94ebe9f02f086f4bbf3df3946f6", "Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "221dab6e9d6840a0b4b7bb24444c87ff",
"Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "aeea4b086507fd7fde3c09c0bd868950", "Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "02dc8e368de9555d062bde31dcc82852",
"Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "80200d21a46c7f521bfb23aef2f87e34", "Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "c808e1da2bccd419201eeeffc32c3729",
"Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530" "Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530"
} }

View file

@ -1,5 +1,5 @@
{ {
".ytdl-sub-single_song_test-download-archive.json": "c8ff22ec3304c9f8dab18cedaed4e8b4", ".ytdl-sub-single_song_test-download-archive.json": "c8ff22ec3304c9f8dab18cedaed4e8b4",
"YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/01 - YouTube Rewind 2019 For the Record #YouTubeRewind.mp3": "829eb7dcc5dcae41240701dec4e1708d", "YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/01 - YouTube Rewind 2019 For the Record #YouTubeRewind.mp3": "b85c812fc761379122a96243d98a5ccb",
"YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a" "YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a"
} }

View file

@ -1,5 +1,5 @@
{ {
".ytdl-sub-single_song_best_test-download-archive.json": "0f9484ed868dcbeef82810a3cf7b0eea", ".ytdl-sub-single_song_best_test-download-archive.json": "0f9484ed868dcbeef82810a3cf7b0eea",
"YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/01 - YouTube Rewind 2019 For the Record #YouTubeRewind.opus": "73e4afdda9bc792807c8b07a63128e5a", "YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/01 - YouTube Rewind 2019 For the Record #YouTubeRewind.opus": "eb32b0ef0568582cafd7fa528a5260e8",
"YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a" "YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a"
} }

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