Merge branch 'jmbannon:master' into master
This commit is contained in:
commit
9fc55a8c99
380 changed files with 5473 additions and 2543 deletions
|
|
@ -3,6 +3,9 @@ FROM ghcr.io/linuxserver/baseimage-alpine:edge
|
|||
###############################################################################
|
||||
# YTDL-SUB INSTALL
|
||||
|
||||
# Needed for phantomjs
|
||||
ENV OPENSSL_CONF="/etc/ssl"
|
||||
|
||||
COPY root/ /
|
||||
RUN mkdir -p /config && \
|
||||
apk update --no-cache && \
|
||||
|
|
@ -24,15 +27,19 @@ RUN mkdir -p /config && \
|
|||
aria2c --version && \
|
||||
# Install phantomjs if using x86_64, ensure it is properly installed
|
||||
if [[ $(uname -m) == "x86_64" ]]; then \
|
||||
apk add --no-cache gcompat && \
|
||||
cd /usr/share && \
|
||||
curl -L https://github.com/Overbryd/docker-phantomjs-alpine/releases/download/2.11/phantomjs-alpine-x86_64.tar.bz2 | tar xj && \
|
||||
ln -s /usr/share/phantomjs/phantomjs /usr/bin/phantomjs && \
|
||||
curl -L https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar xj && \
|
||||
mv /usr/share/phantomjs-2.1.1-linux-x86_64/bin/phantomjs phantomjs && \
|
||||
rm -rf /usr/share/phantomjs-2.1.1-linux-x86_64 && \
|
||||
ln -s /usr/share/phantomjs /usr/bin/phantomjs && \
|
||||
echo "Phantom JS version:" && \
|
||||
phantomjs --version && \
|
||||
cd -; \
|
||||
fi && \
|
||||
echo "hi" && \
|
||||
# Install ytdl-sub, ensure it is installed properly
|
||||
python3 -m pip install --no-cache-dir ytdl_sub-*.whl && \
|
||||
python3 -m pip install --break-system-packages --no-cache-dir ytdl_sub-*.whl && \
|
||||
ytdl-sub -h && \
|
||||
# Delete unneeded packages after install
|
||||
rm ytdl_sub-*.whl && \
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ configuration:
|
|||
presets:
|
||||
|
||||
video:
|
||||
download:
|
||||
download_strategy: "url"
|
||||
|
||||
output_options:
|
||||
output_directory: "/tmp/ytdl-sub-output"
|
||||
file_name: "{uid}.{ext}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,5 @@
|
|||
#
|
||||
rammstein_music_videos:
|
||||
preset: "video"
|
||||
download:
|
||||
url: "https://youtube.com/playlist?list=PLVTLbc6i-h_iuhdwUfuPDLFLXG2QQnz-x"
|
||||
download: "https://youtube.com/playlist?list=PLVTLbc6i-h_iuhdwUfuPDLFLXG2QQnz-x"
|
||||
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
cp -R /defaults/examples /config/
|
||||
|
||||
# permissions
|
||||
chown -R abc:abc \
|
||||
chown -R ${PUID:-abc}:${PGID:-abc} \
|
||||
/config
|
||||
|
|
|
|||
|
|
@ -165,6 +165,15 @@ date_range
|
|||
:member-order: bysource
|
||||
:exclude-members: partial_validate
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
embed_thumbnail
|
||||
''''''''''''''''
|
||||
|
||||
.. autoclass:: ytdl_sub.plugins.embed_thumbnail.EmbedThumbnailOptions()
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
file_convert
|
||||
''''''''''''
|
||||
.. autoclass:: ytdl_sub.plugins.file_convert.FileConvertOptions()
|
||||
|
|
@ -185,8 +194,6 @@ match_filters
|
|||
music_tags
|
||||
''''''''''
|
||||
.. autoclass:: ytdl_sub.plugins.music_tags.MusicTagsOptions()
|
||||
:members:
|
||||
:exclude-members: partial_validate
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -213,8 +220,8 @@ regex
|
|||
.. autoclass:: ytdl_sub.plugins.regex.RegexOptions()
|
||||
:members: skip_if_match_fails
|
||||
|
||||
.. autoclass:: ytdl_sub.plugins.regex.SourceVariableRegex()
|
||||
:members: match, capture_group_names, capture_group_defaults
|
||||
.. autoclass:: ytdl_sub.plugins.regex.VariableRegex()
|
||||
:members: match, capture_group_names, capture_group_defaults, exclude
|
||||
:member-order: bysource
|
||||
:exclude-members: partial_validate
|
||||
|
||||
|
|
@ -241,8 +248,6 @@ subtitles
|
|||
video_tags
|
||||
''''''''''
|
||||
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions()
|
||||
:members:
|
||||
:exclude-members: partial_validate
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -306,6 +311,32 @@ by using the file-wide ``__preset__``:
|
|||
This ``subscription.yaml`` is equivalent to the one above it because all
|
||||
subscriptions automatically set ``__preset__`` as a `parent preset`_.
|
||||
|
||||
File Subscription Value
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
With a clever config and use of ``__preset__``, your subscriptions can typically boil
|
||||
down to a name and url. You can set ``__value__`` to the name of an override variable,
|
||||
and use the override variable ``subscription_name`` to achieve one-liner subscriptions.
|
||||
Using the example above, we can do:
|
||||
|
||||
.. code-block:: yaml
|
||||
:caption: subscription.yaml
|
||||
|
||||
__preset__:
|
||||
preset: "playlist_preset_ex"
|
||||
overrides:
|
||||
playlist_name: "{subscription_name}"
|
||||
|
||||
__value__: "url"
|
||||
|
||||
# single-line subscription
|
||||
"diy-playlist": "https://youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
|
||||
|
||||
``"diy-playlist"`` gets assigned to the ``playlist_name`` override variable by setting
|
||||
it with ``subscription_name`` , and the url gets assigned to ``url`` by setting ``__value__``
|
||||
to write values to it.
|
||||
|
||||
Traditional subscriptions that can override presets will still work when using ``__value__``.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
.. _source-variables:
|
||||
|
|
@ -318,6 +349,12 @@ Source Variables
|
|||
:inherited-members:
|
||||
:undoc-members:
|
||||
|
||||
Override Variables
|
||||
------------------
|
||||
|
||||
.. autoclass:: ytdl_sub.config.preset_options.OverridesVariables()
|
||||
:members:
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
Config Types
|
||||
|
|
|
|||
51
docs/deprecation_notices.rst
Normal file
51
docs/deprecation_notices.rst
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
Deprecation Notices
|
||||
===================
|
||||
|
||||
July 2023
|
||||
---------
|
||||
|
||||
music_tags
|
||||
^^^^^^^^^^
|
||||
|
||||
Music tags are getting simplified. ``tags`` will now reside directly under music_tags, and
|
||||
``embed_thumbnail`` is getting moved to its own plugin (supports video files as well). Convert from:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
my_example_preset:
|
||||
music_tags:
|
||||
embed_thumbnail: True
|
||||
tags:
|
||||
artist: "Elvis Presley"
|
||||
|
||||
To the following:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
my_example_preset:
|
||||
embed_thumbnail: True
|
||||
music_tags:
|
||||
artist: "Elvis Presley"
|
||||
|
||||
The old format will be removed in October 2023.
|
||||
|
||||
video_tags
|
||||
^^^^^^^^^^
|
||||
|
||||
Video tags are getting simplified as well. ``tags`` will now reside directly under video_tags.
|
||||
Convert from:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
my_example_preset:
|
||||
video_tags:
|
||||
tags:
|
||||
title: "Elvis Presley Documentary"
|
||||
|
||||
To the following:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
my_example_preset:
|
||||
video_tags:
|
||||
title: "Elvis Presley Documentary"
|
||||
|
|
@ -26,3 +26,4 @@ Contents
|
|||
getting_started
|
||||
presets
|
||||
config
|
||||
deprecation_notices
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ and overriding the following variables:
|
|||
url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
|
||||
# can be modified from their default value
|
||||
# tv_show_genre: "ytdl-sub"
|
||||
# tv_show_content_rating: "TV-14"
|
||||
# episode_title: "{upload_date_standardized} - {title}"
|
||||
# episode_description: "{webpage_url}"
|
||||
|
||||
|
|
@ -110,7 +111,7 @@ Season Presets
|
|||
* ``collection_season_3``
|
||||
* ``collection_season_4``
|
||||
* ``...``
|
||||
* ``collection_season_20``
|
||||
* ``collection_season_40``
|
||||
|
||||
Example
|
||||
"""""""
|
||||
|
|
|
|||
|
|
@ -64,17 +64,19 @@ presets:
|
|||
# It is recommended to keep most of this as-is, and use override
|
||||
# variables to set them to be what you want.
|
||||
music_tags:
|
||||
tags:
|
||||
artist: "{track_artist}"
|
||||
artists: "{track_artist}"
|
||||
albumartist: "{track_artist}"
|
||||
albumartists: "{track_artist}"
|
||||
title: "{track_title}"
|
||||
album: "{track_album}"
|
||||
track: "{track_number}"
|
||||
tracktotal: "{track_total}"
|
||||
year: "{track_year}"
|
||||
genre: "{track_genre}"
|
||||
artist: "{track_artist}"
|
||||
artists: "{track_artist}"
|
||||
albumartist: "{track_artist}"
|
||||
albumartists: "{track_artist}"
|
||||
title: "{track_title}"
|
||||
album: "{track_album}"
|
||||
track: "{track_number}"
|
||||
tracktotal: "{track_total}"
|
||||
year: "{track_year}"
|
||||
genre: "{track_genre}"
|
||||
|
||||
# Optionally embed the thumbnail into the track
|
||||
embed_thumbnail: False
|
||||
|
||||
# For every configurable field, make it an override variable,
|
||||
# so we can carefully tune different use-cases by only modifying override variables.
|
||||
|
|
@ -108,7 +110,6 @@ presets:
|
|||
preset: "base"
|
||||
|
||||
download:
|
||||
download_strategy: "url"
|
||||
url: "{url}"
|
||||
|
||||
####################################################################################################
|
||||
|
|
@ -164,27 +165,25 @@ presets:
|
|||
|
||||
# Download using the multi_url strategy
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
# The first URL will be all the artist's tracks.
|
||||
# Treat these as singles - an album with a single track
|
||||
- url: "{sc_artist_url}/tracks"
|
||||
variables:
|
||||
sc_track_album: "{title}"
|
||||
sc_track_number: "1"
|
||||
sc_track_number_padded: "01"
|
||||
sc_track_total: "1"
|
||||
sc_track_year: "{upload_year}"
|
||||
# Set the second URL to the artist's albums. If a track belongs to both
|
||||
# to an album and tracks (in the URL above), it will resolve to this
|
||||
# URL and include the album metadata we set below.
|
||||
- url: "{sc_artist_url}/albums"
|
||||
variables:
|
||||
sc_track_album: "{playlist_title}"
|
||||
sc_track_number: "{playlist_index}"
|
||||
sc_track_number_padded: "{playlist_index_padded}"
|
||||
sc_track_total: "{playlist_count}"
|
||||
sc_track_year: "{playlist_max_upload_year}"
|
||||
# The first URL will be all the artist's tracks.
|
||||
# Treat these as singles - an album with a single track
|
||||
- url: "{sc_artist_url}/tracks"
|
||||
variables:
|
||||
sc_track_album: "{title}"
|
||||
sc_track_number: "1"
|
||||
sc_track_number_padded: "01"
|
||||
sc_track_total: "1"
|
||||
sc_track_year: "{upload_year}"
|
||||
# Set the second URL to the artist's albums. If a track belongs to both
|
||||
# to an album and tracks (in the URL above), it will resolve to this
|
||||
# URL and include the album metadata we set below.
|
||||
- url: "{sc_artist_url}/albums"
|
||||
variables:
|
||||
sc_track_album: "{playlist_title}"
|
||||
sc_track_number: "{playlist_index}"
|
||||
sc_track_number_padded: "{playlist_index_padded}"
|
||||
sc_track_total: "{playlist_count}"
|
||||
sc_track_year: "{playlist_max_upload_year}"
|
||||
|
||||
# Override various track properties using playlist variables.
|
||||
overrides:
|
||||
|
|
|
|||
|
|
@ -18,12 +18,10 @@ configuration:
|
|||
|
||||
presets:
|
||||
music_video:
|
||||
# Set the download details
|
||||
# We will only use a single URL to download music video(s).
|
||||
# Make {url} an override variable to set later.
|
||||
download:
|
||||
# We will only use a single URL to download music video(s).
|
||||
# Make {url} an override variable to set later.
|
||||
download_strategy: "url"
|
||||
url: "{url}"
|
||||
- "{url}"
|
||||
|
||||
# For advanced YTDL users only; any YTDL parameter can be set here.
|
||||
# To download age-restricted videos, you will need to set your cookie
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ disable = [
|
|||
"R0801", # similar lines
|
||||
"R0913", # Too many arguments
|
||||
"R0901", # too-many-ancestors
|
||||
"R0902", # too-many-instance-attributes
|
||||
"W0511", # TODO
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@ package_dir =
|
|||
packages=find:
|
||||
|
||||
install_requires =
|
||||
yt-dlp==2023.3.4
|
||||
yt-dlp==2023.7.6
|
||||
argparse==1.4.0
|
||||
colorama==0.4.6,
|
||||
colorama==0.4.6
|
||||
mergedeep==1.3.4
|
||||
mediafile==0.10.1
|
||||
PyYAML==6.0
|
||||
mediafile==0.12.0
|
||||
PyYAML==5.3.1
|
||||
|
||||
[options.package_data]
|
||||
* = *.yaml
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ def _output_transaction_log(
|
|||
transaction_log_file_contents = ""
|
||||
for subscription, transaction_log in transaction_logs:
|
||||
if transaction_log.is_empty:
|
||||
transaction_log_contents = f"No files changed for {subscription.name}"
|
||||
transaction_log_contents = f"\nNo files changed for {subscription.name}"
|
||||
else:
|
||||
transaction_log_contents = (
|
||||
f"Transaction log for {subscription.name}:\n"
|
||||
|
|
@ -316,8 +316,9 @@ def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
|
|||
and not config.config_options.experimental.enable_update_with_info_json
|
||||
):
|
||||
raise ExperimentalFeatureNotEnabled(
|
||||
"--update-with-info-json requires setting "
|
||||
"configuration.experimental.update_with_info_json to True. This feature is ",
|
||||
"--update-with-info-json requires setting"
|
||||
" configuration.experimental.enable_update_with_info_json to True. This"
|
||||
" feature is ",
|
||||
"still being tested and has the ability to destroy files. Ensure you have a ",
|
||||
"full backup before usage. You have been warned!",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ class PersistLogsValidator(StrictDictValidator):
|
|||
return self._keep_successful_logs.value
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class ConfigOptions(StrictDictValidator):
|
||||
_required_keys = {"working_directory"}
|
||||
_optional_keys = {
|
||||
|
|
|
|||
|
|
@ -14,3 +14,7 @@ else:
|
|||
DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe"
|
||||
|
||||
MAX_FILE_NAME_BYTES = os.pathconf("/", "PC_NAME_MAX")
|
||||
|
||||
# Historically was hardcoded to this value. Use this as the default
|
||||
# if download_archive_path is not specified
|
||||
DEFAULT_DOWNLOAD_ARCHIVE_NAME = ".ytdl-sub-{subscription_name}-download-archive.json"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from typing import Dict
|
||||
from typing import Generic
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
from ytdl_sub.config.preset_options import OptionsValidator
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.config.preset_options import TOptionsValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
|
|
@ -24,7 +23,15 @@ class PluginPriority:
|
|||
# If modify_entry priority is >= to this value, run after split
|
||||
MODIFY_ENTRY_AFTER_SPLIT = 10
|
||||
|
||||
def __init__(self, modify_entry: int = 0, post_process: int = 0):
|
||||
# if post_process is >= to this value, run after file_convert
|
||||
POST_PROCESS_AFTER_FILE_CONVERT = 10
|
||||
|
||||
MODIFY_ENTRY_FIRST = 0
|
||||
|
||||
def __init__(
|
||||
self, modify_entry_metadata: int = 5, modify_entry: int = 5, post_process: int = 5
|
||||
):
|
||||
self.modify_entry_metadata = modify_entry_metadata
|
||||
self.modify_entry = modify_entry
|
||||
self.post_process = post_process
|
||||
|
||||
|
|
@ -38,34 +45,33 @@ class PluginPriority:
|
|||
return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT
|
||||
|
||||
|
||||
PluginOptions = OptionsValidator
|
||||
PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions)
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
|
||||
|
||||
class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
|
||||
class BasePlugin(DownloadArchiver, Generic[TOptionsValidator], ABC):
|
||||
"""
|
||||
Class to define the new plugin functionality
|
||||
Shared code amongst all SourcePlugins (downloaders) and Plugins (post-download modification)
|
||||
"""
|
||||
|
||||
plugin_options_type: Type[PluginOptionsT] = NotImplemented
|
||||
priority: PluginPriority = PluginPriority()
|
||||
|
||||
# If the plugin creates multiple entries from a single entry
|
||||
is_split_plugin: bool = False
|
||||
plugin_options_type: Type[TOptionsValidator]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_options: PluginOptionsT,
|
||||
options: TOptionsValidator,
|
||||
overrides: Overrides,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
):
|
||||
DownloadArchiver.__init__(self=self, enhanced_download_archive=enhanced_download_archive)
|
||||
self.plugin_options = plugin_options
|
||||
self.plugin_options = options
|
||||
self.overrides = overrides
|
||||
# TODO pass yaml snake case name in the class somewhere, and use it for the logger
|
||||
self._logger = Logger.get(self.__class__.__name__)
|
||||
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
|
||||
class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
|
||||
"""
|
||||
Class to define the new plugin functionality
|
||||
"""
|
||||
|
||||
def ytdl_options(self) -> Optional[Dict]:
|
||||
"""
|
||||
Returns
|
||||
|
|
@ -73,22 +79,6 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
|
|||
ytdl options to enable/disable when downloading entries for this specific plugin
|
||||
"""
|
||||
|
||||
def split(self, entry: Entry) -> List[Tuple[Entry, FileMetadata]]:
|
||||
"""
|
||||
Very specialized function that takes an entry and creates multiple entries from it.
|
||||
Should mark ``is_split_plugin`` on the plugin class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry
|
||||
Entry to create multiple entries from
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of entries and metadata created from the source entry
|
||||
"""
|
||||
return []
|
||||
|
||||
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
|
||||
"""
|
||||
After entry metadata has been gathered, perform preprocessing on the metadata
|
||||
|
|
@ -135,9 +125,30 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
|
|||
"""
|
||||
return None
|
||||
|
||||
# pylint: enable=no-self-use,unused-argument
|
||||
|
||||
def post_process_subscription(self):
|
||||
"""
|
||||
After all downloaded files have been post-processed, apply a subscription-wide post process
|
||||
"""
|
||||
|
||||
|
||||
class SplitPlugin(Plugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
|
||||
"""
|
||||
Plugin that splits entries into zero or more entries
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def split(self, entry: Entry) -> List[Tuple[Entry, FileMetadata]]:
|
||||
"""
|
||||
Very specialized function that takes an entry and creates multiple entries from it.
|
||||
Should mark ``is_split_plugin`` on the plugin class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry
|
||||
Entry to create multiple entries from
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of entries and metadata created from the source entry
|
||||
"""
|
||||
return []
|
||||
|
|
@ -11,17 +11,15 @@ from typing import Union
|
|||
from mergedeep import mergedeep
|
||||
|
||||
from ytdl_sub.config.config_validator import ConfigValidator
|
||||
from ytdl_sub.config.preset_class_mappings import DownloadStrategyMapping
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_class_mappings import PluginMapping
|
||||
from ytdl_sub.config.preset_options import OptionsValidator
|
||||
from ytdl_sub.config.preset_options import OutputOptions
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.config.preset_options import TOptionsValidator
|
||||
from ytdl_sub.config.preset_options import YTDLOptions
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
|
||||
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.plugins.plugin import PluginOptionsT
|
||||
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
|
||||
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
|
|
@ -35,16 +33,15 @@ from ytdl_sub.validators.string_formatter_validators import StringFormatterValid
|
|||
from ytdl_sub.validators.validators import DictValidator
|
||||
from ytdl_sub.validators.validators import ListValidator
|
||||
from ytdl_sub.validators.validators import StringListValidator
|
||||
from ytdl_sub.validators.validators import StringValidator
|
||||
from ytdl_sub.validators.validators import Validator
|
||||
from ytdl_sub.validators.validators import validation_exception
|
||||
|
||||
PRESET_KEYS = {
|
||||
"preset",
|
||||
"download",
|
||||
"output_options",
|
||||
"ytdl_options",
|
||||
"overrides",
|
||||
*DownloadStrategyMapping.sources(),
|
||||
*PluginMapping.plugins(),
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +64,9 @@ def _parent_preset_error_message(
|
|||
class PresetPlugins:
|
||||
def __init__(self):
|
||||
self.plugin_types: List[Type[Plugin]] = []
|
||||
self.plugin_options: List[PluginOptions] = []
|
||||
self.plugin_options: List[OptionsValidator] = []
|
||||
|
||||
def add(self, plugin_type: Type[Plugin], plugin_options: PluginOptions) -> "PresetPlugins":
|
||||
def add(self, plugin_type: Type[Plugin], plugin_options: OptionsValidator) -> "PresetPlugins":
|
||||
"""
|
||||
Add a pair of plugin type and options to the list
|
||||
"""
|
||||
|
|
@ -77,7 +74,7 @@ class PresetPlugins:
|
|||
self.plugin_options.append(plugin_options)
|
||||
return self
|
||||
|
||||
def zipped(self) -> Iterable[Tuple[Type[Plugin], PluginOptions]]:
|
||||
def zipped(self) -> Iterable[Tuple[Type[Plugin], OptionsValidator]]:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
|
|
@ -85,7 +82,7 @@ class PresetPlugins:
|
|||
"""
|
||||
return zip(self.plugin_types, self.plugin_options)
|
||||
|
||||
def get(self, plugin_type: Type[PluginOptionsT]) -> Optional[PluginOptionsT]:
|
||||
def get(self, plugin_type: Type[TOptionsValidator]) -> Optional[TOptionsValidator]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
|
|
@ -102,49 +99,6 @@ class PresetPlugins:
|
|||
return None
|
||||
|
||||
|
||||
class DownloadStrategyValidator(StrictDictValidator):
|
||||
"""
|
||||
Ensures a download strategy exists for a source. Does not validate any more than that.
|
||||
The respective Downloader's option validator will do that.
|
||||
"""
|
||||
|
||||
# All media sources must define a download strategy
|
||||
_required_keys = {"download_strategy"}
|
||||
|
||||
# Extra fields will be strict-validated using other StictDictValidators
|
||||
_allow_extra_keys = True
|
||||
|
||||
def __init__(self, name: str, value: Any):
|
||||
super().__init__(name=name, value=value)
|
||||
self.download_strategy_name = self._validate_key(
|
||||
key="download_strategy",
|
||||
validator=StringValidator,
|
||||
).value
|
||||
|
||||
def get(self, downloader_source: str) -> Type[BaseDownloader]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
downloader_source:
|
||||
Name of the download source
|
||||
|
||||
Returns
|
||||
-------
|
||||
The downloader class
|
||||
|
||||
Raises
|
||||
------
|
||||
ValidationException
|
||||
If the download strategy is invalid
|
||||
"""
|
||||
try:
|
||||
return DownloadStrategyMapping.get(
|
||||
source=downloader_source, download_strategy=self.download_strategy_name
|
||||
)
|
||||
except ValueError as value_exc:
|
||||
raise self._validation_exception(error_message=value_exc)
|
||||
|
||||
|
||||
class _PresetShell(StrictDictValidator):
|
||||
# Have all present keys optional since parent presets could not have all the
|
||||
# required keys. They will get validated in the init after the mergedeep of dicts
|
||||
|
|
@ -153,70 +107,6 @@ class _PresetShell(StrictDictValidator):
|
|||
|
||||
|
||||
class Preset(_PresetShell):
|
||||
@classmethod
|
||||
def _validate_download_strategy(
|
||||
cls, config: ConfigValidator, name: str, value: Dict, parent_presets: StringListValidator
|
||||
) -> None:
|
||||
sources: List[str] = []
|
||||
for source_name in DownloadStrategyMapping.sources():
|
||||
if source_name in value:
|
||||
sources.append(source_name)
|
||||
|
||||
if len(sources) > 1:
|
||||
raise validation_exception(
|
||||
name=name,
|
||||
error_message=f"Contains the sources {', '.join(sources)} but can only have one",
|
||||
)
|
||||
|
||||
# If no sources, nothing more to validate
|
||||
if not sources:
|
||||
return
|
||||
|
||||
# Ensure all parent/child presets use the same download strategy
|
||||
preset_strs = [preset.value for preset in parent_presets.list] + [name]
|
||||
download_strategy: Optional[str] = None
|
||||
download_strategy_preset: Optional[str] = None
|
||||
for parent_preset in preset_strs:
|
||||
# See if the parent has a download strategy
|
||||
parent_download_strategy = (
|
||||
config.presets.dict.get(parent_preset, {})
|
||||
.get("download", {})
|
||||
.get("download_strategy")
|
||||
)
|
||||
|
||||
# If the parent download strategy is None, do nothing
|
||||
if parent_download_strategy is None:
|
||||
continue
|
||||
|
||||
# If download strategy is not set, then set it to parent download strategy
|
||||
if download_strategy is None:
|
||||
download_strategy = parent_download_strategy
|
||||
download_strategy_preset = parent_preset
|
||||
|
||||
# If it's set and does not match the parent download strategy, error
|
||||
if download_strategy is not None and download_strategy != parent_download_strategy:
|
||||
raise ValidationException(
|
||||
f"Preset {download_strategy_preset} uses download strategy {download_strategy}"
|
||||
f", but is inherited by preset {parent_preset} which uses download strategy "
|
||||
f"{parent_download_strategy}."
|
||||
)
|
||||
|
||||
source_name = sources[0]
|
||||
source_dict = copy.deepcopy(value[source_name])
|
||||
|
||||
# Set the parent download strategy if present
|
||||
if download_strategy:
|
||||
source_dict["download_strategy"] = download_strategy
|
||||
|
||||
downloader = DownloadStrategyValidator(name=f"{name}.{source_name}", value=source_dict).get(
|
||||
downloader_source=source_name
|
||||
)
|
||||
del source_dict["download_strategy"]
|
||||
|
||||
downloader.downloader_options_type.partial_validate(
|
||||
name=f"{name}.{source_name}", value=source_dict
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None:
|
||||
"""
|
||||
|
|
@ -251,9 +141,7 @@ class Preset(_PresetShell):
|
|||
presets=config.presets.keys,
|
||||
)
|
||||
|
||||
cls._validate_download_strategy(
|
||||
config=config, name=name, value=value, parent_presets=parent_presets
|
||||
)
|
||||
cls._partial_validate_key(name, value, "download", MultiUrlValidator)
|
||||
cls._partial_validate_key(name, value, "output_options", OutputOptions)
|
||||
cls._partial_validate_key(name, value, "ytdl_options", YTDLOptions)
|
||||
cls._partial_validate_key(name, value, "overrides", Overrides)
|
||||
|
|
@ -270,55 +158,6 @@ class Preset(_PresetShell):
|
|||
def _source_variables(self) -> List[str]:
|
||||
return Entry.source_variables()
|
||||
|
||||
def __validate_and_get_downloader(self, downloader_source: str) -> Type[BaseDownloader]:
|
||||
return self._validate_key(key=downloader_source, validator=DownloadStrategyValidator).get(
|
||||
downloader_source=downloader_source
|
||||
)
|
||||
|
||||
def __validate_and_get_downloader_options(
|
||||
self, downloader_source: str, downloader: Type[BaseDownloader]
|
||||
) -> BaseDownloaderValidator:
|
||||
# Remove the download_strategy key before validating it against the downloader options
|
||||
# TODO: make this cleaner
|
||||
del self._dict[downloader_source]["download_strategy"]
|
||||
return self._validate_key(
|
||||
key=downloader_source, validator=downloader.downloader_options_type
|
||||
)
|
||||
|
||||
def __validate_and_get_downloader_and_options(
|
||||
self,
|
||||
) -> Tuple[Type[BaseDownloader], BaseDownloaderValidator]:
|
||||
downloader: Optional[Type[BaseDownloader]] = None
|
||||
download_options: Optional[BaseDownloaderValidator] = None
|
||||
downloader_sources = DownloadStrategyMapping.sources()
|
||||
|
||||
for key in self._keys:
|
||||
# skip if the key is not a download source
|
||||
if key not in downloader_sources:
|
||||
continue
|
||||
|
||||
# Ensure there are not multiple sources, i.e. youtube and soundcloud
|
||||
if downloader:
|
||||
raise self._validation_exception(
|
||||
f"'{self._name}' can only have one of the following sources: "
|
||||
f"{', '.join(downloader_sources)}"
|
||||
)
|
||||
|
||||
downloader = self.__validate_and_get_downloader(downloader_source=key)
|
||||
download_options = self.__validate_and_get_downloader_options(
|
||||
downloader_source=key, downloader=downloader
|
||||
)
|
||||
|
||||
# If downloader was not set, error since it is required
|
||||
if not downloader:
|
||||
|
||||
raise self._validation_exception(
|
||||
f"'{self._name} must have one of the following sources: "
|
||||
f"{', '.join(downloader_sources)}"
|
||||
)
|
||||
|
||||
return downloader, download_options
|
||||
|
||||
def __validate_and_get_plugins(self) -> PresetPlugins:
|
||||
preset_plugins = PresetPlugins()
|
||||
|
||||
|
|
@ -477,7 +316,9 @@ class Preset(_PresetShell):
|
|||
# Perform the merge of parent presets before validating any keys
|
||||
self.__merge_parent_preset_dicts_if_present(config=config)
|
||||
|
||||
self.downloader, self.downloader_options = self.__validate_and_get_downloader_and_options()
|
||||
self.downloader_options: MultiUrlValidator = self._validate_key(
|
||||
key="download", validator=MultiUrlValidator
|
||||
)
|
||||
|
||||
self.output_options = self._validate_key(
|
||||
key="output_options",
|
||||
|
|
|
|||
|
|
@ -2,102 +2,23 @@ from typing import Dict
|
|||
from typing import List
|
||||
from typing import Type
|
||||
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.downloaders.url.multi_url import MultiUrlDownloader
|
||||
from ytdl_sub.downloaders.url.url import UrlDownloader
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
|
||||
from ytdl_sub.plugins.chapters import ChaptersPlugin
|
||||
from ytdl_sub.plugins.date_range import DateRangePlugin
|
||||
from ytdl_sub.plugins.embed_thumbnail import EmbedThumbnailPlugin
|
||||
from ytdl_sub.plugins.file_convert import FileConvertPlugin
|
||||
from ytdl_sub.plugins.internal.view import ViewPlugin
|
||||
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
|
||||
from ytdl_sub.plugins.music_tags import MusicTagsPlugin
|
||||
from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
|
||||
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.regex import RegexPlugin
|
||||
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
|
||||
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
|
||||
from ytdl_sub.plugins.video_tags import VideoTagsPlugin
|
||||
|
||||
|
||||
class DownloadStrategyMapping:
|
||||
"""
|
||||
Maps downloader strategies defined in the preset to its respective downloader class
|
||||
"""
|
||||
|
||||
_MAPPING: Dict[str, Dict[str, Type[BaseDownloader]]] = {
|
||||
"download": {
|
||||
"multi_url": MultiUrlDownloader,
|
||||
"url": UrlDownloader,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def sources(cls) -> List[str]:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
Available download sources
|
||||
"""
|
||||
return sorted(list(cls._MAPPING.keys()))
|
||||
|
||||
@classmethod
|
||||
def _validate_is_source(cls, source: str) -> None:
|
||||
"""
|
||||
Ensure the source exists
|
||||
"""
|
||||
if source not in cls.sources():
|
||||
raise ValueError(
|
||||
f"Tried to use source '{source}' which does not exist. Available sources: "
|
||||
f"{', '.join(cls.sources())}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def source_download_strategies(cls, source: str) -> List[str]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
source
|
||||
Name of the source
|
||||
|
||||
Returns
|
||||
-------
|
||||
Available download strategies for the given source
|
||||
"""
|
||||
cls._validate_is_source(source)
|
||||
return sorted(list(cls._MAPPING[source].keys()))
|
||||
|
||||
@classmethod
|
||||
def _validate_is_download_strategy(cls, source: str, download_strategy: str) -> None:
|
||||
"""
|
||||
Ensure the download strategy for the given source exists
|
||||
"""
|
||||
if download_strategy not in cls.source_download_strategies(source):
|
||||
raise ValueError(
|
||||
f"Tried to use download strategy '{download_strategy}' with source '{source}', "
|
||||
f"which does not exist. Available download strategies: "
|
||||
f"{', '.join(cls.source_download_strategies(source))}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get(cls, source: str, download_strategy: str) -> Type[BaseDownloader]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
source:
|
||||
The source (i.e. 'youtube', 'soundcloud') of the downloader
|
||||
download_strategy:
|
||||
The download strategy name
|
||||
|
||||
Returns
|
||||
-------
|
||||
The downloader class
|
||||
"""
|
||||
cls._validate_is_download_strategy(source, download_strategy)
|
||||
return cls._MAPPING[source][download_strategy]
|
||||
|
||||
|
||||
class PluginMapping:
|
||||
"""
|
||||
Maps plugins defined in the preset to its respective plugin class
|
||||
|
|
@ -107,6 +28,7 @@ class PluginMapping:
|
|||
"_view": ViewPlugin,
|
||||
"audio_extract": AudioExtractPlugin,
|
||||
"date_range": DateRangePlugin,
|
||||
"embed_thumbnail": EmbedThumbnailPlugin,
|
||||
"file_convert": FileConvertPlugin,
|
||||
"match_filters": MatchFiltersPlugin,
|
||||
"music_tags": MusicTagsPlugin,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ from typing import Any
|
|||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import TypeVar
|
||||
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
from ytdl_sub.config.defaults import DEFAULT_DOWNLOAD_ARCHIVE_NAME
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator
|
||||
|
|
@ -17,11 +19,12 @@ from ytdl_sub.validators.string_formatter_validators import OverridesStringForma
|
|||
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
||||
from ytdl_sub.validators.validators import BoolValidator
|
||||
from ytdl_sub.validators.validators import LiteralDictValidator
|
||||
from ytdl_sub.validators.validators import Validator
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
# pylint: disable=unused-argument
|
||||
class OptionsValidator(StrictDictValidator, ABC):
|
||||
class OptionsValidator(Validator, ABC):
|
||||
"""
|
||||
Abstract class that validates options for preset sections (plugins, downloaders)
|
||||
"""
|
||||
|
|
@ -69,6 +72,13 @@ class OptionsValidator(StrictDictValidator, ABC):
|
|||
return None
|
||||
|
||||
|
||||
TOptionsValidator = TypeVar("TOptionsValidator", bound=OptionsValidator)
|
||||
|
||||
|
||||
class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC):
|
||||
pass
|
||||
|
||||
|
||||
# pylint: enable=no-self-use
|
||||
# pylint: enable=unused-argument
|
||||
|
||||
|
|
@ -110,9 +120,45 @@ class YTDLOptions(LiteralDictValidator):
|
|||
"""
|
||||
|
||||
|
||||
class OverridesVariables(DictFormatterValidator):
|
||||
"""
|
||||
Override variables that are automatically added to every subscription.
|
||||
"""
|
||||
|
||||
def _add_override_variable(self, key_name: str, format_string: str, sanitize: bool = False):
|
||||
if sanitize:
|
||||
key_name = f"{key_name}_sanitized"
|
||||
format_string = sanitize_filename(format_string)
|
||||
|
||||
self._value[key_name] = StringFormatterValidator(
|
||||
name="__should_never_fail__",
|
||||
value=format_string,
|
||||
)
|
||||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
|
||||
# Add sanitized and non-sanitized override variables
|
||||
for sanitize in [True, False]:
|
||||
self._add_override_variable(
|
||||
key_name="subscription_name",
|
||||
format_string=self.subscription_name,
|
||||
sanitize=sanitize,
|
||||
)
|
||||
|
||||
@property
|
||||
def subscription_name(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
Name of the subscription
|
||||
"""
|
||||
return self._root_name
|
||||
|
||||
|
||||
# Disable for proper docstring formatting
|
||||
# pylint: disable=line-too-long
|
||||
class Overrides(DictFormatterValidator):
|
||||
class Overrides(OverridesVariables):
|
||||
"""
|
||||
Optional. This section allows you to define variables that can be used in any string formatter.
|
||||
For example, if you want your file and thumbnail files to match without copy-pasting a large
|
||||
|
|
@ -142,16 +188,6 @@ class Overrides(DictFormatterValidator):
|
|||
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
def _add_override_variable(self, key_name: str, format_string: str, sanitize: bool = False):
|
||||
if sanitize:
|
||||
key_name = f"{key_name}_sanitized"
|
||||
format_string = sanitize_filename(format_string)
|
||||
|
||||
self._value[key_name] = StringFormatterValidator(
|
||||
name="__should_never_fail__",
|
||||
value=format_string,
|
||||
)
|
||||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
|
||||
|
|
@ -209,6 +245,7 @@ class OutputOptions(StrictDictValidator):
|
|||
# optional
|
||||
thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
|
||||
info_json_name: "{title_sanitized}.{info_json_ext}"
|
||||
download_archive_name: ".ytdl-sub-{subscription_name}-download-archive.json"
|
||||
maintain_download_archive: True
|
||||
keep_files_before: now
|
||||
keep_files_after: 19000101
|
||||
|
|
@ -218,7 +255,7 @@ class OutputOptions(StrictDictValidator):
|
|||
_optional_keys = {
|
||||
"thumbnail_name",
|
||||
"info_json_name",
|
||||
"subtitles_name",
|
||||
"download_archive_name",
|
||||
"maintain_download_archive",
|
||||
"keep_files_before",
|
||||
"keep_files_after",
|
||||
|
|
@ -256,6 +293,12 @@ class OutputOptions(StrictDictValidator):
|
|||
key="info_json_name", validator=StringFormatterFileNameValidator
|
||||
)
|
||||
|
||||
self._download_archive_name = self._validate_key_if_present(
|
||||
key="download_archive_name",
|
||||
validator=OverridesStringFormatterValidator,
|
||||
default=DEFAULT_DOWNLOAD_ARCHIVE_NAME,
|
||||
)
|
||||
|
||||
self._maintain_download_archive = self._validate_key_if_present(
|
||||
key="maintain_download_archive", validator=BoolValidator, default=False
|
||||
)
|
||||
|
|
@ -307,6 +350,14 @@ class OutputOptions(StrictDictValidator):
|
|||
"""
|
||||
return self._info_json_name
|
||||
|
||||
@property
|
||||
def download_archive_name(self) -> Optional[OverridesStringFormatterValidator]:
|
||||
"""
|
||||
Optional. The file name to store a subscriptions download archive placed relative to
|
||||
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
|
||||
"""
|
||||
return self._download_archive_name
|
||||
|
||||
@property
|
||||
def maintain_download_archive(self) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import abc
|
||||
from abc import ABC
|
||||
from typing import Generic
|
||||
from typing import Iterable
|
||||
from typing import List
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
from ytdl_sub.config.preset_options import OptionsValidator
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
BaseDownloaderValidator = OptionsValidator
|
||||
BaseDownloaderOptionsT = TypeVar("BaseDownloaderOptionsT", bound=BaseDownloaderValidator)
|
||||
|
||||
|
||||
class BaseDownloaderPlugin(Plugin[BaseDownloaderOptionsT], ABC):
|
||||
"""
|
||||
Plugins that get added automatically by using a downloader. Downloader options
|
||||
are the plugin options.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader_options: BaseDownloaderOptionsT,
|
||||
overrides: Overrides,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
):
|
||||
super().__init__(
|
||||
# Downloader plugins use download options as their plugin options
|
||||
plugin_options=downloader_options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
)
|
||||
|
||||
|
||||
class BaseDownloader(DownloadArchiver, Generic[BaseDownloaderOptionsT], ABC):
|
||||
downloader_options_type: Type[BaseDownloaderOptionsT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
download_options: BaseDownloaderOptionsT,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
download_ytdl_options: YTDLOptionsBuilder,
|
||||
metadata_ytdl_options: YTDLOptionsBuilder,
|
||||
overrides: Overrides,
|
||||
):
|
||||
super().__init__(enhanced_download_archive=enhanced_download_archive)
|
||||
self.download_options = download_options
|
||||
self.overrides = overrides
|
||||
self._download_ytdl_options_builder = download_ytdl_options
|
||||
self._metadata_ytdl_options_builder = metadata_ytdl_options
|
||||
|
||||
@abc.abstractmethod
|
||||
def download_metadata(self) -> Iterable[Entry]:
|
||||
"""Gathers metadata of all entries to download"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def download(self, entry: Entry) -> Entry:
|
||||
"""The function to perform the download of all media entries"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@classmethod
|
||||
def added_plugins(
|
||||
cls,
|
||||
downloader_options: BaseDownloaderOptionsT,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
overrides: Overrides,
|
||||
) -> List[BaseDownloaderPlugin]:
|
||||
"""Add these plugins from the Downloader to the subscription"""
|
||||
return []
|
||||
|
||||
# pylint: enable=unused-argument
|
||||
|
|
@ -5,10 +5,9 @@ from typing import Dict
|
|||
from typing import Iterable
|
||||
from typing import List
|
||||
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderOptionsT
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
|
||||
from ytdl_sub.downloaders.source_plugin import SourcePlugin
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
|
|
@ -18,23 +17,23 @@ from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadMapping
|
|||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
|
||||
class InfoJsonDownloaderOptions(BaseDownloaderValidator):
|
||||
class InfoJsonDownloaderOptions(OptionsDictValidator):
|
||||
_optional_keys = {"no-op"}
|
||||
|
||||
|
||||
class InfoJsonDownloader(BaseDownloader[InfoJsonDownloaderOptions]):
|
||||
downloader_options_type = InfoJsonDownloaderOptions
|
||||
class InfoJsonDownloader(SourcePlugin[InfoJsonDownloaderOptions]):
|
||||
plugin_options_type = InfoJsonDownloaderOptions
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
download_options: BaseDownloaderOptionsT,
|
||||
options: InfoJsonDownloaderOptions,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
download_ytdl_options: YTDLOptionsBuilder,
|
||||
metadata_ytdl_options: YTDLOptionsBuilder,
|
||||
overrides: Overrides,
|
||||
):
|
||||
super().__init__(
|
||||
download_options=download_options,
|
||||
options=options,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
download_ytdl_options=download_ytdl_options,
|
||||
metadata_ytdl_options=metadata_ytdl_options,
|
||||
|
|
|
|||
72
src/ytdl_sub/downloaders/source_plugin.py
Normal file
72
src/ytdl_sub/downloaders/source_plugin.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import abc
|
||||
from abc import ABC
|
||||
from typing import Dict
|
||||
from typing import Generic
|
||||
from typing import Iterable
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
from typing import final
|
||||
|
||||
from ytdl_sub.config.plugin import BasePlugin
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.config.preset_options import TOptionsValidator
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
|
||||
class SourcePluginExtension(Plugin[TOptionsValidator], ABC):
|
||||
"""
|
||||
Plugins that get added automatically by using a downloader. Downloader options
|
||||
are the plugin options.
|
||||
"""
|
||||
|
||||
@final
|
||||
def ytdl_options(self) -> Optional[Dict]:
|
||||
"""
|
||||
SourcePluginExtensions are intended to run after downloading. ytdl_options at that point
|
||||
are not needed.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class SourcePlugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
|
||||
plugin_extensions: List[Type[SourcePluginExtension]] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
options: TOptionsValidator,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
download_ytdl_options: YTDLOptionsBuilder,
|
||||
metadata_ytdl_options: YTDLOptionsBuilder,
|
||||
overrides: Overrides,
|
||||
):
|
||||
super().__init__(
|
||||
options=options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
)
|
||||
self._download_ytdl_options_builder = download_ytdl_options
|
||||
self._metadata_ytdl_options_builder = metadata_ytdl_options
|
||||
|
||||
@abc.abstractmethod
|
||||
def download_metadata(self) -> Iterable[Entry]:
|
||||
"""Gathers metadata of all entries to download"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def download(self, entry: Entry) -> Entry:
|
||||
"""The function to perform the download of all media entries"""
|
||||
|
||||
@final
|
||||
def added_plugins(self) -> List[SourcePluginExtension]:
|
||||
"""Add these plugins from the Downloader to the subscription"""
|
||||
return [
|
||||
plugin_extension(
|
||||
options=self.plugin_options,
|
||||
overrides=self.overrides,
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
)
|
||||
for plugin_extension in self.plugin_extensions
|
||||
]
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import abc
|
||||
import contextlib
|
||||
import os
|
||||
from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from typing import Iterable
|
||||
|
|
@ -11,11 +9,12 @@ from typing import Optional
|
|||
from typing import Set
|
||||
from typing import Tuple
|
||||
|
||||
from yt_dlp.utils import RejectedVideoReached
|
||||
|
||||
from ytdl_sub.config.plugin import PluginPriority
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderOptionsT
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderPlugin
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
|
||||
from ytdl_sub.downloaders.source_plugin import SourcePlugin
|
||||
from ytdl_sub.downloaders.source_plugin import SourcePluginExtension
|
||||
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
|
||||
from ytdl_sub.downloaders.url.validators import UrlThumbnailListValidator
|
||||
from ytdl_sub.downloaders.url.validators import UrlValidator
|
||||
|
|
@ -31,75 +30,41 @@ from ytdl_sub.entries.variables.kwargs import REQUESTED_SUBTITLES
|
|||
from ytdl_sub.entries.variables.kwargs import SOURCE_ENTRY
|
||||
from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS
|
||||
from ytdl_sub.entries.variables.kwargs import UPLOAD_DATE_INDEX
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_MATCH_FILTER_REJECT
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.utils.thumbnail import ThumbnailTypes
|
||||
from ytdl_sub.utils.thumbnail import convert_download_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.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
|
||||
download_logger = Logger.get(name="downloader")
|
||||
|
||||
|
||||
class DownloaderValidator(BaseDownloaderValidator, ABC):
|
||||
"""
|
||||
Placeholder class to define downloader options
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def collection_validator(self) -> MultiUrlValidator:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
MultiUrlValidator
|
||||
To determine how the entries are downloaded
|
||||
"""
|
||||
|
||||
def added_source_variables(self) -> List[str]:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
Added source variables on the collection
|
||||
"""
|
||||
return self.collection_validator.added_source_variables()
|
||||
|
||||
def validate_with_variables(
|
||||
self, source_variables: List[str], override_variables: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Validates any source variables added by the collection
|
||||
"""
|
||||
self.collection_validator.validate_with_variables(
|
||||
source_variables=source_variables, override_variables=override_variables
|
||||
)
|
||||
|
||||
|
||||
class URLDownloadState:
|
||||
def __init__(self, entries_total: int):
|
||||
self.entries_total = entries_total
|
||||
self.entries_downloaded = 0
|
||||
|
||||
|
||||
class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
|
||||
class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
|
||||
priority = PluginPriority(modify_entry=0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader_options: DownloaderValidator,
|
||||
options: MultiUrlValidator,
|
||||
overrides: Overrides,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
):
|
||||
super().__init__(
|
||||
downloader_options=downloader_options,
|
||||
options=options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
)
|
||||
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 downloader_options.collection_validator.urls.list
|
||||
for collection_url in options.urls.list
|
||||
}
|
||||
|
||||
def _download_parent_thumbnails(
|
||||
|
|
@ -117,11 +82,9 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
|
|||
|
||||
# If latest entry, always update the thumbnail on each entry
|
||||
if thumbnail_id == ThumbnailTypes.LATEST_ENTRY:
|
||||
# Make sure the entry's thumbnail is converted to jpg
|
||||
convert_download_thumbnail(entry, error_if_not_found=False)
|
||||
|
||||
# always save in dry-run even if it doesn't exist...
|
||||
if self.is_dry_run or os.path.isfile(entry.get_download_thumbnail_path()):
|
||||
if self.is_dry_run or entry.is_thumbnail_downloaded():
|
||||
self.save_file(
|
||||
file_name=entry.get_download_thumbnail_name(),
|
||||
output_file_name=thumbnail_name,
|
||||
|
|
@ -173,8 +136,14 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
|
|||
|
||||
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||
"""
|
||||
Use the entry to download thumbnails (or move if LATEST_ENTRY)
|
||||
Use the entry to download thumbnails (or move if LATEST_ENTRY).
|
||||
In addition, convert the entry thumbnail to jpg
|
||||
"""
|
||||
# We always convert entry thumbnails to jpgs, and is performed here to be done
|
||||
# as early as possible in the plugin pipeline (downstream plugins depend on it being jpg)
|
||||
if not self.is_dry_run:
|
||||
try_convert_download_thumbnail(entry=entry)
|
||||
|
||||
if entry.kwargs_get(COLLECTION_URL) in self._collection_url_mapping:
|
||||
self._download_url_thumbnails(
|
||||
collection_url=self._collection_url_mapping[entry.kwargs(COLLECTION_URL)],
|
||||
|
|
@ -183,22 +152,24 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
|
|||
return entry
|
||||
|
||||
|
||||
class UrlDownloaderCollectionVariablePlugin(BaseDownloaderPlugin):
|
||||
class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension):
|
||||
priority = PluginPriority(modify_entry_metadata=0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader_options: DownloaderValidator,
|
||||
options: MultiUrlValidator,
|
||||
overrides: Overrides,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
):
|
||||
super().__init__(
|
||||
downloader_options=downloader_options,
|
||||
options=options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
)
|
||||
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 downloader_options.collection_validator.urls.list
|
||||
for collection_url in options.urls.list
|
||||
}
|
||||
|
||||
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
|
||||
|
|
@ -220,36 +191,14 @@ class UrlDownloaderCollectionVariablePlugin(BaseDownloaderPlugin):
|
|||
return entry
|
||||
|
||||
|
||||
class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
||||
class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
|
||||
"""
|
||||
Class that interacts with ytdl to perform the download of metadata and content,
|
||||
and should translate that to list of Entry objects.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def added_plugins(
|
||||
cls,
|
||||
downloader_options: BaseDownloaderOptionsT,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
overrides: Overrides,
|
||||
) -> List[Plugin]:
|
||||
"""
|
||||
Adds
|
||||
1. URL thumbnail download plugin
|
||||
2. Collection variable plugin to add to each entry
|
||||
"""
|
||||
return [
|
||||
UrlDownloaderThumbnailPlugin(
|
||||
downloader_options=downloader_options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
),
|
||||
UrlDownloaderCollectionVariablePlugin(
|
||||
downloader_options=downloader_options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
),
|
||||
]
|
||||
plugin_options_type = MultiUrlValidator
|
||||
plugin_extensions = [UrlDownloaderThumbnailPlugin, UrlDownloaderCollectionVariablePlugin]
|
||||
|
||||
@classmethod
|
||||
def ytdl_option_defaults(cls) -> Dict:
|
||||
|
|
@ -263,7 +212,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
download_options: BaseDownloaderOptionsT,
|
||||
options: MultiUrlValidator,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
download_ytdl_options: YTDLOptionsBuilder,
|
||||
metadata_ytdl_options: YTDLOptionsBuilder,
|
||||
|
|
@ -272,7 +221,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
"""
|
||||
Parameters
|
||||
----------
|
||||
download_options
|
||||
options
|
||||
Options validator for this downloader
|
||||
enhanced_download_archive
|
||||
Download archive
|
||||
|
|
@ -284,7 +233,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
Override variables
|
||||
"""
|
||||
super().__init__(
|
||||
download_options=download_options,
|
||||
options=options,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
download_ytdl_options=download_ytdl_options,
|
||||
metadata_ytdl_options=metadata_ytdl_options,
|
||||
|
|
@ -349,7 +298,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
@property
|
||||
def collection(self) -> MultiUrlValidator:
|
||||
"""Return the download options collection"""
|
||||
return self.download_options.collection_validator
|
||||
return self.plugin_options
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _separate_download_archives(self, clear_info_json_files: bool = False):
|
||||
|
|
@ -401,7 +350,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
|
||||
is_thumbnail_downloaded_fn=None
|
||||
if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
|
||||
else entry.is_thumbnail_downloaded,
|
||||
else entry.is_thumbnail_downloaded_via_ytdlp,
|
||||
url=entry.webpage_url,
|
||||
)
|
||||
return Entry(download_entry_dict, working_directory=self.working_directory)
|
||||
|
|
@ -523,6 +472,11 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
Returns
|
||||
-------
|
||||
The entry that was downloaded successfully
|
||||
|
||||
Raises
|
||||
------
|
||||
RejectedVideoReached
|
||||
If a video was rejected and was not from match_filter
|
||||
"""
|
||||
download_logger.info(
|
||||
"Downloading entry %d/%d: %s",
|
||||
|
|
@ -530,7 +484,17 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
|
|||
self._url_state.entries_total,
|
||||
entry.title,
|
||||
)
|
||||
download_entry = self._extract_entry_info_with_retry(entry=entry)
|
||||
|
||||
# Match-filters are applied at the download stage (not metadata stage).
|
||||
# If the download is rejected, and match_filter is present in the ytdl options,
|
||||
# then filter downstream in the match_filter plugin
|
||||
try:
|
||||
download_entry = self._extract_entry_info_with_retry(entry=entry)
|
||||
except RejectedVideoReached:
|
||||
if "match_filter" in self.download_ytdl_options:
|
||||
entry.add_kwargs({YTDL_SUB_MATCH_FILTER_REJECT: True})
|
||||
return entry
|
||||
raise
|
||||
|
||||
upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date(
|
||||
upload_date_standardized=entry.upload_date_standardized
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
from typing import Dict
|
||||
from typing import List
|
||||
|
||||
from ytdl_sub.downloaders.url.downloader import BaseUrlDownloader
|
||||
from ytdl_sub.downloaders.url.downloader import DownloaderValidator
|
||||
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
|
||||
|
||||
|
||||
class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
|
||||
# TODO: Remove later - keep for docstring
|
||||
class MultiUrlDownloadOptions(MultiUrlValidator):
|
||||
"""
|
||||
Downloads from multiple URLs. If an entry is returned from more than one URL, it will
|
||||
resolve to the bottom-most URL settings.
|
||||
|
|
@ -19,7 +15,6 @@ class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
|
|||
my_example_preset:
|
||||
download:
|
||||
# required
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
|
||||
variables:
|
||||
|
|
@ -40,29 +35,3 @@ class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
|
|||
- name: "season{season_index}-poster.jpg"
|
||||
uid: "latest_entry"
|
||||
"""
|
||||
|
||||
@property
|
||||
def collection_validator(self) -> MultiUrlValidator:
|
||||
"""Returns itself!"""
|
||||
return self
|
||||
|
||||
def validate_with_variables(
|
||||
self, source_variables: List[str], override_variables: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Validates any source variables added by the collection
|
||||
"""
|
||||
super().validate_with_variables(
|
||||
source_variables=source_variables, override_variables=override_variables
|
||||
)
|
||||
|
||||
has_non_empty_url = False
|
||||
for url_validator in self.urls.list:
|
||||
has_non_empty_url |= bool(url_validator.url.apply_formatter(override_variables))
|
||||
|
||||
if not has_non_empty_url:
|
||||
raise self._validation_exception("Must contain at least one url that is non-empty")
|
||||
|
||||
|
||||
class MultiUrlDownloader(BaseUrlDownloader[MultiUrlDownloadOptions]):
|
||||
downloader_options_type = MultiUrlDownloadOptions
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
from ytdl_sub.downloaders.url.downloader import BaseUrlDownloader
|
||||
from ytdl_sub.downloaders.url.downloader import DownloaderValidator
|
||||
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
|
||||
from ytdl_sub.downloaders.url.validators import UrlValidator
|
||||
|
||||
|
||||
class UrlDownloadOptions(UrlValidator, DownloaderValidator):
|
||||
# TODO: Remove later - keep for docstring
|
||||
class UrlDownloadOptions(UrlValidator):
|
||||
"""
|
||||
Downloads from a single URL supported by yt-dlp.
|
||||
|
||||
|
|
@ -16,7 +14,6 @@ class UrlDownloadOptions(UrlValidator, DownloaderValidator):
|
|||
my_example_preset:
|
||||
download:
|
||||
# required
|
||||
download_strategy: "url"
|
||||
url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
|
||||
# optional
|
||||
playlist_thumbnails:
|
||||
|
|
@ -26,15 +23,3 @@ class UrlDownloadOptions(UrlValidator, DownloaderValidator):
|
|||
uid: "banner_uncropped"
|
||||
download_reverse: True
|
||||
"""
|
||||
|
||||
@property
|
||||
def collection_validator(self) -> MultiUrlValidator:
|
||||
"""Returns itself!"""
|
||||
return MultiUrlValidator(
|
||||
name=self._name,
|
||||
value={"urls": [self._value]},
|
||||
)
|
||||
|
||||
|
||||
class UrlDownloader(BaseUrlDownloader[UrlDownloadOptions]):
|
||||
downloader_options_type = UrlDownloadOptions
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import copy
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
|
|
@ -75,9 +76,7 @@ class UrlValidator(StrictDictValidator):
|
|||
@property
|
||||
def url(self) -> OverridesStringFormatterValidator:
|
||||
"""
|
||||
Required. URL to download from, listed in priority from lowest (top) to highest (bottom).
|
||||
If a download exists in more than one URL, it will resolve to the bottom-most one and
|
||||
inherit those variables.
|
||||
Required. URL to download from.
|
||||
"""
|
||||
return self._url
|
||||
|
||||
|
|
@ -147,8 +146,27 @@ class UrlValidator(StrictDictValidator):
|
|||
return self._download_reverse.value
|
||||
|
||||
|
||||
class UrlListValidator(ListValidator[UrlValidator]):
|
||||
_inner_list_type = UrlValidator
|
||||
class UrlStringOrDictValidator(UrlValidator):
|
||||
"""
|
||||
URL validator that supports a single string like:
|
||||
|
||||
download:
|
||||
- "https://"
|
||||
|
||||
or
|
||||
|
||||
download:
|
||||
- url: "https://"
|
||||
"""
|
||||
|
||||
_expected_value_type = (dict, str)
|
||||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, {"url": value} if isinstance(value, str) else value)
|
||||
|
||||
|
||||
class UrlListValidator(ListValidator[UrlStringOrDictValidator]):
|
||||
_inner_list_type = UrlStringOrDictValidator
|
||||
_expected_value_type_name = "collection url list"
|
||||
|
||||
def __init__(self, name, value):
|
||||
|
|
@ -180,25 +198,38 @@ class MultiUrlValidator(OptionsValidator):
|
|||
resolve to the bottom-most URL settings.
|
||||
"""
|
||||
|
||||
_required_keys = {"urls"}
|
||||
|
||||
@classmethod
|
||||
def partial_validate(cls, name: str, value: Any) -> None:
|
||||
"""
|
||||
Partially validate a collection
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
value["urls"] = value.get("urls", [{"url": "placeholder"}])
|
||||
value["url"] = value.get("url", "sadfasdf")
|
||||
_ = cls(name, value)
|
||||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
self._urls = self._validate_key(key="urls", validator=UrlListValidator)
|
||||
|
||||
# Copy since we're popping things
|
||||
value_copy = copy.deepcopy(value)
|
||||
if isinstance(value, dict):
|
||||
# Pop old required field in case it's still there
|
||||
value_copy.pop("download_strategy", None)
|
||||
|
||||
# Deal with old multi-url download strategy
|
||||
if isinstance(value, dict) and "urls" in value_copy:
|
||||
self._urls = UrlListValidator(name=name, value=value_copy["urls"])
|
||||
else:
|
||||
self._urls = UrlListValidator(name=name, value=value_copy)
|
||||
|
||||
@property
|
||||
def urls(self) -> UrlListValidator:
|
||||
"""
|
||||
Required. A list of :ref:`url` with the addition of the ``variables`` attribute.
|
||||
Multiple URLs should be listed in the order of priority, with the lowest priority being the
|
||||
top-most, and highest priority being the bottom-most. If a download exists in more than
|
||||
one URL, it will resolve to the bottom-most one (the highest priority) and
|
||||
inherit those variables.
|
||||
"""
|
||||
return self._urls
|
||||
|
||||
|
|
@ -246,3 +277,11 @@ class MultiUrlValidator(OptionsValidator):
|
|||
_ = StringFormatterValidator(
|
||||
name=f"{self._name}.{source_var_name}", value=source_var_formatter_str
|
||||
).apply_formatter(base_variables)
|
||||
|
||||
# Ensure at least URL is non-empty
|
||||
has_non_empty_url = False
|
||||
for url_validator in self.urls.list:
|
||||
has_non_empty_url |= bool(url_validator.url.apply_formatter(override_variables))
|
||||
|
||||
if not has_non_empty_url:
|
||||
raise self._validation_exception("Must contain at least one url that is non-empty")
|
||||
|
|
|
|||
|
|
@ -86,10 +86,12 @@ class YTDLP:
|
|||
If the entry fails to download
|
||||
"""
|
||||
num_tries = 0
|
||||
entry_files_exist = False
|
||||
copied_ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides)
|
||||
|
||||
while not entry_files_exist and num_tries < cls._EXTRACT_ENTRY_NUM_RETRIES:
|
||||
is_downloaded = False
|
||||
entry_dict: Optional[Dict] = None
|
||||
|
||||
while num_tries < cls._EXTRACT_ENTRY_NUM_RETRIES:
|
||||
entry_dict = cls.extract_info(
|
||||
ytdl_options_overrides=copied_ytdl_options_overrides, **kwargs
|
||||
)
|
||||
|
|
@ -102,6 +104,10 @@ class YTDLP:
|
|||
if is_downloaded and is_thumbnail_downloaded:
|
||||
return entry_dict
|
||||
|
||||
# Always add check_formats
|
||||
# See https://github.com/yt-dlp/yt-dlp/issues/502
|
||||
copied_ytdl_options_overrides["check_formats"] = True
|
||||
|
||||
# If the video file is downloaded but the thumbnail is not, then do not download
|
||||
# the video again
|
||||
if is_downloaded and not is_thumbnail_downloaded:
|
||||
|
|
@ -111,7 +117,7 @@ class YTDLP:
|
|||
time.sleep(cls._EXTRACT_ENTRY_RETRY_WAIT_SEC)
|
||||
num_tries += 1
|
||||
|
||||
# Remove the download archive so it can retry without thinking its already downloaded,
|
||||
# Remove the download archive to retry without thinking its already downloaded,
|
||||
# even though it is not
|
||||
if "download_archive" in copied_ytdl_options_overrides:
|
||||
del copied_ytdl_options_overrides["download_archive"]
|
||||
|
|
@ -123,6 +129,10 @@ class YTDLP:
|
|||
cls._EXTRACT_ENTRY_NUM_RETRIES,
|
||||
)
|
||||
|
||||
# Still return if the media file downloaded (thumbnail could be missing)
|
||||
if is_downloaded and entry_dict is not None:
|
||||
return entry_dict
|
||||
|
||||
error_dict = {"ytdl_options": ytdl_options_overrides, "kwargs": kwargs}
|
||||
raise FileNotDownloadedException(
|
||||
f"yt-dlp failed to download an entry with these arguments: {error_dict}"
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class Entry(EntryVariables, BaseEntry):
|
|||
"""Returns the entry's thumbnail's file path to where it was downloaded"""
|
||||
return str(Path(self.working_directory()) / self.get_download_thumbnail_name())
|
||||
|
||||
def get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
|
||||
def try_get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
|
||||
"""
|
||||
The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
|
||||
not match. Return the actual downloaded thumbnail path.
|
||||
|
|
@ -83,14 +83,23 @@ class Entry(EntryVariables, BaseEntry):
|
|||
with open(self.get_download_info_json_path(), "w", encoding="utf-8") as file:
|
||||
file.write(kwargs_json)
|
||||
|
||||
@final
|
||||
def is_thumbnail_downloaded_via_ytdlp(self) -> bool:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
True if ANY thumbnail file exist locally. False otherwise.
|
||||
"""
|
||||
return self.try_get_ytdlp_download_thumbnail_path() is not None
|
||||
|
||||
@final
|
||||
def is_thumbnail_downloaded(self) -> bool:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
True if the thumbnail file exist locally. False otherwise.
|
||||
True if the thumbnail file exists and is its proper format. False otherwise.
|
||||
"""
|
||||
return self.get_ytdlp_download_thumbnail_path() is not None
|
||||
return os.path.isfile(self.get_download_thumbnail_path())
|
||||
|
||||
@final
|
||||
def is_downloaded(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from ytdl_sub.entries.variables.kwargs import PLAYLIST_UPLOADER
|
|||
from ytdl_sub.entries.variables.kwargs import PLAYLIST_UPLOADER_ID
|
||||
from ytdl_sub.entries.variables.kwargs import PLAYLIST_UPLOADER_URL
|
||||
from ytdl_sub.entries.variables.kwargs import PLAYLIST_WEBPAGE_URL
|
||||
from ytdl_sub.entries.variables.kwargs import RELEASE_DATE
|
||||
from ytdl_sub.entries.variables.kwargs import SOURCE_COUNT
|
||||
from ytdl_sub.entries.variables.kwargs import SOURCE_DESCRIPTION
|
||||
from ytdl_sub.entries.variables.kwargs import SOURCE_INDEX
|
||||
|
|
@ -668,3 +669,189 @@ class EntryVariables(BaseEntryVariables):
|
|||
The uploaded date formatted as YYYY-MM-DD
|
||||
"""
|
||||
return f"{self.upload_year}-{self.upload_month_padded}-{self.upload_day_padded}"
|
||||
|
||||
@property
|
||||
def release_date(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The entry's release date, in YYYYMMDD format. If not present, return the upload date.
|
||||
"""
|
||||
return self.kwargs_get(RELEASE_DATE, self.upload_date)
|
||||
|
||||
@property
|
||||
def release_year(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The entry's release year
|
||||
"""
|
||||
return int(self.release_date[:4])
|
||||
|
||||
@property
|
||||
def release_year_truncated(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The last two digits of the release year, i.e. 22 in 2022
|
||||
"""
|
||||
return int(str(self.release_year)[-2:])
|
||||
|
||||
@property
|
||||
def release_year_truncated_reversed(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The release year truncated, but reversed using ``100 - {release_year_truncated}``, i.e.
|
||||
2022 returns ``100 - 22`` = ``78``
|
||||
"""
|
||||
return 100 - self.release_year_truncated
|
||||
|
||||
@property
|
||||
def release_month_reversed(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The release month, but reversed
|
||||
using ``13 - {release_month}``, i.e. March returns ``10``
|
||||
"""
|
||||
return 13 - self.release_month
|
||||
|
||||
@property
|
||||
def release_month_reversed_padded(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The reversed release month, but padded. i.e. November returns "02"
|
||||
"""
|
||||
return pad(self.release_month_reversed)
|
||||
|
||||
@property
|
||||
def release_month_padded(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The entry's release month padded to two digits, i.e. March returns "03"
|
||||
"""
|
||||
return self.release_date[4:6]
|
||||
|
||||
@property
|
||||
def release_day_padded(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The entry's release day padded to two digits, i.e. the fifth returns "05"
|
||||
"""
|
||||
return self.release_date[6:8]
|
||||
|
||||
@property
|
||||
def release_month(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The release month as an integer (no padding).
|
||||
"""
|
||||
return int(self.release_month_padded.lstrip("0"))
|
||||
|
||||
@property
|
||||
def release_day(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The release day as an integer (no padding).
|
||||
"""
|
||||
return int(self.release_day_padded.lstrip("0"))
|
||||
|
||||
@property
|
||||
def release_day_reversed(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The release day, but reversed using ``{total_days_in_month} + 1 - {release_day}``,
|
||||
i.e. August 8th would have release_day_reversed of ``31 + 1 - 8`` = ``24``
|
||||
"""
|
||||
total_days_in_month = _days_in_month[self.release_month]
|
||||
if self.release_month == 2 and self.release_year % 4 == 0: # leap year
|
||||
total_days_in_month += 1
|
||||
|
||||
return total_days_in_month + 1 - self.release_day
|
||||
|
||||
@property
|
||||
def release_day_reversed_padded(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The reversed release day, but padded. i.e. August 30th returns "02".
|
||||
"""
|
||||
return pad(self.release_day_reversed)
|
||||
|
||||
@property
|
||||
def release_day_of_year(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The day of the year, i.e. February 1st returns ``32``
|
||||
"""
|
||||
output = sum(_days_in_month[: self.release_month]) + self.release_day
|
||||
if self.release_month > 2 and self.release_year % 4 == 0:
|
||||
output += 1
|
||||
|
||||
return output
|
||||
|
||||
@property
|
||||
def release_day_of_year_padded(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The release day of year, but padded i.e. February 1st returns "032"
|
||||
"""
|
||||
return pad(self.release_day_of_year, width=3)
|
||||
|
||||
@property
|
||||
def release_day_of_year_reversed(self: Self) -> int:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The release day, but reversed using ``{total_days_in_year} + 1 - {release_day}``,
|
||||
i.e. February 2nd would have release_day_of_year_reversed of ``365 + 1 - 32`` = ``334``
|
||||
"""
|
||||
total_days_in_year = 365
|
||||
if self.release_year % 4 == 0:
|
||||
total_days_in_year += 1
|
||||
|
||||
return total_days_in_year + 1 - self.release_day_of_year
|
||||
|
||||
@property
|
||||
def release_day_of_year_reversed_padded(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The reversed release day of year, but padded i.e. December 31st returns "001"
|
||||
"""
|
||||
return pad(self.release_day_of_year_reversed, width=3)
|
||||
|
||||
@property
|
||||
def release_date_standardized(self: Self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The release date formatted as YYYY-MM-DD
|
||||
"""
|
||||
return f"{self.release_year}-{self.release_month_padded}-{self.release_day_padded}"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ UPLOAD_DATE_INDEX = _("upload_date_index", backend=True)
|
|||
REQUESTED_SUBTITLES = _("requested_subtitles", backend=True)
|
||||
CHAPTERS = _("chapters", backend=True)
|
||||
YTDL_SUB_CUSTOM_CHAPTERS = _("ytdl_sub_custom_chapters", backend=True)
|
||||
YTDL_SUB_REGEX_SOURCE_VARS = _("ytdl_sub_regex_source_vars", backend=True)
|
||||
YTDL_SUB_MATCH_FILTER_REJECT = _("ytdl_sub_match_filter_reject", backend=True)
|
||||
SPONSORBLOCK_CHAPTERS = _("sponsorblock_chapters", backend=True)
|
||||
SPLIT_BY_CHAPTERS_PARENT_ENTRY = _("split_by_chapters_parent_entry", backend=True)
|
||||
COMMENTS = _("comments", backend=True)
|
||||
|
|
@ -60,6 +62,7 @@ EXT = _("ext")
|
|||
TITLE = _("title")
|
||||
DESCRIPTION = _("description")
|
||||
WEBPAGE_URL = _("webpage_url")
|
||||
RELEASE_DATE = _("release_date")
|
||||
UPLOAD_DATE = _("upload_date")
|
||||
UPLOADER = _("uploader")
|
||||
UPLOADER_ID = _("uploader_id")
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@ from typing import Any
|
|||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.exceptions import FileNotDownloadedException
|
||||
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_TYPES_EXTENSION_MAPPING
|
||||
from ytdl_sub.validators.audo_codec_validator import AudioTypeValidator
|
||||
from ytdl_sub.validators.validators import FloatValidator
|
||||
|
||||
|
||||
class AudioExtractOptions(PluginOptions):
|
||||
class AudioExtractOptions(OptionsDictValidator):
|
||||
"""
|
||||
Extracts audio from a video file.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ from typing import List
|
|||
from typing import Optional
|
||||
from typing import Set
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.entries.variables.kwargs import COMMENTS
|
||||
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_CUSTOM_CHAPTERS
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.chapters import Chapters
|
||||
from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
|
|
@ -58,7 +58,7 @@ class SponsorBlockCategoryListValidator(ListValidator[SponsorBlockCategoriesVali
|
|||
_inner_list_type = SponsorBlockCategoriesValidator
|
||||
|
||||
|
||||
class ChaptersOptions(PluginOptions):
|
||||
class ChaptersOptions(OptionsDictValidator):
|
||||
"""
|
||||
Embeds chapters to video files if they are present. Additional options to add SponsorBlock
|
||||
chapters and remove specific ones. Can also remove chapters using regex.
|
||||
|
|
@ -302,21 +302,24 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
|
|||
"""
|
||||
chapters = Chapters.from_empty()
|
||||
|
||||
# If there are no embedded chapters, and comment chapters are allowed...
|
||||
if not _contains_any_chapters(entry) and self.plugin_options.allow_chapters_from_comments:
|
||||
# Try to get chapters from comments
|
||||
for comment in entry.kwargs_get(COMMENTS, []):
|
||||
chapters = Chapters.from_string(comment.get("text", ""))
|
||||
if not chapters.is_empty():
|
||||
if chapters.contains_any_chapters():
|
||||
break
|
||||
|
||||
if not chapters.is_empty():
|
||||
entry.add_kwargs({YTDL_SUB_CUSTOM_CHAPTERS: chapters.to_file_metadata_dict()})
|
||||
# If some are actually found, add a special kwarg and embed them
|
||||
if chapters.contains_any_chapters():
|
||||
entry.add_kwargs({YTDL_SUB_CUSTOM_CHAPTERS: chapters.to_file_metadata_dict()})
|
||||
|
||||
if not self.is_dry_run:
|
||||
set_ffmpeg_metadata_chapters(
|
||||
file_path=entry.get_download_file_path(),
|
||||
chapters=chapters,
|
||||
file_duration_sec=entry.kwargs("duration"),
|
||||
)
|
||||
if not self.is_dry_run:
|
||||
set_ffmpeg_metadata_chapters(
|
||||
file_path=entry.get_download_file_path(),
|
||||
chapters=chapters,
|
||||
file_duration_sec=entry.kwargs("duration"),
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.datetime import to_date_range
|
||||
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
|
||||
|
||||
|
||||
class DateRangeOptions(PluginOptions):
|
||||
class DateRangeOptions(OptionsDictValidator):
|
||||
"""
|
||||
Only download files uploaded within the specified date range.
|
||||
|
||||
|
|
|
|||
104
src/ytdl_sub/plugins/embed_thumbnail.py
Normal file
104
src/ytdl_sub/plugins/embed_thumbnail.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import mediafile
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.plugin import PluginPriority
|
||||
from ytdl_sub.config.preset_options import OptionsValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
|
||||
from ytdl_sub.validators.validators import BoolValidator
|
||||
|
||||
logger = Logger.get("embed_thumbnail")
|
||||
|
||||
|
||||
class EmbedThumbnailOptions(BoolValidator, OptionsValidator):
|
||||
"""
|
||||
Whether to embed thumbnails to the audio/video file or not.
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
presets:
|
||||
my_example_preset:
|
||||
embed_thumbnail: True
|
||||
"""
|
||||
|
||||
|
||||
class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
|
||||
plugin_options_type = EmbedThumbnailOptions
|
||||
priority = PluginPriority(post_process=PluginPriority.POST_PROCESS_AFTER_FILE_CONVERT)
|
||||
|
||||
@property
|
||||
def _embed_thumbnail(self) -> bool:
|
||||
return self.plugin_options.value
|
||||
|
||||
@classmethod
|
||||
def _embed_video_thumbnail(cls, entry: Entry) -> None:
|
||||
file_path = entry.get_download_file_path()
|
||||
thumbnail_path = entry.get_download_thumbnail_path()
|
||||
tmp_file_path = FFMPEG.tmp_file_path(file_path)
|
||||
try:
|
||||
ffmpeg_args: List[str] = [
|
||||
"-i",
|
||||
file_path,
|
||||
"-i",
|
||||
thumbnail_path,
|
||||
"-map",
|
||||
"1",
|
||||
"-map",
|
||||
"0",
|
||||
"-dn", # ignore data streams
|
||||
"-c",
|
||||
"copy",
|
||||
"-bitexact", # for reproducibility
|
||||
"-disposition:0",
|
||||
"attached_pic",
|
||||
tmp_file_path,
|
||||
]
|
||||
FFMPEG.run(ffmpeg_args)
|
||||
FileHandler.move(tmp_file_path, file_path)
|
||||
finally:
|
||||
FileHandler.delete(tmp_file_path)
|
||||
|
||||
@classmethod
|
||||
def _embed_audio_file(cls, entry: Entry) -> None:
|
||||
audio_file = mediafile.MediaFile(entry.get_download_file_path())
|
||||
with open(entry.get_download_thumbnail_path(), "rb") as thumb:
|
||||
mediafile_img = mediafile.Image(
|
||||
data=thumb.read(), desc="cover", type=mediafile.ImageType.front
|
||||
)
|
||||
|
||||
audio_file.images = [mediafile_img]
|
||||
audio_file.save()
|
||||
|
||||
def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]:
|
||||
"""
|
||||
Maybe embed the thumbnail
|
||||
"""
|
||||
if not self._embed_thumbnail:
|
||||
return None
|
||||
|
||||
if entry.ext == "webm":
|
||||
logger.warning("webm does not support embedded thumbnails, skipping")
|
||||
return None
|
||||
|
||||
if not self.is_dry_run:
|
||||
if not entry.is_thumbnail_downloaded():
|
||||
logger.warning(
|
||||
"Cannot embed thumbnail for '%s' because it is not available", entry.title
|
||||
)
|
||||
return None
|
||||
|
||||
if entry.ext in AUDIO_CODEC_EXTS:
|
||||
self._embed_audio_file(entry)
|
||||
else:
|
||||
self._embed_video_thumbnail(entry)
|
||||
|
||||
return FileMetadata("Embedded thumbnail")
|
||||
|
|
@ -3,11 +3,11 @@ from typing import Any
|
|||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.plugin import PluginPriority
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.entries.variables.kwargs import EXT
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.plugins.plugin import PluginPriority
|
||||
from ytdl_sub.utils.exceptions import FileNotDownloadedException
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||
|
|
@ -22,7 +22,7 @@ class FileConvertWithValidator(StringSelectValidator):
|
|||
_select_values = {"yt-dlp", "ffmpeg"}
|
||||
|
||||
|
||||
class FileConvertOptions(PluginOptions):
|
||||
class FileConvertOptions(OptionsDictValidator):
|
||||
"""
|
||||
Converts video files from one extension to another.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import copy
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.plugin import PluginPriority
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsOptions
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.plugins.plugin import PluginPriority
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
|
||||
class ViewOptions(PluginOptions):
|
||||
class ViewOptions(OptionsDictValidator):
|
||||
"""
|
||||
INTERNAL PLUGIN. Do not expose in documentation
|
||||
"""
|
||||
|
|
@ -27,11 +26,15 @@ class ViewPlugin(Plugin[ViewOptions]):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_options: OutputDirectoryNfoTagsOptions,
|
||||
options: ViewOptions,
|
||||
overrides: Overrides,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
):
|
||||
super().__init__(plugin_options, overrides, enhanced_download_archive)
|
||||
super().__init__(
|
||||
options=options,
|
||||
overrides=overrides,
|
||||
enhanced_download_archive=enhanced_download_archive,
|
||||
)
|
||||
self._first_entry: Optional[Entry] = None
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -5,15 +5,18 @@ from typing import Optional
|
|||
|
||||
from yt_dlp import match_filter_func
|
||||
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.plugin import PluginPriority
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_MATCH_FILTER_REJECT
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.validators.validators import StringListValidator
|
||||
|
||||
logger = Logger.get("match_filters")
|
||||
|
||||
|
||||
class MatchFiltersOptions(PluginOptions):
|
||||
class MatchFiltersOptions(OptionsDictValidator):
|
||||
"""
|
||||
Set ``--match-filters``` to pass into yt-dlp to filter entries from being downloaded.
|
||||
Uses the same syntax as yt-dlp.
|
||||
|
|
@ -35,8 +38,12 @@ class MatchFiltersOptions(PluginOptions):
|
|||
my_example_preset:
|
||||
match_filters:
|
||||
filters:
|
||||
- "original_url!*=/shorts/"
|
||||
- "!is_live"
|
||||
- "age_limit<?18"
|
||||
- "like_count>?100"
|
||||
# Other common match-filters
|
||||
# - "original_url!*=/shorts/ & !is_live"
|
||||
# - "age_limit<?18"
|
||||
# - "availability=?public"
|
||||
"""
|
||||
|
||||
_required_keys = {"filters"}
|
||||
|
|
@ -56,13 +63,15 @@ class MatchFiltersOptions(PluginOptions):
|
|||
def filters(self) -> List[str]:
|
||||
"""
|
||||
The filters themselves. If used multiple times, the filter matches if at least one of the
|
||||
conditions are met.
|
||||
conditions are met. For logical AND's between match filters, use the ``&`` operator in
|
||||
a single match filter.
|
||||
"""
|
||||
return [validator.value for validator in self._filters]
|
||||
|
||||
|
||||
class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
|
||||
plugin_options_type = MatchFiltersOptions
|
||||
priority = PluginPriority(modify_entry=PluginPriority.MODIFY_ENTRY_FIRST)
|
||||
|
||||
def ytdl_options(self) -> Optional[Dict]:
|
||||
"""
|
||||
|
|
@ -75,4 +84,17 @@ class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
|
|||
logger.debug("Adding match-filter %s", filter_str)
|
||||
match_filters.append(filter_str)
|
||||
|
||||
return {"match_filter": match_filter_func(match_filters)}
|
||||
return {
|
||||
"match_filter": match_filter_func(match_filters),
|
||||
}
|
||||
|
||||
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||
"""
|
||||
If an entry is marked as not being downloaded due to a match_filter reject,
|
||||
do not propagate the entry further (especially since there is no entry file!)
|
||||
"""
|
||||
if entry.kwargs_get(YTDL_SUB_MATCH_FILTER_REJECT, False):
|
||||
logger.info("Entry rejected by match-filter, skipping ..")
|
||||
return None
|
||||
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import copy
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
|
@ -5,12 +6,11 @@ from typing import List
|
|||
|
||||
import mediafile
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
||||
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
|
||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
|
||||
|
|
@ -45,7 +45,7 @@ class MusicTagsValidator(StrictDictValidator):
|
|||
return self._tags
|
||||
|
||||
|
||||
class MusicTagsOptions(PluginOptions):
|
||||
class MusicTagsOptions(OptionsDictValidator):
|
||||
"""
|
||||
Adds tags to every download audio file using
|
||||
`MediaFile <https://mediafile.readthedocs.io/en/latest/>`_,
|
||||
|
|
@ -65,34 +65,31 @@ class MusicTagsOptions(PluginOptions):
|
|||
tags:
|
||||
artist: "{artist}"
|
||||
album: "{album}"
|
||||
genre: "ytdl-sub"
|
||||
# Supports id3v2.4 multi-tags
|
||||
genres:
|
||||
- "{genre}"
|
||||
- "ytdl-sub"
|
||||
albumartists:
|
||||
- "{artist}"
|
||||
- "ytdl-sub"
|
||||
# Optional
|
||||
embed_thumbnail: False
|
||||
"""
|
||||
|
||||
_required_keys = {"tags"}
|
||||
_optional_keys = {"embed_thumbnail"}
|
||||
|
||||
@classmethod
|
||||
def partial_validate(cls, name: str, value: Any) -> None:
|
||||
"""
|
||||
Partially validate music tags
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
value["tags"] = value.get("tags", {})
|
||||
_ = cls(name, value)
|
||||
_optional_keys = {"tags", "embed_thumbnail"}
|
||||
_allow_extra_keys = True
|
||||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
|
||||
self._tags = self._validate_key(key="tags", validator=MusicTagsValidator)
|
||||
self._embed_thumbnail = self._validate_key_if_present(
|
||||
key="embed_thumbnail", validator=BoolValidator, default=False
|
||||
).value
|
||||
key="embed_thumbnail", validator=BoolValidator
|
||||
)
|
||||
|
||||
new_tags_dict: Dict[str, Any] = copy.deepcopy(value)
|
||||
old_tags_dict = new_tags_dict.pop("tags", {})
|
||||
new_tags_dict.pop("embed_thumbnail", None)
|
||||
|
||||
self._is_old_format = len(old_tags_dict) > 0 or self._embed_thumbnail is not None
|
||||
self._tags = MusicTagsValidator(name=name, value=dict(old_tags_dict, **new_tags_dict))
|
||||
|
||||
@property
|
||||
def tags(self) -> MusicTagsValidator:
|
||||
|
|
@ -107,7 +104,9 @@ class MusicTagsOptions(PluginOptions):
|
|||
"""
|
||||
Optional. Whether to embed the thumbnail into the audio file.
|
||||
"""
|
||||
return self._embed_thumbnail
|
||||
if self._embed_thumbnail is None:
|
||||
return False
|
||||
return self._embed_thumbnail.value
|
||||
|
||||
|
||||
class MusicTagsPlugin(Plugin[MusicTagsOptions]):
|
||||
|
|
@ -124,6 +123,20 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
|
|||
f"to audio using the audio_extract plugin."
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
if self.plugin_options._is_old_format:
|
||||
logger.warning(
|
||||
"music_tags.tags is now deprecated. Place your tags directly under music_tags "
|
||||
"instead. The old format will be removed in October of 2023. See "
|
||||
"https://ytdl-sub.readthedocs.io/en/latest/deprecation_notices.html#music-tags "
|
||||
"for more details."
|
||||
)
|
||||
if self.plugin_options.embed_thumbnail:
|
||||
logger.warning(
|
||||
"music_tags.embed_thumbnail is also deprecated. Use the dedicated "
|
||||
"embed_thumbnail plugin instead. This will be removed in October of 2023."
|
||||
)
|
||||
|
||||
# Resolve the tags into this dict
|
||||
tags_to_write: Dict[str, List[str]] = defaultdict(list)
|
||||
for tag_name, tag_formatters in self.plugin_options.tags.as_lists.items():
|
||||
|
|
@ -148,10 +161,7 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
|
|||
)
|
||||
setattr(audio_file, tag_name, tag_value[0])
|
||||
|
||||
if self.plugin_options.embed_thumbnail:
|
||||
# convert the entry thumbnail so it is embedded as jpg
|
||||
convert_download_thumbnail(entry=entry)
|
||||
|
||||
if self.plugin_options.embed_thumbnail and entry.is_thumbnail_downloaded():
|
||||
with open(entry.get_download_thumbnail_path(), "rb") as thumb:
|
||||
mediafile_img = mediafile.Image(
|
||||
data=thumb.read(), desc="cover", type=mediafile.ImageType.front
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ from typing import Dict
|
|||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.xml import XmlElement
|
||||
|
|
@ -23,7 +23,7 @@ from ytdl_sub.validators.string_formatter_validators import StringFormatterValid
|
|||
from ytdl_sub.validators.validators import BoolValidator
|
||||
|
||||
|
||||
class SharedNfoTagsOptions(PluginOptions):
|
||||
class SharedNfoTagsOptions(OptionsDictValidator):
|
||||
"""
|
||||
Shared code between NFO tags and Ouptut Directory NFO Tags
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -93,11 +93,11 @@ class OutputDirectoryNfoTagsPlugin(SharedNfoTagsPlugin):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_options: OutputDirectoryNfoTagsOptions,
|
||||
options: OutputDirectoryNfoTagsOptions,
|
||||
overrides: Overrides,
|
||||
enhanced_download_archive: EnhancedDownloadArchive,
|
||||
):
|
||||
super().__init__(plugin_options, overrides, enhanced_download_archive)
|
||||
super().__init__(options, overrides, enhanced_download_archive)
|
||||
self._last_entry: Optional[Entry] = None
|
||||
|
||||
def post_process_entry(self, entry: Entry) -> None:
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ from typing import Optional
|
|||
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.plugin import PluginPriority
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.plugins.plugin import PluginPriority
|
||||
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_REGEX_SOURCE_VARS
|
||||
from ytdl_sub.utils.exceptions import RegexNoMatchException
|
||||
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.validators.regex_validator import RegexListValidator
|
||||
from ytdl_sub.validators.source_variable_validator import SourceVariableNameListValidator
|
||||
|
|
@ -21,14 +23,14 @@ from ytdl_sub.validators.validators import BoolValidator
|
|||
logger = Logger.get(name="regex")
|
||||
|
||||
|
||||
class SourceVariableRegex(StrictDictValidator):
|
||||
class VariableRegex(StrictDictValidator):
|
||||
|
||||
_required_keys = {"match"}
|
||||
_optional_keys = {"capture_group_defaults", "capture_group_names"}
|
||||
_optional_keys = {"match", "exclude", "capture_group_defaults", "capture_group_names"}
|
||||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
self._match = self._validate_key(key="match", validator=RegexListValidator)
|
||||
self._match = self._validate_key_if_present(key="match", validator=RegexListValidator)
|
||||
self._exclude = self._validate_key_if_present(key="exclude", validator=RegexListValidator)
|
||||
self._capture_group_defaults = self._validate_key_if_present(
|
||||
key="capture_group_defaults", validator=ListFormatterValidator
|
||||
)
|
||||
|
|
@ -36,6 +38,14 @@ class SourceVariableRegex(StrictDictValidator):
|
|||
key="capture_group_names", validator=SourceVariableNameListValidator, default=[]
|
||||
)
|
||||
|
||||
if self._match is None and self._exclude is None:
|
||||
raise self._validation_exception("must specify either `match` or `exclude`")
|
||||
|
||||
if self._match is None and (self._capture_group_defaults or self._capture_group_names.list):
|
||||
raise self._validation_exception(
|
||||
"capture group parameters requires at least one `match` to be specified"
|
||||
)
|
||||
|
||||
# If defaults are to be used, ensure there are the same number of defaults as there are
|
||||
# capture groups
|
||||
if self._capture_group_defaults is not None and self._match.num_capture_groups != len(
|
||||
|
|
@ -47,20 +57,30 @@ class SourceVariableRegex(StrictDictValidator):
|
|||
)
|
||||
|
||||
# If there are capture groups, ensure there are capture group names
|
||||
if len(self._capture_group_names.list) != self._match.num_capture_groups:
|
||||
if self._match and (len(self._capture_group_names.list) != self._match.num_capture_groups):
|
||||
raise self._validation_exception(
|
||||
f"number of capture group names must match number of capture groups, "
|
||||
f"{len(self._capture_group_names.list)} != {self._match.num_capture_groups}"
|
||||
)
|
||||
|
||||
@property
|
||||
def match(self) -> RegexListValidator:
|
||||
def match(self) -> Optional[RegexListValidator]:
|
||||
"""
|
||||
Required. List of regex strings to try to match against a source variable. Each regex
|
||||
List of regex strings to try to match against a source or override variable. Each regex
|
||||
string must have the same number of capture groups.
|
||||
"""
|
||||
return self._match
|
||||
|
||||
@property
|
||||
def exclude(self) -> Optional[RegexListValidator]:
|
||||
"""
|
||||
List of regex strings to try to match against a source or override variable. If one of the
|
||||
regex strings match, then the entry will be skipped. If both ``exclude`` and ``match`` are
|
||||
specified, entries will get skipped if the regex matches against both ``exclude`` and
|
||||
``match``.
|
||||
"""
|
||||
return self._exclude
|
||||
|
||||
@property
|
||||
def capture_group_names(self) -> Optional[List[str]]:
|
||||
"""
|
||||
|
|
@ -96,19 +116,25 @@ class FromSourceVariablesRegex(StrictDictValidator):
|
|||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
self.source_variable_capture_dict: Dict[str, SourceVariableRegex] = {
|
||||
key: self._validate_key(key=key, validator=SourceVariableRegex) for key in self._keys
|
||||
self.variable_capture_dict: Dict[str, VariableRegex] = {
|
||||
key: self._validate_key(key=key, validator=VariableRegex) for key in self._keys
|
||||
}
|
||||
|
||||
|
||||
class RegexOptions(PluginOptions):
|
||||
class RegexOptions(OptionsDictValidator):
|
||||
r"""
|
||||
Performs regex matching on an entry's source variables. Regex can be used to filter entries
|
||||
from proceeding with download or capture groups to create new source variables. NOTE to
|
||||
Performs regex matching on an entry's source or override variables. Regex can be used to filter
|
||||
entries from proceeding with download or capture groups to create new source variables. NOTE to
|
||||
use backslashes anywhere in your regex, i.e. ``\d``, you must add another backslash escape. This
|
||||
means ``\d`` should be written as ``\\d``. This is because YAML requires an escape for any
|
||||
backslash usage.
|
||||
|
||||
If you want to regex-search multiple source variables to create a logical OR effect, you can
|
||||
create an override variable that contains the concatenation of them, and search that with regex.
|
||||
For example, creating the override variable ``"title_and_description": "{title} {description}"``
|
||||
and using ``title_and_description`` can regex match/exclude from either ``title`` or
|
||||
``description``.
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
|
@ -128,7 +154,7 @@ class RegexOptions(PluginOptions):
|
|||
# This will only download videos with "[Official Video]" in it. Note that we
|
||||
# double backslash to make YAML happy
|
||||
match:
|
||||
- '\\[Official Video\\]'
|
||||
- '\\[Official Video\\]'
|
||||
|
||||
# For each entry's `description` value...
|
||||
description:
|
||||
|
|
@ -136,14 +162,17 @@ class RegexOptions(PluginOptions):
|
|||
# This tries to scrape a date from the description and produce new
|
||||
# source variables
|
||||
match:
|
||||
- "([0-9]{4})-([0-9]{2})-([0-9]{2})"
|
||||
- "([0-9]{4})-([0-9]{2})-([0-9]{2})"
|
||||
# Exclude any entry where the description contains #short
|
||||
exclude:
|
||||
- "#short"
|
||||
|
||||
# Each capture group creates these new source variables, respectively,
|
||||
# as well a sanitized version, i.e. `captured_upload_year_sanitized`
|
||||
capture_group_names:
|
||||
- "captured_upload_year"
|
||||
- "captured_upload_month"
|
||||
- "captured_upload_day"
|
||||
- "captured_upload_year"
|
||||
- "captured_upload_month"
|
||||
- "captured_upload_day"
|
||||
|
||||
# And if the string does not match, use these as respective default
|
||||
# values for the new source variables.
|
||||
|
|
@ -195,9 +224,9 @@ class RegexOptions(PluginOptions):
|
|||
"""
|
||||
for key, regex_options in self.source_variable_capture_dict.items():
|
||||
# Ensure each variable getting captured is a source variable
|
||||
if key not in source_variables:
|
||||
if key not in source_variables and key not in override_variables:
|
||||
raise self._validation_exception(
|
||||
f"cannot regex capture '{key}' because it is not a source variable"
|
||||
f"cannot regex capture '{key}' because it is not a source or override variable"
|
||||
)
|
||||
|
||||
# Ensure the capture group names are not existing source/override variables
|
||||
|
|
@ -214,13 +243,13 @@ class RegexOptions(PluginOptions):
|
|||
)
|
||||
|
||||
@property
|
||||
def source_variable_capture_dict(self) -> Dict[str, SourceVariableRegex]:
|
||||
def source_variable_capture_dict(self) -> Dict[str, VariableRegex]:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
Dict of { source variable: capture options }
|
||||
"""
|
||||
return self._from.source_variable_capture_dict
|
||||
return self._from.variable_capture_dict
|
||||
|
||||
def added_source_variables(self) -> List[str]:
|
||||
"""
|
||||
|
|
@ -245,12 +274,66 @@ class RegexPlugin(Plugin[RegexOptions]):
|
|||
modify_entry=PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + 0,
|
||||
)
|
||||
|
||||
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||
@classmethod
|
||||
def _add_processed_regex_variable_name(cls, entry: Entry, source_var: str) -> None:
|
||||
if not entry.kwargs_contains(YTDL_SUB_REGEX_SOURCE_VARS):
|
||||
entry.add_kwargs({YTDL_SUB_REGEX_SOURCE_VARS: []})
|
||||
|
||||
entry.kwargs(YTDL_SUB_REGEX_SOURCE_VARS).append(source_var)
|
||||
|
||||
@classmethod
|
||||
def _contains_processed_regex_variable(cls, entry: Entry, variable_name: str) -> bool:
|
||||
return variable_name in entry.kwargs_get(YTDL_SUB_REGEX_SOURCE_VARS, [])
|
||||
|
||||
def _try_skip_entry(self, entry: Entry, variable_name: str) -> None:
|
||||
# Skip the entry if toggled
|
||||
if self.plugin_options.skip_if_match_fails:
|
||||
logger.info(
|
||||
"Regex failed to match '%s' from '%s', skipping.",
|
||||
variable_name,
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
|
||||
# Otherwise, error
|
||||
raise RegexNoMatchException(f"Regex failed to match '{variable_name}' from '{entry.title}'")
|
||||
|
||||
def _can_process_at_metadata_stage(self, entry: Entry, variable_name: str) -> bool:
|
||||
# If the variable is an override...
|
||||
if variable_name in self.overrides.dict:
|
||||
# Try to see if it can resolve
|
||||
try:
|
||||
self.overrides.apply_formatter(
|
||||
formatter=self.overrides.dict[variable_name],
|
||||
entry=entry,
|
||||
)
|
||||
# If it can not from missing variables (from post-metadata stage), return False
|
||||
except StringFormattingVariableNotFoundException:
|
||||
return False
|
||||
# If it is a source variable and not present, return false
|
||||
elif variable_name not in entry.to_dict():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _get_regex_input_string(self, entry: Entry, variable_name: str) -> str:
|
||||
# Apply override formatter if it's an override
|
||||
if variable_name in self.overrides.dict:
|
||||
return self.overrides.apply_formatter(
|
||||
formatter=self.overrides.dict[variable_name],
|
||||
entry=entry,
|
||||
)
|
||||
# Otherwise pluck from the entry's source variable
|
||||
return entry.to_dict()[variable_name]
|
||||
|
||||
def _modify_entry_metadata(self, entry: Entry, is_metadata_stage: bool) -> Optional[Entry]:
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
entry
|
||||
Entry to add source variables to
|
||||
is_metadata_stage
|
||||
Whether this is called at the metadata stage or modify stage
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
|
@ -261,73 +344,102 @@ class RegexPlugin(Plugin[RegexOptions]):
|
|||
ValidationException
|
||||
If no capture and no defaults
|
||||
"""
|
||||
entry_variable_dict = entry.to_dict()
|
||||
|
||||
# Iterate each source var to capture and add to the entry
|
||||
for source_var, regex_options in self.plugin_options.source_variable_capture_dict.items():
|
||||
maybe_capture = regex_options.match.match_any(input_str=entry_variable_dict[source_var])
|
||||
for (
|
||||
variable_name,
|
||||
regex_options,
|
||||
) in self.plugin_options.source_variable_capture_dict.items():
|
||||
|
||||
# If no capture
|
||||
if maybe_capture is None:
|
||||
# and no defaults
|
||||
if not regex_options.has_defaults:
|
||||
# Skip the entry if toggled
|
||||
if self.plugin_options.skip_if_match_fails:
|
||||
logger.info(
|
||||
"Regex failed to match '%s' from '%s', skipping.",
|
||||
source_var,
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
# Record which regex source variables are processed, to
|
||||
# process as many variables as possible in the metadata stage, then the rest
|
||||
# after the media file has been downloaded.
|
||||
if self._contains_processed_regex_variable(entry, variable_name):
|
||||
continue
|
||||
|
||||
# Otherwise, error
|
||||
raise RegexNoMatchException(
|
||||
f"Regex failed to match '{source_var}' from '{entry.title}'"
|
||||
# If it's the metadata stage, and it can't be processed, skip until post-metadata
|
||||
if is_metadata_stage and not self._can_process_at_metadata_stage(
|
||||
entry=entry, variable_name=variable_name
|
||||
):
|
||||
continue
|
||||
|
||||
self._add_processed_regex_variable_name(entry, variable_name)
|
||||
|
||||
regex_input_str = self._get_regex_input_string(
|
||||
entry=entry,
|
||||
variable_name=variable_name,
|
||||
)
|
||||
|
||||
if (
|
||||
regex_options.exclude is not None
|
||||
and regex_options.exclude.match_any(input_str=regex_input_str) is not None
|
||||
):
|
||||
return self._try_skip_entry(entry=entry, variable_name=variable_name)
|
||||
|
||||
# If match is present
|
||||
if regex_options.match is not None:
|
||||
maybe_capture = regex_options.match.match_any(input_str=regex_input_str)
|
||||
|
||||
# And nothing matched
|
||||
if maybe_capture is None:
|
||||
# and no defaults
|
||||
if not regex_options.has_defaults:
|
||||
return self._try_skip_entry(entry=entry, variable_name=variable_name)
|
||||
|
||||
# otherwise, use defaults (apply them using the original entry source dict)
|
||||
source_variables_and_overrides_dict = dict(
|
||||
entry.to_dict(), **self.overrides.dict_with_format_strings
|
||||
)
|
||||
|
||||
# otherwise, use defaults (apply them using the original entry source dict)
|
||||
source_variables_and_overrides_dict = dict(
|
||||
entry_variable_dict, **self.overrides.dict_with_format_strings
|
||||
)
|
||||
|
||||
# add both the default...
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
regex_options.capture_group_names[i]: default.apply_formatter(
|
||||
variable_dict=source_variables_and_overrides_dict
|
||||
)
|
||||
for i, default in enumerate(regex_options.capture_group_defaults)
|
||||
},
|
||||
)
|
||||
# and sanitized default
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
|
||||
default.apply_formatter(
|
||||
# add both the default...
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
regex_options.capture_group_names[i]: default.apply_formatter(
|
||||
variable_dict=source_variables_and_overrides_dict
|
||||
)
|
||||
)
|
||||
for i, default in enumerate(regex_options.capture_group_defaults)
|
||||
},
|
||||
)
|
||||
# There is a capture, add the source variables to the entry as
|
||||
# {source_var}_capture_1, {source_var}_capture_2, ...
|
||||
else:
|
||||
# Add the value...
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
regex_options.capture_group_names[i]: capture
|
||||
for i, capture in enumerate(maybe_capture)
|
||||
},
|
||||
)
|
||||
# And the sanitized value
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
|
||||
capture
|
||||
)
|
||||
for i, capture in enumerate(maybe_capture)
|
||||
},
|
||||
)
|
||||
for i, default in enumerate(regex_options.capture_group_defaults)
|
||||
},
|
||||
)
|
||||
# and sanitized default
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
|
||||
default.apply_formatter(
|
||||
variable_dict=source_variables_and_overrides_dict
|
||||
)
|
||||
)
|
||||
for i, default in enumerate(regex_options.capture_group_defaults)
|
||||
},
|
||||
)
|
||||
# There is a capture, add the source variables to the entry as
|
||||
# {source_var}_capture_1, {source_var}_capture_2, ...
|
||||
else:
|
||||
# Add the value...
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
regex_options.capture_group_names[i]: capture
|
||||
for i, capture in enumerate(maybe_capture)
|
||||
},
|
||||
)
|
||||
# And the sanitized value
|
||||
entry.add_variables(
|
||||
variables_to_add={
|
||||
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
|
||||
capture
|
||||
)
|
||||
for i, capture in enumerate(maybe_capture)
|
||||
},
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
|
||||
"""
|
||||
Perform regex at the metadata stage
|
||||
"""
|
||||
return self._modify_entry_metadata(entry, is_metadata_stage=True)
|
||||
|
||||
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||
"""
|
||||
Perform regex at the metadata stage
|
||||
"""
|
||||
return self._modify_entry_metadata(entry, is_metadata_stage=False)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import copy
|
||||
import os.path
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
|
|
@ -8,20 +7,19 @@ from typing import Tuple
|
|||
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
from ytdl_sub.config.plugin import SplitPlugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.entries.variables.kwargs import CHAPTERS
|
||||
from ytdl_sub.entries.variables.kwargs import SPLIT_BY_CHAPTERS_PARENT_ENTRY
|
||||
from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS
|
||||
from ytdl_sub.entries.variables.kwargs import UID
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.chapters import Chapters
|
||||
from ytdl_sub.utils.chapters import Timestamp
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
||||
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
||||
|
||||
|
||||
|
|
@ -47,7 +45,7 @@ class WhenNoChaptersValidator(StringSelectValidator):
|
|||
_select_values = {"pass", "drop", "error"}
|
||||
|
||||
|
||||
class SplitByChaptersOptions(PluginOptions):
|
||||
class SplitByChaptersOptions(OptionsDictValidator):
|
||||
"""
|
||||
Splits a file by chapters into multiple files. Each file becomes its own entry with the
|
||||
new source variables ``chapter_title``, ``chapter_title_sanitized``, ``chapter_index``,
|
||||
|
|
@ -101,9 +99,8 @@ class SplitByChaptersOptions(PluginOptions):
|
|||
return self._when_no_chapters
|
||||
|
||||
|
||||
class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
|
||||
class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]):
|
||||
plugin_options_type = SplitByChaptersOptions
|
||||
is_split_plugin = True
|
||||
|
||||
def _create_split_entry(
|
||||
self, source_entry: Entry, title: str, idx: int, chapters: Chapters
|
||||
|
|
@ -164,14 +161,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
|
|||
Tags the entry's audio file using values defined in the metadata options
|
||||
"""
|
||||
split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = []
|
||||
|
||||
if self.is_dry_run:
|
||||
chapters = Chapters.from_entry_chapters(entry=entry)
|
||||
else:
|
||||
chapters = Chapters.from_embedded_chapters(
|
||||
ffprobe_path=FFMPEG.ffprobe_path(),
|
||||
file_path=entry.get_download_file_path(),
|
||||
)
|
||||
chapters = Chapters.from_entry_chapters(entry=entry)
|
||||
|
||||
# If no chapters, do not split anything
|
||||
if not chapters.contains_any_chapters():
|
||||
|
|
@ -192,11 +182,6 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
|
|||
f"Tried to split '{entry.title}' by chapters but it has no chapters"
|
||||
)
|
||||
|
||||
# convert the entry thumbnail early so we do not have to guess the thumbnail extension
|
||||
# when copying it. Do not error if it's not found, in case thumbnail_name is not set
|
||||
if not self.is_dry_run:
|
||||
convert_download_thumbnail(entry=entry, error_if_not_found=False)
|
||||
|
||||
for idx, title in enumerate(chapters.titles):
|
||||
new_uid = _split_video_uid(source_uid=entry.uid, idx=idx)
|
||||
|
||||
|
|
@ -217,7 +202,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
|
|||
|
||||
# Copy the original vid thumbnail to the working directory with the new uid. This so
|
||||
# downstream logic thinks this split video has its own thumbnail
|
||||
if os.path.isfile(entry.get_download_thumbnail_path()):
|
||||
if entry.is_thumbnail_downloaded():
|
||||
FileHandler.copy(
|
||||
src_file_path=entry.get_download_thumbnail_path(),
|
||||
dst_file_path=Path(self.working_directory)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ from typing import Dict
|
|||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
|
|
@ -25,7 +25,7 @@ class SubtitlesTypeValidator(StringSelectValidator):
|
|||
_select_values = SUBTITLE_EXTENSIONS
|
||||
|
||||
|
||||
class SubtitleOptions(PluginOptions):
|
||||
class SubtitleOptions(OptionsDictValidator):
|
||||
"""
|
||||
Defines how to download and store subtitles. Using this plugin creates two new variables:
|
||||
``lang`` and ``subtitles_ext``. ``lang`` is dynamic since you can download multiple subtitles.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import copy
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.plugin import PluginOptions
|
||||
from ytdl_sub.utils.ffmpeg import add_ffmpeg_metadata_key_values
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
|
||||
|
||||
logger = Logger.get("video_tags")
|
||||
|
||||
class VideoTagsOptions(PluginOptions):
|
||||
|
||||
class VideoTagsOptions(OptionsDictValidator):
|
||||
"""
|
||||
Adds tags to every downloaded video file using ffmpeg ``-metadata key=value`` args.
|
||||
|
||||
|
|
@ -20,13 +24,13 @@ class VideoTagsOptions(PluginOptions):
|
|||
presets:
|
||||
my_example_preset:
|
||||
video_tags:
|
||||
tags:
|
||||
title: "{title}"
|
||||
date: "{upload_date}"
|
||||
description: "{description}"
|
||||
title: "{title}"
|
||||
date: "{upload_date}"
|
||||
description: "{description}"
|
||||
"""
|
||||
|
||||
_required_keys = {"tags"}
|
||||
_optional_keys = {"tags"}
|
||||
_allow_extra_keys = True
|
||||
|
||||
@classmethod
|
||||
def partial_validate(cls, name: str, value: Any) -> None:
|
||||
|
|
@ -39,7 +43,12 @@ class VideoTagsOptions(PluginOptions):
|
|||
|
||||
def __init__(self, name, value):
|
||||
super().__init__(name, value)
|
||||
self._tags = self._validate_key(key="tags", validator=DictFormatterValidator)
|
||||
|
||||
new_tags_dict: Dict[str, Any] = copy.deepcopy(value)
|
||||
old_tags_dict = new_tags_dict.pop("tags", {})
|
||||
|
||||
self._is_old_format = len(old_tags_dict) > 0
|
||||
self._tags = DictFormatterValidator(name=name, value=dict(old_tags_dict, **new_tags_dict))
|
||||
|
||||
@property
|
||||
def tags(self) -> DictFormatterValidator:
|
||||
|
|
@ -56,6 +65,15 @@ class VideoTagsPlugin(Plugin[VideoTagsOptions]):
|
|||
"""
|
||||
Tags the entry's audio file using values defined in the metadata options
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
if self.plugin_options._is_old_format:
|
||||
logger.warning(
|
||||
"video_tags.tags is now deprecated. Place your tags directly under video_tags "
|
||||
"instead. The old format will be removed in October of 2023. See "
|
||||
"https://ytdl-sub.readthedocs.io/en/latest/deprecation_notices.html#video-tags "
|
||||
"for more details."
|
||||
)
|
||||
|
||||
tags_to_write: Dict[str, str] = {}
|
||||
for tag_name, tag_formatter in self.plugin_options.tags.dict.items():
|
||||
tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
presets:
|
||||
|
||||
_view:
|
||||
download:
|
||||
download_strategy: "url"
|
||||
url: "{url}"
|
||||
download: "{url}"
|
||||
output_options:
|
||||
output_directory: "/tmp/ytdl-sub-view"
|
||||
file_name: "{uid}.{ext}"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ presets:
|
|||
# TODO: Update this
|
||||
kodi_music_video:
|
||||
download:
|
||||
download_strategy: "url"
|
||||
url: "{music_video_url}"
|
||||
- "{music_video_url}"
|
||||
|
||||
output_options:
|
||||
output_directory: "{music_video_directory}"
|
||||
|
|
|
|||
|
|
@ -26,24 +26,26 @@ presets:
|
|||
# episode_number
|
||||
# episode_number_padded
|
||||
tv_show_genre: "ytdl-sub"
|
||||
tv_show_content_rating: "TV-14"
|
||||
season_title: "{season_number_padded}"
|
||||
episode_title: "{upload_date_standardized} - {title}"
|
||||
episode_plot: "{webpage_url}\n\n{description}"
|
||||
episode_year: "{upload_year}"
|
||||
episode_content_rating: "{tv_show_content_rating}"
|
||||
episode_date_standardized: "{upload_date_standardized}"
|
||||
episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {file_title}"
|
||||
episode_file_path: "Season {season_number_padded}/{episode_file_name}"
|
||||
|
||||
_episode_video_tags:
|
||||
video_tags:
|
||||
tags:
|
||||
show: "{tv_show_name}"
|
||||
genre: "{tv_show_genre}"
|
||||
episode_id: "{episode_number}"
|
||||
title: "{episode_title}"
|
||||
synopsis: "{episode_plot}"
|
||||
year: "{episode_year}"
|
||||
date: "{episode_date_standardized}"
|
||||
show: "{tv_show_name}"
|
||||
genre: "{tv_show_genre}"
|
||||
episode_id: "{episode_number}"
|
||||
title: "{episode_title}"
|
||||
synopsis: "{episode_plot}"
|
||||
year: "{episode_year}"
|
||||
date: "{episode_date_standardized}"
|
||||
contentRating: "{episode_content_rating}"
|
||||
|
||||
_episode_nfo_tags:
|
||||
nfo_tags:
|
||||
|
|
@ -58,6 +60,7 @@ presets:
|
|||
plot: "{episode_plot}"
|
||||
year: "{episode_year}"
|
||||
aired: "{episode_date_standardized}"
|
||||
mpaa: "{episode_content_rating}"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
nfo_name: "tvshow.nfo"
|
||||
|
|
@ -66,6 +69,7 @@ presets:
|
|||
title: "{tv_show_name}"
|
||||
genre:
|
||||
- "{tv_show_genre}"
|
||||
mpaa: "{tv_show_content_rating}"
|
||||
|
||||
####################################################################################################
|
||||
|
||||
|
|
|
|||
|
|
@ -34,33 +34,31 @@ presets:
|
|||
# TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart
|
||||
_tv_show_by_date:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{url}"
|
||||
playlist_thumbnails:
|
||||
- name: "{tv_show_poster_file_name}"
|
||||
uid: "avatar_uncropped"
|
||||
- name: "{tv_show_fanart_file_name}"
|
||||
uid: "banner_uncropped"
|
||||
- url: "{url2}"
|
||||
- url: "{url3}"
|
||||
- url: "{url4}"
|
||||
- url: "{url5}"
|
||||
- url: "{url6}"
|
||||
- url: "{url7}"
|
||||
- url: "{url8}"
|
||||
- url: "{url9}"
|
||||
- url: "{url10}"
|
||||
- url: "{url11}"
|
||||
- url: "{url12}"
|
||||
- url: "{url13}"
|
||||
- url: "{url14}"
|
||||
- url: "{url15}"
|
||||
- url: "{url16}"
|
||||
- url: "{url17}"
|
||||
- url: "{url18}"
|
||||
- url: "{url19}"
|
||||
- url: "{url20}"
|
||||
- url: "{url}"
|
||||
playlist_thumbnails:
|
||||
- name: "{tv_show_poster_file_name}"
|
||||
uid: "avatar_uncropped"
|
||||
- name: "{tv_show_fanart_file_name}"
|
||||
uid: "banner_uncropped"
|
||||
- url: "{url2}"
|
||||
- url: "{url3}"
|
||||
- url: "{url4}"
|
||||
- url: "{url5}"
|
||||
- url: "{url6}"
|
||||
- url: "{url7}"
|
||||
- url: "{url8}"
|
||||
- url: "{url9}"
|
||||
- url: "{url10}"
|
||||
- url: "{url11}"
|
||||
- url: "{url12}"
|
||||
- url: "{url13}"
|
||||
- url: "{url14}"
|
||||
- url: "{url15}"
|
||||
- url: "{url16}"
|
||||
- url: "{url17}"
|
||||
- url: "{url18}"
|
||||
- url: "{url19}"
|
||||
- url: "{url20}"
|
||||
overrides:
|
||||
url2: ""
|
||||
url3: ""
|
||||
|
|
@ -87,9 +85,6 @@ presets:
|
|||
# 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:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
|
||||
overrides:
|
||||
season_number: "{collection_season_number}"
|
||||
season_number_padded: "{collection_season_number_padded}"
|
||||
|
|
@ -21,21 +21,19 @@ presets:
|
|||
|
||||
collection_season_1:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_1_url}"
|
||||
variables:
|
||||
collection_season_number: "1"
|
||||
collection_season_number_padded: "01"
|
||||
playlist_thumbnails:
|
||||
# Use latest_entry first, then see if YT channel artwork exists
|
||||
# ONLY FOR SEASON 1! The channel artwork will be the show's artwork.
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- name: "{tv_show_poster_file_name}"
|
||||
uid: "avatar_uncropped"
|
||||
- name: "{tv_show_fanart_file_name}"
|
||||
uid: "banner_uncropped"
|
||||
- url: "{collection_season_1_url}"
|
||||
variables:
|
||||
collection_season_number: "1"
|
||||
collection_season_number_padded: "01"
|
||||
playlist_thumbnails:
|
||||
# Use latest_entry first, then see if YT channel artwork exists
|
||||
# ONLY FOR SEASON 1! The channel artwork will be the show's artwork.
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- name: "{tv_show_poster_file_name}"
|
||||
uid: "avatar_uncropped"
|
||||
- name: "{tv_show_fanart_file_name}"
|
||||
uid: "banner_uncropped"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -46,15 +44,13 @@ presets:
|
|||
|
||||
collection_season_2:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_2_url}"
|
||||
variables:
|
||||
collection_season_number: "2"
|
||||
collection_season_number_padded: "02"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_2_url}"
|
||||
variables:
|
||||
collection_season_number: "2"
|
||||
collection_season_number_padded: "02"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -65,15 +61,13 @@ presets:
|
|||
|
||||
collection_season_3:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_3_url}"
|
||||
variables:
|
||||
collection_season_number: "3"
|
||||
collection_season_number_padded: "03"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_3_url}"
|
||||
variables:
|
||||
collection_season_number: "3"
|
||||
collection_season_number_padded: "03"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -84,15 +78,13 @@ presets:
|
|||
|
||||
collection_season_4:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_4_url}"
|
||||
variables:
|
||||
collection_season_number: "4"
|
||||
collection_season_number_padded: "04"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_4_url}"
|
||||
variables:
|
||||
collection_season_number: "4"
|
||||
collection_season_number_padded: "04"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -103,15 +95,13 @@ presets:
|
|||
|
||||
collection_season_5:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_5_url}"
|
||||
variables:
|
||||
collection_season_number: "5"
|
||||
collection_season_number_padded: "05"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_5_url}"
|
||||
variables:
|
||||
collection_season_number: "5"
|
||||
collection_season_number_padded: "05"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -122,15 +112,13 @@ presets:
|
|||
|
||||
collection_season_6:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_6_url}"
|
||||
variables:
|
||||
collection_season_number: "6"
|
||||
collection_season_number_padded: "06"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_6_url}"
|
||||
variables:
|
||||
collection_season_number: "6"
|
||||
collection_season_number_padded: "06"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -141,15 +129,13 @@ presets:
|
|||
|
||||
collection_season_7:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_7_url}"
|
||||
variables:
|
||||
collection_season_number: "7"
|
||||
collection_season_number_padded: "07"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_7_url}"
|
||||
variables:
|
||||
collection_season_number: "7"
|
||||
collection_season_number_padded: "07"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -160,15 +146,13 @@ presets:
|
|||
|
||||
collection_season_8:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_8_url}"
|
||||
variables:
|
||||
collection_season_number: "8"
|
||||
collection_season_number_padded: "08"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_8_url}"
|
||||
variables:
|
||||
collection_season_number: "8"
|
||||
collection_season_number_padded: "08"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -179,15 +163,13 @@ presets:
|
|||
|
||||
collection_season_9:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_9_url}"
|
||||
variables:
|
||||
collection_season_number: "9"
|
||||
collection_season_number_padded: "09"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_9_url}"
|
||||
variables:
|
||||
collection_season_number: "9"
|
||||
collection_season_number_padded: "09"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -198,15 +180,13 @@ presets:
|
|||
|
||||
collection_season_10:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_10_url}"
|
||||
variables:
|
||||
collection_season_number: "10"
|
||||
collection_season_number_padded: "10"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_10_url}"
|
||||
variables:
|
||||
collection_season_number: "10"
|
||||
collection_season_number_padded: "10"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -217,15 +197,13 @@ presets:
|
|||
|
||||
collection_season_11:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_11_url}"
|
||||
variables:
|
||||
collection_season_number: "11"
|
||||
collection_season_number_padded: "11"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_11_url}"
|
||||
variables:
|
||||
collection_season_number: "11"
|
||||
collection_season_number_padded: "11"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -236,15 +214,13 @@ presets:
|
|||
|
||||
collection_season_12:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_12_url}"
|
||||
variables:
|
||||
collection_season_number: "12"
|
||||
collection_season_number_padded: "12"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_12_url}"
|
||||
variables:
|
||||
collection_season_number: "12"
|
||||
collection_season_number_padded: "12"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -255,15 +231,13 @@ presets:
|
|||
|
||||
collection_season_13:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_13_url}"
|
||||
variables:
|
||||
collection_season_number: "13"
|
||||
collection_season_number_padded: "13"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_13_url}"
|
||||
variables:
|
||||
collection_season_number: "13"
|
||||
collection_season_number_padded: "13"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -274,15 +248,13 @@ presets:
|
|||
|
||||
collection_season_14:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_14_url}"
|
||||
variables:
|
||||
collection_season_number: "14"
|
||||
collection_season_number_padded: "14"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_14_url}"
|
||||
variables:
|
||||
collection_season_number: "14"
|
||||
collection_season_number_padded: "14"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -293,15 +265,13 @@ presets:
|
|||
|
||||
collection_season_15:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_15_url}"
|
||||
variables:
|
||||
collection_season_number: "15"
|
||||
collection_season_number_padded: "15"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_15_url}"
|
||||
variables:
|
||||
collection_season_number: "15"
|
||||
collection_season_number_padded: "15"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -312,15 +282,13 @@ presets:
|
|||
|
||||
collection_season_16:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_16_url}"
|
||||
variables:
|
||||
collection_season_number: "16"
|
||||
collection_season_number_padded: "16"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_16_url}"
|
||||
variables:
|
||||
collection_season_number: "16"
|
||||
collection_season_number_padded: "16"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -331,15 +299,13 @@ presets:
|
|||
|
||||
collection_season_17:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_17_url}"
|
||||
variables:
|
||||
collection_season_number: "17"
|
||||
collection_season_number_padded: "17"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_17_url}"
|
||||
variables:
|
||||
collection_season_number: "17"
|
||||
collection_season_number_padded: "17"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -350,15 +316,13 @@ presets:
|
|||
|
||||
collection_season_18:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_18_url}"
|
||||
variables:
|
||||
collection_season_number: "18"
|
||||
collection_season_number_padded: "18"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_18_url}"
|
||||
variables:
|
||||
collection_season_number: "18"
|
||||
collection_season_number_padded: "18"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -369,15 +333,13 @@ presets:
|
|||
|
||||
collection_season_19:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_19_url}"
|
||||
variables:
|
||||
collection_season_number: "19"
|
||||
collection_season_number_padded: "19"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_19_url}"
|
||||
variables:
|
||||
collection_season_number: "19"
|
||||
collection_season_number_padded: "19"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
|
|
@ -388,19 +350,357 @@ presets:
|
|||
|
||||
collection_season_20:
|
||||
download:
|
||||
download_strategy: "multi_url"
|
||||
urls:
|
||||
- url: "{collection_season_20_url}"
|
||||
variables:
|
||||
collection_season_number: "20"
|
||||
collection_season_number_padded: "20"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
- url: "{collection_season_20_url}"
|
||||
variables:
|
||||
collection_season_number: "20"
|
||||
collection_season_number_padded: "20"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_20_name}"
|
||||
attributes:
|
||||
number: "20"
|
||||
number: "20"
|
||||
|
||||
collection_season_21:
|
||||
download:
|
||||
- url: "{collection_season_21_url}"
|
||||
variables:
|
||||
collection_season_number: "21"
|
||||
collection_season_number_padded: "21"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_21_name}"
|
||||
attributes:
|
||||
number: "21"
|
||||
|
||||
collection_season_22:
|
||||
download:
|
||||
- url: "{collection_season_22_url}"
|
||||
variables:
|
||||
collection_season_number: "22"
|
||||
collection_season_number_padded: "22"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_22_name}"
|
||||
attributes:
|
||||
number: "22"
|
||||
|
||||
collection_season_23:
|
||||
download:
|
||||
- url: "{collection_season_23_url}"
|
||||
variables:
|
||||
collection_season_number: "23"
|
||||
collection_season_number_padded: "23"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_23_name}"
|
||||
attributes:
|
||||
number: "23"
|
||||
|
||||
collection_season_24:
|
||||
download:
|
||||
- url: "{collection_season_24_url}"
|
||||
variables:
|
||||
collection_season_number: "24"
|
||||
collection_season_number_padded: "24"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_24_name}"
|
||||
attributes:
|
||||
number: "24"
|
||||
|
||||
collection_season_25:
|
||||
download:
|
||||
- url: "{collection_season_25_url}"
|
||||
variables:
|
||||
collection_season_number: "25"
|
||||
collection_season_number_padded: "25"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_25_name}"
|
||||
attributes:
|
||||
number: "25"
|
||||
|
||||
collection_season_26:
|
||||
download:
|
||||
- url: "{collection_season_26_url}"
|
||||
variables:
|
||||
collection_season_number: "26"
|
||||
collection_season_number_padded: "26"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_26_name}"
|
||||
attributes:
|
||||
number: "26"
|
||||
|
||||
collection_season_27:
|
||||
download:
|
||||
- url: "{collection_season_27_url}"
|
||||
variables:
|
||||
collection_season_number: "27"
|
||||
collection_season_number_padded: "27"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_27_name}"
|
||||
attributes:
|
||||
number: "27"
|
||||
|
||||
collection_season_28:
|
||||
download:
|
||||
- url: "{collection_season_28_url}"
|
||||
variables:
|
||||
collection_season_number: "28"
|
||||
collection_season_number_padded: "28"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_28_name}"
|
||||
attributes:
|
||||
number: "28"
|
||||
|
||||
collection_season_29:
|
||||
download:
|
||||
- url: "{collection_season_29_url}"
|
||||
variables:
|
||||
collection_season_number: "29"
|
||||
collection_season_number_padded: "29"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_29_name}"
|
||||
attributes:
|
||||
number: "29"
|
||||
|
||||
collection_season_30:
|
||||
download:
|
||||
- url: "{collection_season_30_url}"
|
||||
variables:
|
||||
collection_season_number: "30"
|
||||
collection_season_number_padded: "30"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_30_name}"
|
||||
attributes:
|
||||
number: "30"
|
||||
|
||||
collection_season_31:
|
||||
download:
|
||||
- url: "{collection_season_31_url}"
|
||||
variables:
|
||||
collection_season_number: "31"
|
||||
collection_season_number_padded: "31"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_31_name}"
|
||||
attributes:
|
||||
number: "31"
|
||||
|
||||
collection_season_32:
|
||||
download:
|
||||
- url: "{collection_season_32_url}"
|
||||
variables:
|
||||
collection_season_number: "32"
|
||||
collection_season_number_padded: "32"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_32_name}"
|
||||
attributes:
|
||||
number: "32"
|
||||
|
||||
collection_season_33:
|
||||
download:
|
||||
- url: "{collection_season_33_url}"
|
||||
variables:
|
||||
collection_season_number: "33"
|
||||
collection_season_number_padded: "33"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_33_name}"
|
||||
attributes:
|
||||
number: "33"
|
||||
|
||||
collection_season_34:
|
||||
download:
|
||||
- url: "{collection_season_34_url}"
|
||||
variables:
|
||||
collection_season_number: "34"
|
||||
collection_season_number_padded: "34"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_34_name}"
|
||||
attributes:
|
||||
number: "34"
|
||||
|
||||
collection_season_35:
|
||||
download:
|
||||
- url: "{collection_season_35_url}"
|
||||
variables:
|
||||
collection_season_number: "35"
|
||||
collection_season_number_padded: "35"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_35_name}"
|
||||
attributes:
|
||||
number: "35"
|
||||
|
||||
collection_season_36:
|
||||
download:
|
||||
- url: "{collection_season_36_url}"
|
||||
variables:
|
||||
collection_season_number: "36"
|
||||
collection_season_number_padded: "36"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_36_name}"
|
||||
attributes:
|
||||
number: "36"
|
||||
|
||||
collection_season_37:
|
||||
download:
|
||||
- url: "{collection_season_37_url}"
|
||||
variables:
|
||||
collection_season_number: "37"
|
||||
collection_season_number_padded: "37"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_37_name}"
|
||||
attributes:
|
||||
number: "37"
|
||||
|
||||
collection_season_38:
|
||||
download:
|
||||
- url: "{collection_season_38_url}"
|
||||
variables:
|
||||
collection_season_number: "38"
|
||||
collection_season_number_padded: "38"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_38_name}"
|
||||
attributes:
|
||||
number: "38"
|
||||
|
||||
collection_season_39:
|
||||
download:
|
||||
- url: "{collection_season_39_url}"
|
||||
variables:
|
||||
collection_season_number: "39"
|
||||
collection_season_number_padded: "39"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_39_name}"
|
||||
attributes:
|
||||
number: "39"
|
||||
|
||||
collection_season_40:
|
||||
download:
|
||||
- url: "{collection_season_40_url}"
|
||||
variables:
|
||||
collection_season_number: "40"
|
||||
collection_season_number_padded: "40"
|
||||
playlist_thumbnails:
|
||||
- name: "{season_poster_file_name}"
|
||||
uid: "latest_entry"
|
||||
|
||||
output_directory_nfo_tags:
|
||||
tags:
|
||||
namedseason:
|
||||
- tag: "{collection_season_40_name}"
|
||||
attributes:
|
||||
number: "40"
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Type
|
||||
|
||||
from ytdl_sub.config.config_validator import ConfigOptions
|
||||
from ytdl_sub.config.preset import Preset
|
||||
|
|
@ -8,8 +7,7 @@ from ytdl_sub.config.preset import PresetPlugins
|
|||
from ytdl_sub.config.preset_options import OutputOptions
|
||||
from ytdl_sub.config.preset_options import Overrides
|
||||
from ytdl_sub.config.preset_options import YTDLOptions
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
|
||||
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
||||
|
||||
|
|
@ -46,22 +44,13 @@ class BaseSubscription(ABC):
|
|||
self._preset_options = preset_options
|
||||
|
||||
self._enhanced_download_archive = EnhancedDownloadArchive(
|
||||
subscription_name=name,
|
||||
file_name=self.overrides.apply_formatter(self.output_options.download_archive_name),
|
||||
working_directory=self.working_directory,
|
||||
output_directory=self.output_directory,
|
||||
)
|
||||
|
||||
@property
|
||||
def downloader_class(self) -> Type[BaseDownloader]:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
This subscription's downloader class
|
||||
"""
|
||||
return self._preset_options.downloader
|
||||
|
||||
@property
|
||||
def downloader_options(self) -> BaseDownloaderValidator:
|
||||
def downloader_options(self) -> MultiUrlValidator:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ from typing import List
|
|||
from ytdl_sub.config.config_file import ConfigFile
|
||||
from ytdl_sub.config.preset import Preset
|
||||
from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
from ytdl_sub.utils.yaml import load_yaml
|
||||
from ytdl_sub.validators.validators import LiteralDictValidator
|
||||
|
||||
FILE_PRESET_APPLY_KEY = "__preset__"
|
||||
FILE_SUBSCRIPTION_VALUE_KEY = "__value__"
|
||||
|
||||
|
||||
class Subscription(SubscriptionDownload):
|
||||
|
|
@ -64,7 +66,9 @@ class Subscription(SubscriptionDownload):
|
|||
@classmethod
|
||||
def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]:
|
||||
"""
|
||||
Loads subscriptions from a file and applies __preset__ to all of them if present.
|
||||
Loads subscriptions from a file and applies ``__preset__`` to all of them if present.
|
||||
If a subscription is in the form of key: value, it will set value to the override
|
||||
variable defined in ``__value__``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
|
@ -76,11 +80,17 @@ class Subscription(SubscriptionDownload):
|
|||
Returns
|
||||
-------
|
||||
List of subscriptions, for each one in the subscription yaml
|
||||
|
||||
Raises
|
||||
------
|
||||
ValidationException
|
||||
If subscription file is misconfigured
|
||||
"""
|
||||
subscriptions: List["Subscription"] = []
|
||||
subscription_dict = load_yaml(file_path=subscription_path)
|
||||
|
||||
has_file_preset = FILE_PRESET_APPLY_KEY in subscription_dict
|
||||
has_file_subscription_value = FILE_SUBSCRIPTION_VALUE_KEY in subscription_dict
|
||||
|
||||
# If a file preset is present...
|
||||
if has_file_preset:
|
||||
|
|
@ -94,12 +104,39 @@ class Subscription(SubscriptionDownload):
|
|||
config = copy.deepcopy(config)
|
||||
config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict
|
||||
|
||||
if has_file_subscription_value:
|
||||
if not isinstance(subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY], str):
|
||||
raise ValidationException(
|
||||
f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription"
|
||||
f"must be a string that corresponds to an override variable"
|
||||
)
|
||||
|
||||
for subscription_key, subscription_object in subscription_dict.items():
|
||||
|
||||
# Skip file preset
|
||||
if subscription_key == FILE_PRESET_APPLY_KEY:
|
||||
# Skip file preset or value
|
||||
if subscription_key in [FILE_PRESET_APPLY_KEY, FILE_SUBSCRIPTION_VALUE_KEY]:
|
||||
continue
|
||||
|
||||
# If the subscription obj is just a string, set it to the override variable
|
||||
# defined in FILE_SUBSCRIPTION_VALUE_KEY
|
||||
if isinstance(subscription_object, str) and has_file_subscription_value:
|
||||
subscription_object = {
|
||||
"overrides": {
|
||||
subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY]: subscription_object
|
||||
}
|
||||
}
|
||||
elif isinstance(subscription_object, dict):
|
||||
pass
|
||||
elif isinstance(subscription_object, str) and not has_file_subscription_value:
|
||||
raise ValidationException(
|
||||
f"Subscription {subscription_key} is a string, but "
|
||||
f"{FILE_SUBSCRIPTION_VALUE_KEY} is not set to an override variable"
|
||||
)
|
||||
else:
|
||||
raise ValidationException(
|
||||
f"Subscription {subscription_key} should be in the form of a preset"
|
||||
)
|
||||
|
||||
# If it has file_preset, inject it as a parent preset
|
||||
if has_file_preset:
|
||||
parent_preset = subscription_object.get("preset", [])
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from abc import ABC
|
||||
|
|
@ -6,12 +7,14 @@ from pathlib import Path
|
|||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.plugin import SplitPlugin
|
||||
from ytdl_sub.downloaders.info_json.info_json_downloader import InfoJsonDownloader
|
||||
from ytdl_sub.downloaders.info_json.info_json_downloader import InfoJsonDownloaderOptions
|
||||
from ytdl_sub.downloaders.source_plugin import SourcePlugin
|
||||
from ytdl_sub.downloaders.url.downloader import MultiUrlDownloader
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.subscriptions.base_subscription import BaseSubscription
|
||||
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
|
||||
from ytdl_sub.utils.datetime import to_date_range
|
||||
|
|
@ -19,11 +22,13 @@ from ytdl_sub.utils.exceptions import ValidationException
|
|||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
|
||||
logger: logging.Logger = Logger.get()
|
||||
|
||||
|
||||
def _get_split_plugin(plugins: List[Plugin]) -> Optional[Plugin]:
|
||||
split_plugins = [plugin for plugin in plugins if plugin.is_split_plugin]
|
||||
def _get_split_plugin(plugins: List[Plugin]) -> Optional[SplitPlugin]:
|
||||
split_plugins = [plugin for plugin in plugins if isinstance(plugin, SplitPlugin)]
|
||||
|
||||
if len(split_plugins) == 1:
|
||||
return split_plugins[0]
|
||||
|
|
@ -67,16 +72,12 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
entry=entry,
|
||||
)
|
||||
|
||||
# TODO: see if entry even has a thumbnail
|
||||
if self.output_options.thumbnail_name:
|
||||
# Always pretend to include the thumbnail in a dry-run
|
||||
if self.output_options.thumbnail_name and (dry_run or entry.is_thumbnail_downloaded()):
|
||||
output_thumbnail_name = self.overrides.apply_formatter(
|
||||
formatter=self.output_options.thumbnail_name, entry=entry
|
||||
)
|
||||
|
||||
# We always convert entry thumbnails to jpgs, and is performed here
|
||||
if not dry_run:
|
||||
convert_download_thumbnail(entry=entry)
|
||||
|
||||
# Copy the thumbnails since they could be used later for other things
|
||||
self._enhanced_download_archive.save_file_to_output_directory(
|
||||
file_name=entry.get_download_thumbnail_name(),
|
||||
|
|
@ -84,6 +85,10 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
entry=entry,
|
||||
copy_file=True,
|
||||
)
|
||||
elif not entry.is_thumbnail_downloaded():
|
||||
logger.warning(
|
||||
"Cannot save thumbnail for '%s' because it is not available", entry.title
|
||||
)
|
||||
|
||||
if self.output_options.info_json_name:
|
||||
output_info_json_name = self.overrides.apply_formatter(
|
||||
|
|
@ -144,8 +149,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
self._enhanced_download_archive.remove_stale_files(date_range=date_range_to_keep)
|
||||
|
||||
self._enhanced_download_archive.save_download_mappings()
|
||||
FileHandler.delete(self._enhanced_download_archive.archive_working_file_path)
|
||||
FileHandler.delete(self._enhanced_download_archive.mapping_working_file_path)
|
||||
FileHandler.delete(self._enhanced_download_archive.working_file_path)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _remove_empty_directories_in_output_directory(self):
|
||||
|
|
@ -174,23 +178,14 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
-------
|
||||
List of plugins defined in the subscription, initialized and ready to use.
|
||||
"""
|
||||
# Always add plugins provided by the downloader
|
||||
plugins: List[Plugin] = self.downloader_class.added_plugins(
|
||||
downloader_options=self.downloader_options,
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
overrides=self.overrides,
|
||||
)
|
||||
|
||||
for plugin_type, plugin_options in self.plugins.zipped():
|
||||
plugin = plugin_type(
|
||||
plugin_options=plugin_options,
|
||||
return [
|
||||
plugin_type(
|
||||
options=plugin_options,
|
||||
overrides=self.overrides,
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
)
|
||||
|
||||
plugins.append(plugin)
|
||||
|
||||
return plugins
|
||||
for plugin_type, plugin_options in self.plugins.zipped()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _cleanup_entry_files(cls, entry: Entry):
|
||||
|
|
@ -201,7 +196,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
@classmethod
|
||||
def _preprocess_entry(cls, plugins: List[Plugin], entry: Entry) -> Optional[Entry]:
|
||||
maybe_entry: Optional[Entry] = entry
|
||||
for plugin in plugins:
|
||||
for plugin in sorted(plugins, key=lambda _plugin: _plugin.priority.modify_entry_metadata):
|
||||
if (maybe_entry := plugin.modify_entry_metadata(maybe_entry)) is None:
|
||||
return None
|
||||
|
||||
|
|
@ -244,7 +239,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
self._cleanup_entry_files(entry)
|
||||
|
||||
def _process_split_entry(
|
||||
self, split_plugin: Plugin, plugins: List[Plugin], dry_run: bool, entry: Entry
|
||||
self, split_plugin: SplitPlugin, plugins: List[Plugin], dry_run: bool, entry: Entry
|
||||
) -> None:
|
||||
entry_: Optional[Entry] = entry
|
||||
|
||||
|
|
@ -291,7 +286,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
def _process_subscription(
|
||||
self,
|
||||
plugins: List[Plugin],
|
||||
downloader: BaseDownloader,
|
||||
downloader: SourcePlugin,
|
||||
dry_run: bool,
|
||||
) -> FileHandlerTransactionLog:
|
||||
with self._subscription_download_context_managers():
|
||||
|
|
@ -339,14 +334,16 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
downloader = self.downloader_class(
|
||||
download_options=self.downloader_options,
|
||||
downloader = MultiUrlDownloader(
|
||||
options=self.downloader_options,
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
download_ytdl_options=subscription_ytdl_options.download_builder(),
|
||||
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
|
||||
overrides=self.overrides,
|
||||
)
|
||||
|
||||
plugins.extend(downloader.added_plugins())
|
||||
|
||||
return self._process_subscription(
|
||||
plugins=plugins,
|
||||
downloader=downloader,
|
||||
|
|
@ -365,8 +362,27 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
|||
self._enhanced_download_archive.reinitialize(dry_run=dry_run)
|
||||
plugins = self._initialize_plugins()
|
||||
|
||||
subscription_ytdl_options = SubscriptionYTDLOptions(
|
||||
preset=self._preset_options,
|
||||
plugins=plugins,
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
working_directory=self.working_directory,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
# Re-add the original downloader class' plugins
|
||||
plugins.extend(
|
||||
MultiUrlDownloader(
|
||||
options=self.downloader_options,
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
download_ytdl_options=subscription_ytdl_options.download_builder(),
|
||||
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
|
||||
overrides=self.overrides,
|
||||
).added_plugins()
|
||||
)
|
||||
|
||||
downloader = InfoJsonDownloader(
|
||||
download_options=InfoJsonDownloaderOptions(name="no-op", value={}),
|
||||
options=InfoJsonDownloaderOptions(name="no-op", value={}),
|
||||
enhanced_download_archive=self._enhanced_download_archive,
|
||||
download_ytdl_options=YTDLOptionsBuilder(),
|
||||
metadata_ytdl_options=YTDLOptionsBuilder(),
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@ from typing import Optional
|
|||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
from ytdl_sub.config.plugin import Plugin
|
||||
from ytdl_sub.config.preset import Preset
|
||||
from ytdl_sub.downloaders.base_downloader import BaseDownloader
|
||||
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
|
||||
from ytdl_sub.plugins.chapters import ChaptersPlugin
|
||||
from ytdl_sub.plugins.date_range import DateRangePlugin
|
||||
from ytdl_sub.plugins.file_convert import FileConvertPlugin
|
||||
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
|
||||
from ytdl_sub.plugins.plugin import Plugin
|
||||
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
|
||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||
|
|
@ -42,10 +41,6 @@ class SubscriptionYTDLOptions:
|
|||
return plugin
|
||||
return None
|
||||
|
||||
@property
|
||||
def _downloader(self) -> Type[BaseDownloader]:
|
||||
return self._preset.downloader
|
||||
|
||||
@property
|
||||
def _global_options(self) -> Dict:
|
||||
"""
|
||||
|
|
@ -78,14 +73,16 @@ class SubscriptionYTDLOptions:
|
|||
"writeinfojson": True,
|
||||
}
|
||||
|
||||
@property
|
||||
def _download_only_options(self) -> Dict:
|
||||
return {"break_on_reject": True}
|
||||
|
||||
@property
|
||||
def _output_options(self) -> Dict:
|
||||
ytdl_options = {}
|
||||
|
||||
if self._preset.output_options.maintain_download_archive:
|
||||
ytdl_options["download_archive"] = str(
|
||||
Path(self._working_directory) / self._enhanced_download_archive.archive_file_name
|
||||
)
|
||||
ytdl_options["download_archive"] = self._enhanced_download_archive.working_file_path
|
||||
|
||||
return ytdl_options
|
||||
|
||||
|
|
@ -110,7 +107,6 @@ class SubscriptionYTDLOptions:
|
|||
self._global_options,
|
||||
self._output_options,
|
||||
self._plugin_ytdl_options(DateRangePlugin),
|
||||
self._plugin_ytdl_options(MatchFiltersPlugin),
|
||||
self._user_ytdl_options, # user ytdl options...
|
||||
self._info_json_only_options, # then info_json_only options
|
||||
)
|
||||
|
|
@ -125,12 +121,13 @@ class SubscriptionYTDLOptions:
|
|||
ytdl_options_builder = YTDLOptionsBuilder().add(
|
||||
self._global_options,
|
||||
self._output_options,
|
||||
self._plugin_ytdl_options(DateRangePlugin),
|
||||
self._plugin_ytdl_options(MatchFiltersPlugin),
|
||||
self._plugin_ytdl_options(FileConvertPlugin),
|
||||
self._plugin_ytdl_options(SubtitlesPlugin),
|
||||
self._plugin_ytdl_options(ChaptersPlugin),
|
||||
self._plugin_ytdl_options(AudioExtractPlugin),
|
||||
self._user_ytdl_options, # user ytdl options...
|
||||
self._download_only_options, # then download_only options
|
||||
)
|
||||
# Add dry run options last if enabled
|
||||
if self._dry_run:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.entries.variables.kwargs import CHAPTERS
|
||||
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_CUSTOM_CHAPTERS
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
|
||||
|
||||
|
|
@ -202,6 +202,7 @@ class Chapters:
|
|||
timestamps: List[Timestamp] = []
|
||||
titles: List[str] = []
|
||||
|
||||
# Try to accumulate chapters by parsing lines individually
|
||||
for line in input_str.split("\n"):
|
||||
# Timestamp captured, store it
|
||||
if match := Timestamp.TIMESTAMP_REGEX.search(line):
|
||||
|
|
@ -211,53 +212,12 @@ class Chapters:
|
|||
# Remove timestamp and surrounding whitespace from it
|
||||
title_str = re.sub(f"\\s*{re.escape(timestamp_str)}\\s*", " ", line).strip()
|
||||
titles.append(title_str)
|
||||
elif len(timestamps) >= 3:
|
||||
return Chapters(timestamps=timestamps, titles=titles)
|
||||
# Timestamp was not stored, if only contained 1, reset
|
||||
else:
|
||||
timestamps = []
|
||||
titles = []
|
||||
|
||||
return Chapters(timestamps=timestamps, titles=titles)
|
||||
|
||||
@classmethod
|
||||
def from_embedded_chapters(cls, ffprobe_path: str, file_path: str) -> "Chapters":
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
ffprobe_path
|
||||
Path to ffprobe executable
|
||||
file_path
|
||||
File to read ffmpeg chapter metadata from
|
||||
|
||||
Returns
|
||||
-------
|
||||
Chapters object
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
ffprobe_path,
|
||||
"-loglevel",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_chapters",
|
||||
"--",
|
||||
file_path,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
embedded_chapters = json.loads(proc.stdout)
|
||||
timestamps: List[Timestamp] = []
|
||||
titles: List[str] = []
|
||||
for chapter in embedded_chapters["chapters"]:
|
||||
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
|
||||
titles.append(chapter["tags"]["title"])
|
||||
|
||||
return Chapters(timestamps=timestamps, titles=titles)
|
||||
# If more than 3 timestamps were parsed, return it
|
||||
if len(timestamps) >= 3:
|
||||
return Chapters(timestamps=timestamps, titles=titles)
|
||||
# Otherwise return empty chapters
|
||||
return Chapters(timestamps=[], titles=[])
|
||||
|
||||
@classmethod
|
||||
def from_entry_chapters(cls, entry: Entry) -> "Chapters":
|
||||
|
|
@ -274,13 +234,14 @@ class Chapters:
|
|||
timestamps: List[Timestamp] = []
|
||||
titles: List[str] = []
|
||||
|
||||
chapters = {}
|
||||
if entry.kwargs_contains("chapters"):
|
||||
chapters = entry.kwargs("chapters") or []
|
||||
|
||||
for chapter in chapters:
|
||||
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
|
||||
titles.append(chapter["title"])
|
||||
if entry.kwargs_contains(CHAPTERS):
|
||||
for chapter in entry.kwargs_get(CHAPTERS, []):
|
||||
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
|
||||
titles.append(chapter["title"])
|
||||
elif entry.kwargs_contains(YTDL_SUB_CUSTOM_CHAPTERS):
|
||||
for start_time, title in entry.kwargs_get(YTDL_SUB_CUSTOM_CHAPTERS, {}).items():
|
||||
timestamps.append(Timestamp.from_str(start_time))
|
||||
titles.append(title)
|
||||
|
||||
return Chapters(timestamps=timestamps, titles=titles)
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ def set_ffmpeg_metadata_chapters(
|
|||
metadata_file.name,
|
||||
"-map",
|
||||
"0",
|
||||
"-dn", # ignore data streams
|
||||
"-map_chapters",
|
||||
"1",
|
||||
"-bitexact", # for reproducibility
|
||||
|
|
@ -192,7 +193,13 @@ def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -
|
|||
"""
|
||||
tmp_file_path = FFMPEG.tmp_file_path(file_path)
|
||||
|
||||
ffmpeg_args = ["-i", file_path, "-map", "0"]
|
||||
ffmpeg_args = [
|
||||
"-i",
|
||||
file_path,
|
||||
"-map",
|
||||
"0",
|
||||
"-dn", # ignore data streams
|
||||
]
|
||||
for key, value in key_values.items():
|
||||
ffmpeg_args.extend(["-metadata", f"{key}={value}"])
|
||||
ffmpeg_args.extend(["-codec", "copy", tmp_file_path])
|
||||
|
|
|
|||
|
|
@ -44,7 +44,19 @@ else:
|
|||
/ str(working_directory_path).replace("/", "_")
|
||||
)
|
||||
|
||||
lock_file = open(lock_file_path, "w", encoding="utf-8")
|
||||
try:
|
||||
lock_file = open(lock_file_path, "w", encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
# pylint: disable=line-too-long
|
||||
raise ValidationException(
|
||||
"Failed to create a file-lock to prevent multiple instances of ytdl-sub from "
|
||||
"colliding with each other. If you get this error, it typically means it tried to "
|
||||
"create the file in a directory that is not a part of the same filesystem that "
|
||||
"ytdl-sub is running on. See "
|
||||
"https://ytdl-sub.readthedocs.io/en/latest/config.html#ytdl_sub.config.config_validator.ConfigOptions.lock_directory "
|
||||
"on how to change the directory that this lock gets written to."
|
||||
) from exc
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
try:
|
||||
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from subprocess import CalledProcessError
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen
|
||||
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
from ytdl_sub.utils.retry import retry
|
||||
|
||||
|
||||
|
|
@ -13,39 +16,39 @@ class ThumbnailTypes:
|
|||
LATEST_ENTRY = "latest_entry"
|
||||
|
||||
|
||||
def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None:
|
||||
logger: logging.Logger = Logger.get("thumbnail")
|
||||
|
||||
|
||||
def try_convert_download_thumbnail(entry: Entry) -> None:
|
||||
"""
|
||||
Converts an entry's downloaded thumbnail into jpg format
|
||||
Converts an entry's downloaded thumbnail into jpg format.
|
||||
Log with a warning if the thumbnail is not found or fails to convert
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry
|
||||
Entry with the thumbnail
|
||||
error_if_not_found
|
||||
If the thumbnail is not found, error if True.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
Entry thumbnail file not found
|
||||
"""
|
||||
download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path()
|
||||
download_thumbnail_path: Optional[str] = entry.try_get_ytdlp_download_thumbnail_path()
|
||||
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
|
||||
|
||||
# If it was already converted, do not convert again
|
||||
if os.path.isfile(download_thumbnail_path_as_jpg):
|
||||
if entry.is_thumbnail_downloaded():
|
||||
return
|
||||
|
||||
if not download_thumbnail_path:
|
||||
if error_if_not_found:
|
||||
raise ValueError("Thumbnail not found")
|
||||
logger.warning("Thumbnail for '%s' was not downloaded", entry.title)
|
||||
return
|
||||
|
||||
if not download_thumbnail_path == download_thumbnail_path_as_jpg:
|
||||
FFMPEG.run(
|
||||
["-y", "-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg]
|
||||
)
|
||||
FileHandler.delete(download_thumbnail_path)
|
||||
try:
|
||||
FFMPEG.run(
|
||||
["-y", "-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg]
|
||||
)
|
||||
except CalledProcessError:
|
||||
logger.warning("Failed to convert thumbnail for '%s' to jpg", entry.title)
|
||||
finally:
|
||||
FileHandler.delete(download_thumbnail_path)
|
||||
|
||||
|
||||
@retry(times=3, exceptions=(Exception,))
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ def load_yaml(file_path: str | Path) -> Dict:
|
|||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
output = yaml.safe_load(file)
|
||||
except YAMLError as yaml_exception:
|
||||
logger.debug(yaml_exception)
|
||||
raise InvalidYamlException(
|
||||
f"'{file_path}' has invalid YAML, copy-paste it into a YAML checker to find the issue."
|
||||
f"'{file_path}' has invalid YAML:\n{yaml_exception}\n\n"
|
||||
f"Copy-pasting it into a YAML parser can also help find the issue."
|
||||
) from yaml_exception
|
||||
|
||||
if not isinstance(output, dict):
|
||||
|
|
|
|||
|
|
@ -179,6 +179,16 @@ class DictValidator(Validator):
|
|||
super().__init__(name, value)
|
||||
self.__validator_dict: Dict[str, Validator] = {}
|
||||
|
||||
@final
|
||||
@property
|
||||
def _root_name(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
"first" from the first.element.of.the.name
|
||||
"""
|
||||
return self._name.split(".")[0]
|
||||
|
||||
@final
|
||||
@property
|
||||
def _dict(self) -> dict:
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from ytdl_sub.entries.variables.kwargs import SPLIT_BY_CHAPTERS_PARENT_ENTRY
|
|||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||
from ytdl_sub.utils.file_handler import FileMetadata
|
||||
from ytdl_sub.utils.logger import Logger
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -370,21 +369,19 @@ class EnhancedDownloadArchive:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_name: str,
|
||||
file_name: str,
|
||||
working_directory: str,
|
||||
output_directory: str,
|
||||
dry_run: bool = False,
|
||||
):
|
||||
self.subscription_name = subscription_name
|
||||
self._file_name = file_name
|
||||
self._file_handler = FileHandler(
|
||||
working_directory=working_directory, output_directory=output_directory, dry_run=dry_run
|
||||
)
|
||||
self._download_mapping = self._maybe_load_download_mappings(
|
||||
mapping_file_path=self._mapping_output_file_path
|
||||
mapping_file_path=self.output_file_path
|
||||
)
|
||||
|
||||
self._logger = Logger.get(name=subscription_name)
|
||||
|
||||
self.num_entries_added: int = 0
|
||||
self.num_entries_modified: int = 0
|
||||
self.num_entries_removed: int = 0
|
||||
|
|
@ -418,7 +415,7 @@ class EnhancedDownloadArchive:
|
|||
dry_run=dry_run,
|
||||
)
|
||||
self._download_mapping = self._maybe_load_download_mappings(
|
||||
mapping_file_path=self._mapping_output_file_path
|
||||
mapping_file_path=self.output_file_path
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
@ -431,16 +428,6 @@ class EnhancedDownloadArchive:
|
|||
"""
|
||||
return self._file_handler.dry_run
|
||||
|
||||
@property
|
||||
def archive_file_name(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
The download archive's file name (no path)
|
||||
Used to recreate yt-dlp's download archive in the working directory
|
||||
"""
|
||||
return f".ytdl-sub-{self.subscription_name}-download-archive.txt"
|
||||
|
||||
@property
|
||||
def working_directory(self) -> str:
|
||||
"""
|
||||
|
|
@ -460,40 +447,31 @@ class EnhancedDownloadArchive:
|
|||
return self._file_handler.output_directory
|
||||
|
||||
@property
|
||||
def _mapping_file_name(self) -> str:
|
||||
def file_name(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
The download mapping's file name (no path)
|
||||
"""
|
||||
return f".ytdl-sub-{self.subscription_name}-download-archive.json"
|
||||
return self._file_name
|
||||
|
||||
@property
|
||||
def _mapping_output_file_path(self) -> str:
|
||||
def output_file_path(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
The download mapping's file path in the output directory.
|
||||
"""
|
||||
return str(Path(self.output_directory) / self._mapping_file_name)
|
||||
return str(Path(self.output_directory) / self.file_name)
|
||||
|
||||
@property
|
||||
def mapping_working_file_path(self) -> str:
|
||||
def working_file_path(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
The download mapping's file path in the working directory.
|
||||
"""
|
||||
return str(Path(self.working_directory) / self._mapping_file_name)
|
||||
|
||||
@property
|
||||
def archive_working_file_path(self) -> str:
|
||||
"""
|
||||
Returns
|
||||
-------
|
||||
The download archive's file path in the working directory.
|
||||
"""
|
||||
return str(Path(self.working_directory) / self.archive_file_name)
|
||||
return str(Path(self.working_directory) / self.file_name)
|
||||
|
||||
@property
|
||||
def mapping(self) -> DownloadMappings:
|
||||
|
|
@ -526,7 +504,7 @@ class EnhancedDownloadArchive:
|
|||
return self
|
||||
|
||||
# Otherwise, create a ytdl download archive file in the working directory.
|
||||
self.mapping.to_download_archive().to_file(self.archive_working_file_path)
|
||||
self.mapping.to_download_archive().to_file(self.working_file_path)
|
||||
|
||||
return self
|
||||
|
||||
|
|
@ -566,8 +544,8 @@ class EnhancedDownloadArchive:
|
|||
self
|
||||
"""
|
||||
if not self.get_file_handler_transaction_log().is_empty:
|
||||
self._download_mapping.to_file(output_json_file=self.mapping_working_file_path)
|
||||
self.save_file_to_output_directory(file_name=self._mapping_file_name)
|
||||
self._download_mapping.to_file(output_json_file=self.working_file_path)
|
||||
self.save_file_to_output_directory(file_name=self.file_name)
|
||||
return self
|
||||
|
||||
def delete_file_from_output_directory(self, file_name: str):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def single_song_preset_dict(output_directory):
|
||||
def single_song_preset_dict_old_format(output_directory):
|
||||
return {
|
||||
"preset": "single",
|
||||
# test multi-tags
|
||||
|
|
@ -23,6 +23,26 @@ def single_song_preset_dict(output_directory):
|
|||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_song_preset_dict(output_directory):
|
||||
return {
|
||||
"preset": "single",
|
||||
# test multi-tags
|
||||
"music_tags": {"genres": ["multi_tag_1", "multi_tag_2"]},
|
||||
# test the new embed_thumbnail plugin
|
||||
"embed_thumbnail": True,
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
"format": "worst[ext=mp4]",
|
||||
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
|
||||
},
|
||||
"overrides": {
|
||||
"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
|
||||
"music_directory": output_directory,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiple_songs_preset_dict(output_directory):
|
||||
return {
|
||||
|
|
@ -42,7 +62,33 @@ def multiple_songs_preset_dict(output_directory):
|
|||
|
||||
class TestAudioExtract:
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
def test_audio_extract_single_song(
|
||||
def test_audio_extract_single_song_old_format(
|
||||
self,
|
||||
music_audio_config,
|
||||
single_song_preset_dict_old_format,
|
||||
output_directory,
|
||||
dry_run,
|
||||
):
|
||||
subscription = Subscription.from_dict(
|
||||
config=music_audio_config,
|
||||
preset_name="single_song_test",
|
||||
preset_dict=single_song_preset_dict_old_format,
|
||||
)
|
||||
|
||||
transaction_log = subscription.download(dry_run=dry_run)
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name="plugins/test_audio_extract_single_old_format.txt",
|
||||
)
|
||||
assert_expected_downloads(
|
||||
output_directory=output_directory,
|
||||
dry_run=dry_run,
|
||||
expected_download_summary_file_name="plugins/test_audio_extract_single_old_format.json",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
def test_audio_extract_single_song_new_format(
|
||||
self,
|
||||
music_audio_config,
|
||||
single_song_preset_dict,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
|||
def sponsorblock_and_subs_preset_dict(output_directory) -> Dict:
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://www.youtube.com/watch?v=-wJOUAuKZm8"},
|
||||
"download": "https://www.youtube.com/watch?v=-wJOUAuKZm8",
|
||||
# override the output directory with our fixture-generated dir
|
||||
"output_options": {"output_directory": output_directory},
|
||||
"subtitles": {
|
||||
|
|
@ -46,9 +46,7 @@ def sponsorblock_and_subs_preset_dict(output_directory) -> Dict:
|
|||
|
||||
@pytest.fixture
|
||||
def chapters_from_comments_preset_dict(sponsorblock_and_subs_preset_dict: Dict) -> Dict:
|
||||
sponsorblock_and_subs_preset_dict["download"][
|
||||
"url"
|
||||
] = "https://www.youtube.com/watch?v=MO5AWAqe01Y"
|
||||
sponsorblock_and_subs_preset_dict["download"] = "https://www.youtube.com/watch?v=MO5AWAqe01Y"
|
||||
sponsorblock_and_subs_preset_dict["chapters"] = {
|
||||
"embed_chapters": True,
|
||||
"allow_chapters_from_comments": True,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
|||
def preset_dict(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://www.youtube.com/watch?v=2zYF9JLHDmA"},
|
||||
"download": "https://www.youtube.com/watch?v=2zYF9JLHDmA",
|
||||
"output_options": {"output_directory": output_directory},
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import pytest
|
||||
from expected_download import assert_expected_downloads
|
||||
from expected_transaction_log import assert_transaction_log_matches
|
||||
|
||||
from ytdl_sub.subscriptions.subscription import Subscription
|
||||
|
||||
|
|
@ -7,7 +9,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
|||
def preset_dict(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://www.youtube.com/watch?v=2zYF9JLHDmA"},
|
||||
"download": "https://www.youtube.com/watch?v=2zYF9JLHDmA",
|
||||
"output_options": {"output_directory": output_directory},
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
|
|
@ -17,9 +19,23 @@ def preset_dict(output_directory):
|
|||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_preset_dict(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": "https://www.youtube.com/playlist?list=PL5BC0FC26BECA5A35",
|
||||
"output_options": {"output_directory": output_directory},
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
|
||||
},
|
||||
"match_filters": {"filters": ["view_count > 20000"]},
|
||||
}
|
||||
|
||||
|
||||
class TestFileConvert:
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
def test_match_filters(
|
||||
def test_match_filters_empty(
|
||||
self,
|
||||
music_video_config,
|
||||
preset_dict,
|
||||
|
|
@ -34,3 +50,31 @@ class TestFileConvert:
|
|||
|
||||
transaction_log = subscription.download(dry_run=dry_run)
|
||||
assert transaction_log.is_empty
|
||||
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
def test_match_filters_partial(
|
||||
self,
|
||||
music_video_config,
|
||||
playlist_preset_dict,
|
||||
output_directory,
|
||||
dry_run,
|
||||
):
|
||||
subscription = Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="match_filter_test",
|
||||
preset_dict=playlist_preset_dict,
|
||||
)
|
||||
|
||||
transaction_log = subscription.download(dry_run=dry_run)
|
||||
|
||||
summary_path = "plugins/match_filters"
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name=f"{summary_path}/test_match_filters_partial.txt",
|
||||
)
|
||||
assert_expected_downloads(
|
||||
output_directory=output_directory,
|
||||
dry_run=dry_run,
|
||||
expected_download_summary_file_name=f"{summary_path}/test_match_filters_partial.json",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import re
|
||||
|
||||
import pytest
|
||||
from expected_download import assert_expected_downloads
|
||||
from expected_transaction_log import assert_transaction_log_matches
|
||||
|
||||
from ytdl_sub.subscriptions.subscription import Subscription
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
|
|
@ -11,13 +9,10 @@ from ytdl_sub.utils.exceptions import ValidationException
|
|||
@pytest.fixture
|
||||
def single_song_video_dict(output_directory):
|
||||
return {
|
||||
"download": {
|
||||
"download_strategy": "url",
|
||||
"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
|
||||
},
|
||||
"download": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
|
||||
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
|
||||
# test multi-tags
|
||||
"music_tags": {"embed_thumbnail": True, "tags": {"genres": ["multi_tag_1", "multi_tag_2"]}},
|
||||
"music_tags": {"genres": ["multi_tag_1", "multi_tag_2"]},
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
"format": "worst[ext=mp4]",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
|||
def subscription_dict(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://www.youtube.com/shorts/ucYmEqmlhFw"},
|
||||
"download": "https://www.youtube.com/shorts/ucYmEqmlhFw",
|
||||
# override the output directory with our fixture-generated dir
|
||||
"output_options": {"output_directory": output_directory},
|
||||
# download the worst format so it is fast
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import copy
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
||||
import mergedeep
|
||||
import pytest
|
||||
from expected_transaction_log import assert_transaction_log_matches
|
||||
|
||||
from ytdl_sub.config.preset import Preset
|
||||
from ytdl_sub.config.config_file import ConfigFile
|
||||
from ytdl_sub.subscriptions.subscription import Subscription
|
||||
from ytdl_sub.utils.exceptions import RegexNoMatchException
|
||||
from ytdl_sub.utils.exceptions import ValidationException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regex_subscription_dict(output_directory):
|
||||
def regex_subscription_dict_base(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35"},
|
||||
"download": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35",
|
||||
# override the output directory with our fixture-generated dir
|
||||
"output_options": {"output_directory": output_directory},
|
||||
# download the worst format so it is fast
|
||||
|
|
@ -24,13 +27,6 @@ def regex_subscription_dict(output_directory):
|
|||
"regex": {
|
||||
# tests that skip_if_match_fails defaults to True
|
||||
"from": {
|
||||
"title": {
|
||||
"match": [
|
||||
"should not cap (.+) - (.+)",
|
||||
".*\\[(.+) - (Feb.+)]", # should filter out march video
|
||||
],
|
||||
"capture_group_names": ["title_type", "title_date"],
|
||||
},
|
||||
"description": {
|
||||
"match": [".*http:\\/\\/(.+).com.*"],
|
||||
"capture_group_names": ["description_website"],
|
||||
|
|
@ -48,58 +44,188 @@ def regex_subscription_dict(output_directory):
|
|||
},
|
||||
},
|
||||
},
|
||||
"nfo_tags": {
|
||||
"tags": {
|
||||
"title_cap_1": "{title_type}",
|
||||
"title_cap_1_sanitized": "{title_type_sanitized}",
|
||||
"title_cap_2": "{title_date}",
|
||||
"desc_cap": "{description_website}",
|
||||
"upload_date_both_caps": "{upload_captured_year} and {upload_captured_month}",
|
||||
"override_with_capture_variable": "{contains_regex_default}",
|
||||
"override_with_capture_variable_sanitized": "{contains_regex_sanitized_default}",
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"in_regex_default": "in regex default",
|
||||
"contains_regex_default": "contains {title_type}",
|
||||
"contains_regex_sanitized_default": "contains {title_type_sanitized}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regex_subscription_dict_no_match_fails(regex_subscription_dict):
|
||||
regex_subscription_dict["regex"]["skip_if_match_fails"] = False
|
||||
return regex_subscription_dict
|
||||
def regex_subscription_dict(regex_subscription_dict_base, output_directory):
|
||||
return mergedeep.merge(
|
||||
regex_subscription_dict_base,
|
||||
{
|
||||
"regex": {
|
||||
# tests that skip_if_match_fails defaults to True
|
||||
"from": {
|
||||
"title": {
|
||||
"match": [
|
||||
"should not cap (.+) - (.+)",
|
||||
".*\\[(.+) - (Feb.+)]", # should filter out march video
|
||||
],
|
||||
"capture_group_names": ["title_type", "title_date"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"nfo_tags": {
|
||||
"tags": {
|
||||
"title_cap_1": "{title_type}",
|
||||
"title_cap_1_sanitized": "{title_type_sanitized}",
|
||||
"title_cap_2": "{title_date}",
|
||||
"desc_cap": "{description_website}",
|
||||
"upload_date_both_caps": "{upload_captured_year} and {upload_captured_month}",
|
||||
"override_with_capture_variable": "{contains_regex_default}",
|
||||
"override_with_capture_variable_sanitized": "{contains_regex_sanitized_default}",
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"contains_regex_default": "contains {title_type}",
|
||||
"contains_regex_sanitized_default": "contains {title_type_sanitized}",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regex_subscription_dict_exclude(regex_subscription_dict_base, output_directory):
|
||||
return mergedeep.merge(
|
||||
regex_subscription_dict_base,
|
||||
{
|
||||
"regex": {
|
||||
# tests that skip_if_match_fails defaults to True
|
||||
"from": {
|
||||
"title": {
|
||||
"exclude": [
|
||||
"should not cap",
|
||||
".*Feb.*", # should filter out march video
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regex_subscription_dict_match_and_exclude(regex_subscription_dict_base, output_directory):
|
||||
return mergedeep.merge(
|
||||
regex_subscription_dict_base,
|
||||
{
|
||||
"regex": {
|
||||
# tests that skip_if_match_fails defaults to True
|
||||
"from": {
|
||||
"title": {
|
||||
"match": [
|
||||
"should not cap (.+) - (.+)",
|
||||
".*\\[(.+) - (Feb.+)]", # should filter out march video
|
||||
],
|
||||
"capture_group_names": ["title_type", "title_date"],
|
||||
"exclude": [
|
||||
"should not cap",
|
||||
".*27.*", # should filter out Feb 27th video
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"nfo_tags": {
|
||||
"tags": {
|
||||
"title_cap_1": "{title_type}",
|
||||
"title_cap_1_sanitized": "{title_type_sanitized}",
|
||||
"title_cap_2": "{title_date}",
|
||||
"desc_cap": "{description_website}",
|
||||
"upload_date_both_caps": "{upload_captured_year} and {upload_captured_month}",
|
||||
"override_with_capture_variable": "{contains_regex_default}",
|
||||
"override_with_capture_variable_sanitized": "{contains_regex_sanitized_default}",
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"contains_regex_default": "contains {title_type}",
|
||||
"contains_regex_sanitized_default": "contains {title_type_sanitized}",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regex_subscription_dict_match_and_exclude_override_variable(
|
||||
regex_subscription_dict_base, output_directory
|
||||
):
|
||||
return mergedeep.merge(
|
||||
regex_subscription_dict_base,
|
||||
{
|
||||
"regex": {
|
||||
# tests that skip_if_match_fails defaults to True
|
||||
"from": {
|
||||
"override_title": {
|
||||
"exclude": [
|
||||
"should not cap",
|
||||
".*Feb.*", # should filter out march video
|
||||
],
|
||||
},
|
||||
"override_description": {
|
||||
"match": [".*http:\\/\\/(.+).com.*"],
|
||||
"capture_group_names": ["override_description_website"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": {"override_title": "{title}", "override_description": "{description}"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_subscription(music_video_config, regex_subscription_dict):
|
||||
playlist_preset = Preset.from_dict(
|
||||
return Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="regex_capture_playlist_test",
|
||||
preset_dict=regex_subscription_dict,
|
||||
)
|
||||
|
||||
return Subscription.from_preset(
|
||||
preset=playlist_preset,
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_subscription_no_match_fails(
|
||||
music_video_config: ConfigFile, regex_subscription_dict: Dict[str, Any]
|
||||
):
|
||||
regex_subscription_dict["regex"]["skip_if_match_fails"] = False
|
||||
|
||||
return Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="regex_capture_playlist_test",
|
||||
preset_dict=regex_subscription_dict,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_subscription_no_match_fails(
|
||||
music_video_config, regex_subscription_dict_no_match_fails
|
||||
):
|
||||
playlist_preset = Preset.from_dict(
|
||||
def playlist_subscription_exclude(
|
||||
music_video_config: ConfigFile, regex_subscription_dict_exclude: Dict[str, Any]
|
||||
) -> Subscription:
|
||||
return Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="regex_capture_playlist_test",
|
||||
preset_dict=regex_subscription_dict_no_match_fails,
|
||||
preset_name="regex_exclude_playlist_test",
|
||||
preset_dict=regex_subscription_dict_exclude,
|
||||
)
|
||||
|
||||
return Subscription.from_preset(
|
||||
preset=playlist_preset,
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_subscription_overrides(
|
||||
music_video_config: ConfigFile,
|
||||
regex_subscription_dict_match_and_exclude_override_variable: Dict[str, Any],
|
||||
) -> Subscription:
|
||||
return Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="regex_using_overrides_test",
|
||||
preset_dict=regex_subscription_dict_match_and_exclude_override_variable,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_subscription_match_and_exclude(
|
||||
music_video_config: ConfigFile, regex_subscription_dict_match_and_exclude: Dict[str, Any]
|
||||
) -> Subscription:
|
||||
return Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="regex_match_and_exclude_playlist_test",
|
||||
preset_dict=regex_subscription_dict_match_and_exclude,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -113,6 +239,35 @@ class TestRegex:
|
|||
transaction_log_summary_file_name="plugins/test_regex.txt",
|
||||
)
|
||||
|
||||
def test_regex_excludes_success(self, playlist_subscription_exclude, output_directory):
|
||||
# Should only contain the march video
|
||||
transaction_log = playlist_subscription_exclude.download(dry_run=True)
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name="plugins/test_regex_exclude.txt",
|
||||
)
|
||||
|
||||
def test_regex_match_and_excludes_success(
|
||||
self, playlist_subscription_match_and_exclude, output_directory
|
||||
):
|
||||
# Should only contain the Feb 1st video
|
||||
transaction_log = playlist_subscription_match_and_exclude.download(dry_run=True)
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name="plugins/test_regex_match_and_exclude.txt",
|
||||
)
|
||||
|
||||
def test_regex_using_overrides_success(self, playlist_subscription_overrides, output_directory):
|
||||
# Should only contain the march video
|
||||
transaction_log = playlist_subscription_overrides.download(dry_run=True)
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name="plugins/test_regex_overrides.txt",
|
||||
)
|
||||
|
||||
def test_regex_fails_no_match(self, playlist_subscription_no_match_fails, output_directory):
|
||||
with pytest.raises(
|
||||
RegexNoMatchException,
|
||||
|
|
@ -122,6 +277,34 @@ class TestRegex:
|
|||
):
|
||||
_ = playlist_subscription_no_match_fails.download(dry_run=True)
|
||||
|
||||
def test_regex_fails_capture_group_with_only_excludes(
|
||||
self, regex_subscription_dict_exclude, music_video_config
|
||||
):
|
||||
regex_subscription_dict_exclude["regex"]["from"]["title"]["capture_group_names"] = ["uid"]
|
||||
with pytest.raises(
|
||||
ValidationException,
|
||||
match=re.escape(
|
||||
"capture group parameters requires at least one `match` to be specified"
|
||||
),
|
||||
):
|
||||
_ = Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="test_regex_fails_capture_group_is_source_variable",
|
||||
preset_dict=regex_subscription_dict_exclude,
|
||||
)
|
||||
|
||||
def test_regex_fails_no_match_or_exclude(self, regex_subscription_dict, music_video_config):
|
||||
del regex_subscription_dict["regex"]["from"]["title"]["match"]
|
||||
with pytest.raises(
|
||||
ValidationException,
|
||||
match=re.escape("must specify either `match` or `exclude`"),
|
||||
):
|
||||
_ = Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="test_regex_fails_capture_group_is_source_variable",
|
||||
preset_dict=regex_subscription_dict,
|
||||
)
|
||||
|
||||
def test_regex_fails_capture_group_is_source_variable(
|
||||
self, regex_subscription_dict, music_video_config
|
||||
):
|
||||
|
|
@ -164,7 +347,9 @@ class TestRegex:
|
|||
)
|
||||
with pytest.raises(
|
||||
ValidationException,
|
||||
match=re.escape("cannot regex capture 'dne' because it is not a source variable"),
|
||||
match=re.escape(
|
||||
"cannot regex capture 'dne' because it is not a source or override variable"
|
||||
),
|
||||
):
|
||||
_ = Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict
|
|||
{
|
||||
"regex": {
|
||||
"from": {
|
||||
"chapter_title": {
|
||||
# Ensure regex can handle override variables that come from the
|
||||
# post-metadata stage
|
||||
"override_chapter_title": {
|
||||
"match": r"\d+\. (.+)",
|
||||
"capture_group_names": "captured_track_title",
|
||||
"capture_group_defaults": "{chapter_title}",
|
||||
|
|
@ -54,6 +56,7 @@ def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict
|
|||
}
|
||||
},
|
||||
"overrides": {
|
||||
"override_chapter_title": "{chapter_title}",
|
||||
"track_title": "{captured_track_title}",
|
||||
"track_album": "{captured_track_album}",
|
||||
"track_artist": "{captured_track_artist}",
|
||||
|
|
@ -133,7 +136,7 @@ class TestSplitByChapters:
|
|||
mergedeep.merge(
|
||||
yt_album_as_chapters_with_regex_preset_dict,
|
||||
{
|
||||
"download": {"url": "https://youtube.com/watch?v=HKTNxEqsN3Q"},
|
||||
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
|
||||
"split_by_chapters": {"when_no_chapters": when_no_chapters},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
|
|||
def single_video_subs_embed_preset_dict(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"},
|
||||
"download": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
|
||||
# override the output directory with our fixture-generated dir
|
||||
"output_options": {"output_directory": output_directory},
|
||||
"subtitles": {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class TestChannel:
|
|||
expected md5 file hashes.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
@pytest.mark.parametrize("dry_run", [False])
|
||||
def test_full_channel_download(
|
||||
self,
|
||||
channel_as_tv_show_config,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from conftest import preset_dict_to_dl_args
|
||||
from e2e.conftest import mock_run_from_cli
|
||||
from expected_download import assert_expected_downloads
|
||||
from expected_transaction_log import assert_transaction_log_matches
|
||||
from mergedeep import mergedeep
|
||||
|
||||
from ytdl_sub.downloaders.ytdlp import YTDLP
|
||||
from ytdl_sub.entries.entry import Entry
|
||||
from ytdl_sub.subscriptions.subscription import Subscription
|
||||
from ytdl_sub.utils.file_handler import FileHandler
|
||||
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_video_preset_dict(output_directory):
|
||||
def single_video_preset_dict_old_video_tags_format(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": {"url": "https://youtube.com/watch?v=HKTNxEqsN3Q"},
|
||||
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
|
||||
# override the output directory with our fixture-generated dir
|
||||
"output_options": {
|
||||
"output_directory": output_directory,
|
||||
"maintain_download_archive": False,
|
||||
},
|
||||
# embed thumb into the video
|
||||
"embed_thumbnail": True,
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
"format": "worst[ext=mp4]",
|
||||
|
|
@ -32,6 +39,30 @@ def single_video_preset_dict(output_directory):
|
|||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_video_preset_dict(output_directory):
|
||||
return {
|
||||
"preset": "music_video",
|
||||
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
|
||||
# override the output directory with our fixture-generated dir
|
||||
"output_options": {
|
||||
"output_directory": output_directory,
|
||||
"maintain_download_archive": False,
|
||||
},
|
||||
# embed thumb into the video
|
||||
"embed_thumbnail": True,
|
||||
# download the worst format so it is fast
|
||||
"ytdl_options": {
|
||||
"format": "worst[ext=mp4]",
|
||||
},
|
||||
# also test video tags
|
||||
"video_tags": {
|
||||
"title": "{title}",
|
||||
},
|
||||
"overrides": {"artist": "JMC"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_tv_show_video_nulled_values_preset_dict(output_directory):
|
||||
return {
|
||||
|
|
@ -49,6 +80,13 @@ def single_tv_show_video_nulled_values_preset_dict(output_directory):
|
|||
"format": "worst[ext=mp4]",
|
||||
"max_downloads": 2,
|
||||
},
|
||||
# test override variables added by ytdl-sub
|
||||
"nfo_tags": {
|
||||
"tags": {
|
||||
"subscription_name": "{subscription_name}",
|
||||
"subscription_name_sanitized": "{subscription_name_sanitized}",
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"url": "https://www.youtube.com/@ProjectZombie603",
|
||||
"tv_show_name": "Project Zombie",
|
||||
|
|
@ -63,7 +101,26 @@ def single_video_preset_dict_dl_args(single_video_preset_dict):
|
|||
|
||||
|
||||
class TestYoutubeVideo:
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
def test_single_video_old_video_tags_format_download(
|
||||
self,
|
||||
music_video_config,
|
||||
single_video_preset_dict_old_video_tags_format,
|
||||
output_directory,
|
||||
):
|
||||
single_video_subscription = Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="music_video_single_video_test",
|
||||
preset_dict=single_video_preset_dict_old_video_tags_format,
|
||||
)
|
||||
|
||||
transaction_log = single_video_subscription.download(dry_run=True)
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name="youtube/test_video.txt",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("dry_run", [True])
|
||||
def test_single_video_download(
|
||||
self,
|
||||
music_video_config,
|
||||
|
|
@ -89,6 +146,44 @@ class TestYoutubeVideo:
|
|||
expected_download_summary_file_name="youtube/test_video.json",
|
||||
)
|
||||
|
||||
def test_single_video_download_missing_thumbnail(
|
||||
self,
|
||||
music_video_config,
|
||||
single_video_preset_dict,
|
||||
working_directory,
|
||||
output_directory,
|
||||
):
|
||||
single_video_subscription = Subscription.from_dict(
|
||||
config=music_video_config,
|
||||
preset_name="music_video_single_video_test",
|
||||
preset_dict=single_video_preset_dict,
|
||||
)
|
||||
|
||||
def delete_entry_thumb(entry: Entry) -> None:
|
||||
FileHandler.delete(entry.get_download_thumbnail_path())
|
||||
try_convert_download_thumbnail(entry=entry)
|
||||
|
||||
# Pretend the thumbnail did not download via returning nothing for its downloaded path
|
||||
with patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1), patch.object(
|
||||
Entry, "try_get_ytdlp_download_thumbnail_path"
|
||||
) as mock_ytdlp_path, patch(
|
||||
"ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail",
|
||||
side_effect=delete_entry_thumb,
|
||||
):
|
||||
mock_ytdlp_path.return_value = None
|
||||
transaction_log = single_video_subscription.download(dry_run=False)
|
||||
|
||||
assert_transaction_log_matches(
|
||||
output_directory=output_directory,
|
||||
transaction_log=transaction_log,
|
||||
transaction_log_summary_file_name="youtube/test_video_missing_thumb.txt",
|
||||
)
|
||||
assert_expected_downloads(
|
||||
output_directory=output_directory,
|
||||
dry_run=False,
|
||||
expected_download_summary_file_name="youtube/test_video_missing_thumb.json",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("dry_run", [True, False])
|
||||
def test_single_video_download_from_cli_dl(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ def assert_expected_downloads(
|
|||
dry_run: bool,
|
||||
expected_download_summary_file_name: str,
|
||||
ignore_md5_hashes_for: Optional[List[str]] = None,
|
||||
regenerate_expected_download_summary: bool = REGENERATE_FIXTURES,
|
||||
):
|
||||
if dry_run:
|
||||
output_directory_contents = list(Path(output_directory).rglob("*"))
|
||||
|
|
@ -134,7 +133,11 @@ def assert_expected_downloads(
|
|||
return
|
||||
|
||||
summary_full_path = _EXPECTED_DOWNLOADS_SUMMARY_PATH / expected_download_summary_file_name
|
||||
if regenerate_expected_download_summary:
|
||||
|
||||
# USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
|
||||
# Updates the file with the input transaction log. Should only be used to update
|
||||
# tests after an expected change is made.
|
||||
if REGENERATE_FIXTURES:
|
||||
os.makedirs(os.path.dirname(summary_full_path), exist_ok=True)
|
||||
ExpectedDownloads.from_directory(directory_path=output_directory).to_summary_file(
|
||||
summary_file_path=summary_full_path
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ def assert_transaction_log_matches(
|
|||
output_directory: Path,
|
||||
transaction_log: FileHandlerTransactionLog,
|
||||
transaction_log_summary_file_name: str,
|
||||
regenerate_transaction_log: bool = REGENERATE_FIXTURES,
|
||||
):
|
||||
"""
|
||||
Parameters
|
||||
|
|
@ -26,16 +25,14 @@ def assert_transaction_log_matches(
|
|||
transaction_log_summary_file_name
|
||||
Name if the transaction log summary to compare.
|
||||
Lives in tests/e2e/resources/transaction_log_summaries
|
||||
regenerate_transaction_log
|
||||
USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
|
||||
Updates the file with the input transaction log. Should only be used to update
|
||||
tests after an expected change is made.
|
||||
"""
|
||||
transaction_log_path = _TRANSACTION_LOG_SUMMARY_PATH / transaction_log_summary_file_name
|
||||
summary = transaction_log.to_output_message(output_directory=output_directory)
|
||||
|
||||
# Write the expected summary file if regenerate is True
|
||||
if regenerate_transaction_log:
|
||||
# USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
|
||||
# Updates the file with the input transaction log. Should only be used to update
|
||||
# tests after an expected change is made.
|
||||
if REGENERATE_FIXTURES:
|
||||
os.makedirs(os.path.dirname(transaction_log_path), exist_ok=True)
|
||||
with open(transaction_log_path, "w", encoding="utf-8") as summary_file:
|
||||
summary_file.write(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
".ytdl-sub-jb-download-archive.json": "9f5ca17dc0d842ac4c296054c0011ace",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/01 - Spark it.mp3": "6f21b6f977609382fa9b9fa9817b28cc",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/02 - Astronomikal Funk.mp3": "870199f5e75ea170051122f7220e2f42",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/03 - Impulse.mp3": "3a14e5e034016ad131186caf94be00da",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/04 - September.mp3": "c58520ef3f7f8944151ee0fb95fb0ac5",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/05 - Interlune.mp3": "5a7365a253ac4a5af54e550508a40b66",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/06 - Ninjutsu Techniques.mp3": "07b3f74ef78cb0c869c86dded054b1e9",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/07 - Space Funk Flow.mp3": "fef74e222258c4845e8b51e05acd240f",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/08 - Cellar Groove.mp3": "743927c47df60e811d367f5ef969faaf",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/09 - Solar Wind.mp3": "dd97643e3dc5decefb543da3a6e31356",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/10 - Interlune II.mp3": "37717cbd8f837657047616743937595b",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/11 - Ghetto Anthem.mp3": "54089f39836d469f391f6850926a392d",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/12 - Street Degree.mp3": "ddd3dc74d9bc07b988e4e4da363624dd",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/13 - Trouble World.mp3": "90dacf561b52852312f9e3f06084bfca",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/14 - Relaxation.mp3": "12dc503e0f758ae0b962e941727f272b",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/15 - Journey From Within.mp3": "c593e610ac6853b3795781b6650fc59e",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/01 - Spark it.mp3": "f1fcc87884ced84be016391e3989b5c1",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/02 - Astronomikal Funk.mp3": "d07701404b8ddaa2e38eb51f7e2ac2d8",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/03 - Impulse.mp3": "43118db203e1d9427036102cfb39a1e3",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/04 - September.mp3": "3d75c5068e3dbb93dd86fc650c7ca84d",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/05 - Interlune.mp3": "63897d8af920f31af2183a17bc697c11",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/06 - Ninjutsu Techniques.mp3": "a500409583ee30e4cb68d64af2b32596",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/07 - Space Funk Flow.mp3": "1e1a12a5a42a607abf342ec4f301f471",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/08 - Cellar Groove.mp3": "a18ceff8785e8849d166eb2710caafa4",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/09 - Solar Wind.mp3": "a41665337e3fe05d7c7d9d8e6ddde737",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/10 - Interlune II.mp3": "8d2f3158ac9bac51aafbdbe6eb43154b",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/11 - Ghetto Anthem.mp3": "39085bae1b0f8544acd08f74f88cacfd",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/12 - Street Degree.mp3": "561f0d8fbc62f87f2149c17ee0a53ce2",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/13 - Trouble World.mp3": "1ad07dc6a2d6ced78449bc46a2909335",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/14 - Relaxation.mp3": "ba8addb0d59f26a84bbb79a6cdd1af11",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/15 - Journey From Within.mp3": "bd97135290482c6bbe6eac7d3a700f11",
|
||||
"El Jazzy Chavo/[2022] S950 Funk/folder.jpg": "62e51365dd309e76a6323f927f6fcc94"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
".ytdl-sub-chapters_from_comments-download-archive.json": "122723ce8d257eebb05178daa26141f6",
|
||||
"JMC/JMC - Move 78 - Automated Improvisation [Full Album]-thumb.jpg": "c12e6a6f242680d1096a1a99d74a62c6",
|
||||
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "207f8c8aa7a383aa1acb99a6836f2fb6",
|
||||
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "40a74798dddf486a656c761516eb0528",
|
||||
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "31d3e187632cffa84879027c9c7ddb2f",
|
||||
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "b82e8eb3f912a4ee2b14d90332fdce89",
|
||||
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].nfo": "7a65b184d24c68fc0ec5380432250f5b"
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"Project ⧸ Zombie/.ytdl-sub-recent-download-archive.json": "ca7275efd7f2ebe489b8ca4007dc5361",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer-thumb.jpg": "705ca4e0d99b37e9ecdf6bfe4b90c59b",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.info.json": "a8bc452d1b2075d761f5f2bf3812ecb9",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.info.json": "f249bd1137702b3db3d2805bbf4bfff8",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.mp4": "dbb140af8676f34fb701d5199e8708ea",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.nfo": "bb868035301eed6afea636f7f7bddaf5",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.nfo": "ee5bee94667ed4f1d47af08384418d3c",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "abd80bd5828a3b986f41ec0bd8d497c1",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "6cbd3a26974aa3be8ab73578e748e013",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "e143a26148069d0477dc45896afba93e",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
|
||||
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
||||
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
||||
"Project ⧸ Zombie/tvshow.nfo": "3adb1769f4ca5a0c4dd59a9f662a4780"
|
||||
"Project ⧸ Zombie/tvshow.nfo": "bc3d17c23371da3af6db1108faea5a84"
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"Project ⧸ Zombie/.ytdl-sub-recent-download-archive.json": "46170edaf7cc51e5f59ef5d4c0476200",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "abd80bd5828a3b986f41ec0bd8d497c1",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "6cbd3a26974aa3be8ab73578e748e013",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "e143a26148069d0477dc45896afba93e",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
|
||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
|
||||
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
||||
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
||||
"Project ⧸ Zombie/tvshow.nfo": "3adb1769f4ca5a0c4dd59a9f662a4780"
|
||||
"Project ⧸ Zombie/tvshow.nfo": "bc3d17c23371da3af6db1108faea5a84"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
".ytdl-sub-file_convert_test-download-archive.json": "65aa49c7345f9fc201e42ddc4a96b19b",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3-thumb.jpg": "662fcaadf6e80d63591bac19a5fdffb0",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "cc013e7b638b49e51970e855bcf821c8",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "5201f78cc65d5bb26aeea114e11d6264",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4": "390177bcc076e309d1534b8bb5afdcc7",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
".ytdl-sub-file_convert_test-download-archive.json": "74813dccf4e9732e49f5dc3c2d66f3be",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3-thumb.jpg": "662fcaadf6e80d63591bac19a5fdffb0",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "848617cf0fa5f9e51b929de72d594d78",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "20cf671c3a613d4aae8f5bfbf14feba0",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv": "b5f248b560f89f3f2a83fcdcd197d486",
|
||||
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
".ytdl-sub-match_filter_test-download-archive.json": "6017105735dd8fdfe18932c9c048dccf",
|
||||
"Project Zombie/Project Zombie - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
|
||||
"Project Zombie/Project Zombie - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "ea8e2d03835416861654e3cffed5e181",
|
||||
"Project Zombie/Project Zombie - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "17c303f07e9b3af77c90975f843c95bd",
|
||||
"Project Zombie/Project Zombie - Jesse's Minecraft Server [Trailer - Mar.21].webm": "ad90ebef1ea6cbdb924670cdd7d1b9b9"
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
".ytdl-sub-split_by_chapters_video-download-archive.json": "c3fb0b4f31caaa10ac7954ea93da33c4",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "29d09da874133ce5c5f7459c2fa6cd85",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "d5432e6a4f7af9810b0e7c8eebe4898a",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "cf03da6688a73373f9295dc595156e11",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "b7b943b6f3395c05433b8532364d63d6",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "0d9719268900d4abcd8c0f51ad3af92c",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "bbd9ec616a0f7d3c5d13c04bb118681e",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "2e59ecdc0f8050bcf190a7898d7ae968",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "e58966f3326b876f0ab97459b681f76f",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "d6939215b73457fce4e026f8c9b57ecc",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "82d0e7d8070fd15d71c2e11c0285d426",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "3edb11e8cd5f270dd42ee62d9a9aa4ff",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "8827ac62201abeb94f44a46d91628aa8",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "f17e02119bf073c4ed98da2a960b85d3",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "63c0bc97cbdaf35203011378b1860418",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "eb1a8533390243a5c4c49d8b6871c0ce",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "a1b54772207228bd4922ad93d249c9f5",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "1d217264368d5f078e67691ea1b3df97",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "4d1a1728dbd69a30bbc2ffa354851169",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "c204cf16f9f44945634c6171a5192e99",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "795e22297f8db00697563774328ffb3a",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "3904a5cd81b5ac43850b7858fb8dd378",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "a89549db5b16ff76d04fd0bbbfba3424",
|
||||
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
".ytdl-sub-split_by_chapters_with_regex_video-download-archive.json": "4008e43668447f1a3a6a55520a6ff475",
|
||||
"Project Zombie/[2010] Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3": "a3c01f164eeca4541aeed49264d2fc8c",
|
||||
"Project Zombie/[2010] Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3": "8fc765ce55304814942c1f38fe9e38e2",
|
||||
"Project Zombie/[2010] Oblivion Mod "Falcor" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e"
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
".ytdl-sub-split_by_chapters_with_regex_video-download-archive.json": "9798e8289742586d0efd295a97c6c906",
|
||||
"Alfa Mist/[2017] Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "7be57c4dde9ba2c3fa72e9cc65b0f586",
|
||||
"Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "5ee032da9965c51913fcfa0319d061bd",
|
||||
"Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "82c1f35bdccfb4ec146f33661ac94d6b",
|
||||
"Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "040b38c249e478d8832dcea8bfeca17f",
|
||||
"Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "06f7e57fecdc7b4d2e37c7652791ce19",
|
||||
"Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "91a03449c33bf9dcff5a9654a8525626",
|
||||
"Alfa Mist/[2017] Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "54c26621b8d4e74f37217c836479a077",
|
||||
"Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "2704327ac5998086e77a77da164e2afd",
|
||||
"Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "75b8a26d3099aa2d9ba488b33c4eb1e7",
|
||||
"Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "0bf0979c99c55ef986efaba55c0dc858",
|
||||
"Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "78950fbfe459de8e9aa951ab6fa153bf",
|
||||
"Alfa Mist/[2017] Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "c10cff170976c3c5139f6073eeb3e25e",
|
||||
"Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "881ecdbee4a0d565c0ec6eacf03115df",
|
||||
"Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "aa5c6fbab58dd2be2660bfb65be6a172",
|
||||
"Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "a64ce02ed1188276c9744f92cfc81e8e",
|
||||
"Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "a6dbe96141515caa32f3df4f66e46f75",
|
||||
"Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "9b8ff5357109c376cce4bdd25e724d7b",
|
||||
"Alfa Mist/[2017] Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "022b077892b838c478c65ba3b5f00b55",
|
||||
"Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "75c9c0f789c378c45bb176aa6cc6ec57",
|
||||
"Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "af59a3ac869249ac6ef2586fa6c59009",
|
||||
"Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "27d36f90dc87264c57e88f3663b29467",
|
||||
"Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "be7085f9a536dfd2f70ce001c65a416c",
|
||||
"Alfa Mist/[2017] Nocturne/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
".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": "5657c5b92f8980b20d8bbee0fdc7e5d8",
|
||||
"Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "a2a3a34e02e26a6c0265530d4499473b",
|
||||
"Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "b2d6388b4ddf8e3fbca042cb123672c3",
|
||||
"Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "602166199db42b48e49483f82acc33ce",
|
||||
"Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "eae6fff43c176fe6b9001a5e1d66517c",
|
||||
"Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "67b590e4323c67229b05faf45fe557da",
|
||||
"Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530"
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
".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": "7affc0ecf01f36de9cee1a7aafd776eb",
|
||||
"YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/01 - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "33c69b0dce605e9f78fe23246ce442dd",
|
||||
"YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a"
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
".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": "37b38834eda1293ad503e00dcff7c4dc",
|
||||
"YouTube/[2019] YouTube Rewind 2019: For the Record | #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "40abc64a8fe93a10406bff1548a2cbe2",
|
||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case-thumb.jpg": "b5353a824a4800cc26f884e3025ed969",
|
||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.info.json": "970c6f6a3b34851fa6fcbe7e9b7b21d0",
|
||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "03696a5cf4491c2a3649c7022801a14a",
|
||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.info.json": "324f582f8a69d3844019e650b382773f",
|
||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "20a9ddbececcc2c75d6df06e88007b5c",
|
||||
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "b9bd35e4f260c728774d8dd31f83637c"
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
".ytdl-sub-subtitles_embedded_test-download-archive.json": "1aa35c1d2663ac721f33f8374c00af1a",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg": "50ee47c80f679029f5d3503bb91b045a",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json": "24ee80a0b78191fbc091b29901ca7067",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json": "7940facc211cb3e3956af3f2ad6f19fd",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "1f1f91f85b162c20596a2413e704b809",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48"
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg": "50ee47c80f679029f5d3503bb91b045a",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.de.srt": "b343c3bb9257b7ee7ba38f570a115b37",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.en.srt": "fe8c6ee92cae6e059fd80fd61691adbe",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json": "b295080aa389e023026d4127e8ab0a06",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.info.json": "a3f7906782db2be4aa43bdd1354e80bb",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "1f1f91f85b162c20596a2413e704b809",
|
||||
"JMC/JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48"
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
".ytdl-sub-jb-download-archive.json": "1a99156e9ece62539fb2608416a07200",
|
||||
"j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3": "a8955bccae34c39e64d70a021c95e6d4",
|
||||
"j_b/[2021] Baby Santana's Dorian Groove/01 - Baby Santana's Dorian Groove.mp3": "317a9ee75431d6020e3cad0883c7392f",
|
||||
"j_b/[2021] Baby Santana's Dorian Groove/folder.jpg": "967892be44b8c47e1be73f055a7c6f08",
|
||||
"j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3": "ec1d40fee597da35f5dc0126438efe54",
|
||||
"j_b/[2021] Purple Clouds/01 - Purple Clouds.mp3": "4a5971c12ca6fdc335b0185a14e829f2",
|
||||
"j_b/[2021] Purple Clouds/folder.jpg": "967892be44b8c47e1be73f055a7c6f08",
|
||||
"j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "768e148a2b24ed2de299ca4899d8e533",
|
||||
"j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "a9cd2d2176f94fdad8965c1fbbc3396a",
|
||||
"j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "f3c74e7e8595505d15a384226d0c906e",
|
||||
"j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "d13a3008d9a39da3e5fdcd0c279718e8",
|
||||
"j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "3a40d1108c4f6a0d004eea2151001d66",
|
||||
"j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "8224406bb95e100f4ebcdf7fec3db1c0",
|
||||
"j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "1ea96a8581eb81e45564617f75bd467e",
|
||||
"j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "df14e3b41386cf408b4d1549428a17a5",
|
||||
"j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "f52df647e1f5cf15df720d62b5ad64b1",
|
||||
"j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "429ac1c752822230803c1a452987e492",
|
||||
"j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "9322445a6a7cdd506038b8714cb1731f",
|
||||
"j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "bcc88eb579311b59ede4c13cdf24bcb9",
|
||||
"j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "239d10c17047662c760cce834548d91d",
|
||||
"j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "2d6e977e3e665012fdeaa0e8c2ee7375",
|
||||
"j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "9da5b26fc231dfd0c8f09a2a3d2fe116",
|
||||
"j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "8fb1cb262175921dbdddd6783664fa65",
|
||||
"j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "529846e26a18be616deb3f12d858b8ea",
|
||||
"j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "ba2be1ace7b0c31964bf0b8b15781b27",
|
||||
"j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "ad248c0392ced4f6d548a4852fd99f1f",
|
||||
"j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "52326cbd96e6f243cae77b0f68f00ef6",
|
||||
"j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "a265ed7e4d3a014d09e79ac5f4fe3fc7",
|
||||
"j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "94d548773dded0089e73b26e6f923d9a",
|
||||
"j_b/[2022] Acoustic Treats/folder.jpg": "967892be44b8c47e1be73f055a7c6f08"
|
||||
}
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "f1315ac907d99c567839ac982b396f51",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "44a703e77c8285e81fec57ed9000bb0c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "559c970f2fde6db18d42850b65f038b4",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "3fe91099a510c9dc820a0a7adba98868",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "6e712b8c5b40f3622732f087127d75f3",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "e780d5e9e678d4aae5b131a5f3603695",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "61d42a2f8aa085eae5758206ff853538",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "4a4d542da434d5b3ff8d304f0e077469",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,36 +1,36 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "2bf8edeaf8b5658c4a42a9faa9bb2f60",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "4b04325397e3b9ed0ee51f319ce07353",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "3caee440547dd4ed46ec3cdca46978f2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "729ca4dba26539e6d8c04443c9aa67ab",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "0b180b38ac673e7e320e9e615974fe41",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "075f2c2ce8c995195decf755fc980c89",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "5b26edd07c0534d53884d6a42908f8db",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "e3b5da94c5a86db003633570af47d1a9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "629fb263cff2e93b7ec67769ebbc75e0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.mp4": "e8b77ffd826f8f7233b875e311adaf34",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "17489dad8935540598d3c5f873eb11f8",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "c1f5925e1eab8bd21e077df560879d94",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "c572cf9767af2221f4e88b40ab11e7dd",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "0a3998c019cc33845be703ca1bb448f9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.mp4": "4343e692b53f0abce80559b59ef2fa0c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "5be336b1d5e9db676bc864081dd0a20c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "9fdbaa70187252b0d303d128123c4e83",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "4fe8ba1b78f29c9e578d84d3ef6e8caa",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "e891c1960d5c29b5e1e73c98ecf2e7f6",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.mp4": "dea5d69ae35e47aa78a51f464088a6ad",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "296ac9d9371ba73e4361ace900de3056",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "5e5815bbc8b471df94d8536b117409f4",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "943a66e92c610b032471cf684fa2cdf0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "fe18c65b99ca8942e65d777c9a498577",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.mp4": "b2f03dcefe44b8afc65b2500c873aec2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "acd99e3536227ce1fbfdb0323a180418",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "a86c62cd14e6a42dd79639a6155165fe",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "fc9484dbc42b6fd6149fdcacc235e206",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "aeeb1be477400f34485799112a8121c2",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "044162512a1982b573a0d6f19646801a",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "bebd3dafb168c976a8c817e83a550ce2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "d6e1d8a486125f87d04959607e2dc515",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "7543d21dcf2bd220a9ce4cd15f1e1578",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "5632b6ac9992ee07bf6052e9c87a02c5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "921a343cf11be7cc492678459dbdd926",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "aa1269526f1e384b25d630c7db17860e",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "5757f06de01c68c5104ec7a7b0c6a69b",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
|
||||
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
|
||||
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "2bf8edeaf8b5658c4a42a9faa9bb2f60",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "b8f3acff3cb18657dfa8a37b037c78f1",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "3a613f0724932930ab746bcf9f8bdf42",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "e4efb3ea481b6936d188470ace4d6bae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "a702f2b8e66c348ceb3ec1fd65956024",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "683d44d82d8854b1bed529196dc091c9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "455e6a6bce2b8630d1a53e11c5554362",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "f2ab803ea763a11fb271d7a36b2eb340",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "04c2a406e17fb509e849c3d5ccac8b5e",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.mp4": "e8b77ffd826f8f7233b875e311adaf34",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "17489dad8935540598d3c5f873eb11f8",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "c1f5925e1eab8bd21e077df560879d94",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "6e010415351d9d94f64624607d7ad831",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "b5960c859c450da1c40fe68951499a8f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.mp4": "4343e692b53f0abce80559b59ef2fa0c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "5be336b1d5e9db676bc864081dd0a20c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "9fdbaa70187252b0d303d128123c4e83",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "7214c68d8bd6f3c714dee034a5e04fa8",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "1892550ced3033c5b779324e39ef77ac",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.mp4": "dea5d69ae35e47aa78a51f464088a6ad",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "296ac9d9371ba73e4361ace900de3056",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "5e5815bbc8b471df94d8536b117409f4",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "d04117d103932f217a50e1a28db42962",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "03254329cabbac6a887801d1a3a4a9fc",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.mp4": "b2f03dcefe44b8afc65b2500c873aec2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "acd99e3536227ce1fbfdb0323a180418",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "a86c62cd14e6a42dd79639a6155165fe",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "e33b506e3e441ad05af03826b8ccb758",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "09b731b1e404368cf7ce3fe8197e1de9",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
|
||||
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
|
||||
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "f1315ac907d99c567839ac982b396f51",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "44a703e77c8285e81fec57ed9000bb0c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "559c970f2fde6db18d42850b65f038b4",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "3fe91099a510c9dc820a0a7adba98868",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "6e712b8c5b40f3622732f087127d75f3",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "e780d5e9e678d4aae5b131a5f3603695",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "61d42a2f8aa085eae5758206ff853538",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "4a4d542da434d5b3ff8d304f0e077469",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,36 +1,36 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "126aec3c19ad3a56faceed442515faf5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "e3b5da94c5a86db003633570af47d1a9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "629fb263cff2e93b7ec67769ebbc75e0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.mp4": "a44c49a4adb03fc619ff10a37025c09d",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "9ee9c7df9a719d90bc6218f0d06af136",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "c4311723a97b1b299342aca9c079ffbc",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "c572cf9767af2221f4e88b40ab11e7dd",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "0a3998c019cc33845be703ca1bb448f9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.mp4": "ca2a1e878e543c73e5d95c983580ea66",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "130944ece25592f7dc7d3b395ec1cee5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "2d3b121869944eeef9919765b6d10ec3",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "4fe8ba1b78f29c9e578d84d3ef6e8caa",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "e891c1960d5c29b5e1e73c98ecf2e7f6",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.mp4": "1ed7951cb4414e79f78f48176f9fc2c2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "0e10bead605b29b3939de12bcfc68039",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "3b507ba7e4482c16ccd5b1a832c205eb",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "943a66e92c610b032471cf684fa2cdf0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "fe18c65b99ca8942e65d777c9a498577",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.mp4": "fdc41ef1aceab35f2f229962ec4bb66a",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "885a2381f1dab6b02cb21176fdf958ce",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "27e19fdc5941b870320b68f189e2c2ec",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "4b04325397e3b9ed0ee51f319ce07353",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "3caee440547dd4ed46ec3cdca46978f2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "729ca4dba26539e6d8c04443c9aa67ab",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "0b180b38ac673e7e320e9e615974fe41",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "075f2c2ce8c995195decf755fc980c89",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "5b26edd07c0534d53884d6a42908f8db",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "fc9484dbc42b6fd6149fdcacc235e206",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "aeeb1be477400f34485799112a8121c2",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "044162512a1982b573a0d6f19646801a",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "bebd3dafb168c976a8c817e83a550ce2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "d6e1d8a486125f87d04959607e2dc515",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "7543d21dcf2bd220a9ce4cd15f1e1578",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "5632b6ac9992ee07bf6052e9c87a02c5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "921a343cf11be7cc492678459dbdd926",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "aa1269526f1e384b25d630c7db17860e",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "5757f06de01c68c5104ec7a7b0c6a69b",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
|
||||
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
|
||||
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "126aec3c19ad3a56faceed442515faf5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "f2ab803ea763a11fb271d7a36b2eb340",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "04c2a406e17fb509e849c3d5ccac8b5e",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.mp4": "a44c49a4adb03fc619ff10a37025c09d",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "9ee9c7df9a719d90bc6218f0d06af136",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "c4311723a97b1b299342aca9c079ffbc",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "6e010415351d9d94f64624607d7ad831",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "b5960c859c450da1c40fe68951499a8f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.mp4": "ca2a1e878e543c73e5d95c983580ea66",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "130944ece25592f7dc7d3b395ec1cee5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "2d3b121869944eeef9919765b6d10ec3",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "7214c68d8bd6f3c714dee034a5e04fa8",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "1892550ced3033c5b779324e39ef77ac",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.mp4": "1ed7951cb4414e79f78f48176f9fc2c2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "0e10bead605b29b3939de12bcfc68039",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "3b507ba7e4482c16ccd5b1a832c205eb",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "d04117d103932f217a50e1a28db42962",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "03254329cabbac6a887801d1a3a4a9fc",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.mp4": "fdc41ef1aceab35f2f229962ec4bb66a",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "885a2381f1dab6b02cb21176fdf958ce",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "27e19fdc5941b870320b68f189e2c2ec",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "b8f3acff3cb18657dfa8a37b037c78f1",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "3a613f0724932930ab746bcf9f8bdf42",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "e4efb3ea481b6936d188470ace4d6bae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "a702f2b8e66c348ceb3ec1fd65956024",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "683d44d82d8854b1bed529196dc091c9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "455e6a6bce2b8630d1a53e11c5554362",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "e33b506e3e441ad05af03826b8ccb758",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "09b731b1e404368cf7ce3fe8197e1de9",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
|
||||
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
|
||||
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "05539b932348da7e949a63eb71f77efc",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "6e712b8c5b40f3622732f087127d75f3",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "e780d5e9e678d4aae5b131a5f3603695",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.mp4": "5eafeb7a1c616dfe02c28fc902ce00fb",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "57a07df96fb016c59a292b5cd1d22e10",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "88fee5353ad7eecd96865b34654bbde6",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "559c970f2fde6db18d42850b65f038b4",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "3fe91099a510c9dc820a0a7adba98868",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.mp4": "e4a7144efa7039a03ae69460361ae518",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "473a638408eff688582ec8572ba955b2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "d781b80ef24916acbf3bf181ab32ddea",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "f1315ac907d99c567839ac982b396f51",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "44a703e77c8285e81fec57ed9000bb0c",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.mp4": "7566491129c54183e337e83455ab5bda",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "b1070f080d4448a08cbead85540f370d",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "252b3639b4d1686f8c79476943f89439",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "61d42a2f8aa085eae5758206ff853538",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "4a4d542da434d5b3ff8d304f0e077469",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.mp4": "e94af7eed1fe7d2512d042979ca878dd",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "7f90134d9346efe476cc5a1e531f8ecd",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
|
|
@ -1,36 +1,36 @@
|
|||
{
|
||||
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "6baee42e9095883c0ed77e6b5829e2c2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "075f2c2ce8c995195decf755fc980c89",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "5b26edd07c0534d53884d6a42908f8db",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.mp4": "5eafeb7a1c616dfe02c28fc902ce00fb",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "57a07df96fb016c59a292b5cd1d22e10",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "88fee5353ad7eecd96865b34654bbde6",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "729ca4dba26539e6d8c04443c9aa67ab",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "0b180b38ac673e7e320e9e615974fe41",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.mp4": "e4a7144efa7039a03ae69460361ae518",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "473a638408eff688582ec8572ba955b2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "d781b80ef24916acbf3bf181ab32ddea",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "4b04325397e3b9ed0ee51f319ce07353",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "3caee440547dd4ed46ec3cdca46978f2",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.mp4": "7566491129c54183e337e83455ab5bda",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "b1070f080d4448a08cbead85540f370d",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "252b3639b4d1686f8c79476943f89439",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.info.json": "943a66e92c610b032471cf684fa2cdf0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.info.json": "fe18c65b99ca8942e65d777c9a498577",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.mp4": "0850f53dd2335ba57632128b9bb9d112",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.nfo": "ed439adf2aa5eb4ba741934791ca99e5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.nfo": "4edc04aaea3799ef24bafe6afe4017e5",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.info.json": "4fe8ba1b78f29c9e578d84d3ef6e8caa",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.info.json": "e891c1960d5c29b5e1e73c98ecf2e7f6",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.mp4": "0dcd1a017948164d217f0bde59551655",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.nfo": "a59a0fa2cef91dea9c1416f3c1abd3e0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.nfo": "d7d3005d7a8b3feb7465e697cfb4cad4",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.info.json": "c572cf9767af2221f4e88b40ab11e7dd",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.info.json": "0a3998c019cc33845be703ca1bb448f9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.mp4": "6c4b98bd5e32fbffef033f268d8587ae",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.nfo": "d33cdac7ddd02bb601add35e4dce0173",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.nfo": "1832889c5a8010c28914ef5c9845e14f",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.info.json": "e3b5da94c5a86db003633570af47d1a9",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.info.json": "629fb263cff2e93b7ec67769ebbc75e0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.mp4": "386aa9718db383c17aafc9a00d61d0f0",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.nfo": "1c65b21f8bd5a52dab41ae2d297925e3",
|
||||
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.nfo": "75a73feabcd828d88768c453ab13718e",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "fc9484dbc42b6fd6149fdcacc235e206",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "aeeb1be477400f34485799112a8121c2",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.mp4": "e94af7eed1fe7d2512d042979ca878dd",
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "7f90134d9346efe476cc5a1e531f8ecd",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
|
||||
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
|
||||
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue