Merge branch 'jmbannon:master' into master

This commit is contained in:
CatDuck 2023-08-13 17:46:47 -04:00 committed by GitHub
commit 9fc55a8c99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
380 changed files with 5473 additions and 2543 deletions

View file

@ -3,6 +3,9 @@ FROM ghcr.io/linuxserver/baseimage-alpine:edge
############################################################################### ###############################################################################
# YTDL-SUB INSTALL # YTDL-SUB INSTALL
# Needed for phantomjs
ENV OPENSSL_CONF="/etc/ssl"
COPY root/ / COPY root/ /
RUN mkdir -p /config && \ RUN mkdir -p /config && \
apk update --no-cache && \ apk update --no-cache && \
@ -24,15 +27,19 @@ RUN mkdir -p /config && \
aria2c --version && \ aria2c --version && \
# Install phantomjs if using x86_64, ensure it is properly installed # Install phantomjs if using x86_64, ensure it is properly installed
if [[ $(uname -m) == "x86_64" ]]; then \ if [[ $(uname -m) == "x86_64" ]]; then \
apk add --no-cache gcompat && \
cd /usr/share && \ cd /usr/share && \
curl -L https://github.com/Overbryd/docker-phantomjs-alpine/releases/download/2.11/phantomjs-alpine-x86_64.tar.bz2 | tar xj && \ curl -L https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar xj && \
ln -s /usr/share/phantomjs/phantomjs /usr/bin/phantomjs && \ 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:" && \ echo "Phantom JS version:" && \
phantomjs --version && \ phantomjs --version && \
cd -; \ cd -; \
fi && \ fi && \
echo "hi" && \
# Install ytdl-sub, ensure it is installed properly # 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 && \ ytdl-sub -h && \
# Delete unneeded packages after install # Delete unneeded packages after install
rm ytdl_sub-*.whl && \ rm ytdl_sub-*.whl && \

View file

@ -17,9 +17,6 @@ configuration:
presets: presets:
video: video:
download:
download_strategy: "url"
output_options: output_options:
output_directory: "/tmp/ytdl-sub-output" output_directory: "/tmp/ytdl-sub-output"
file_name: "{uid}.{ext}" file_name: "{uid}.{ext}"

View file

@ -13,6 +13,5 @@
# #
rammstein_music_videos: rammstein_music_videos:
preset: "video" preset: "video"
download: download: "https://youtube.com/playlist?list=PLVTLbc6i-h_iuhdwUfuPDLFLXG2QQnz-x"
url: "https://youtube.com/playlist?list=PLVTLbc6i-h_iuhdwUfuPDLFLXG2QQnz-x"

View file

@ -9,5 +9,5 @@
cp -R /defaults/examples /config/ cp -R /defaults/examples /config/
# permissions # permissions
chown -R abc:abc \ chown -R ${PUID:-abc}:${PGID:-abc} \
/config /config

View file

@ -165,6 +165,15 @@ date_range
:member-order: bysource :member-order: bysource
:exclude-members: partial_validate :exclude-members: partial_validate
-------------------------------------------------------------------------------
embed_thumbnail
''''''''''''''''
.. autoclass:: ytdl_sub.plugins.embed_thumbnail.EmbedThumbnailOptions()
-------------------------------------------------------------------------------
file_convert file_convert
'''''''''''' ''''''''''''
.. autoclass:: ytdl_sub.plugins.file_convert.FileConvertOptions() .. autoclass:: ytdl_sub.plugins.file_convert.FileConvertOptions()
@ -185,8 +194,6 @@ match_filters
music_tags music_tags
'''''''''' ''''''''''
.. autoclass:: ytdl_sub.plugins.music_tags.MusicTagsOptions() .. autoclass:: ytdl_sub.plugins.music_tags.MusicTagsOptions()
:members:
:exclude-members: partial_validate
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
@ -213,8 +220,8 @@ regex
.. autoclass:: ytdl_sub.plugins.regex.RegexOptions() .. autoclass:: ytdl_sub.plugins.regex.RegexOptions()
:members: skip_if_match_fails :members: skip_if_match_fails
.. autoclass:: ytdl_sub.plugins.regex.SourceVariableRegex() .. autoclass:: ytdl_sub.plugins.regex.VariableRegex()
:members: match, capture_group_names, capture_group_defaults :members: match, capture_group_names, capture_group_defaults, exclude
:member-order: bysource :member-order: bysource
:exclude-members: partial_validate :exclude-members: partial_validate
@ -241,8 +248,6 @@ subtitles
video_tags video_tags
'''''''''' ''''''''''
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions() .. 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 This ``subscription.yaml`` is equivalent to the one above it because all
subscriptions automatically set ``__preset__`` as a `parent preset`_. 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: .. _source-variables:
@ -318,6 +349,12 @@ Source Variables
:inherited-members: :inherited-members:
:undoc-members: :undoc-members:
Override Variables
------------------
.. autoclass:: ytdl_sub.config.preset_options.OverridesVariables()
:members:
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
Config Types Config Types

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

View file

@ -26,3 +26,4 @@ Contents
getting_started getting_started
presets presets
config config
deprecation_notices

View file

@ -55,6 +55,7 @@ and overriding the following variables:
url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw" url: "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
# can be modified from their default value # can be modified from their default value
# tv_show_genre: "ytdl-sub" # tv_show_genre: "ytdl-sub"
# tv_show_content_rating: "TV-14"
# episode_title: "{upload_date_standardized} - {title}" # episode_title: "{upload_date_standardized} - {title}"
# episode_description: "{webpage_url}" # episode_description: "{webpage_url}"
@ -110,7 +111,7 @@ Season Presets
* ``collection_season_3`` * ``collection_season_3``
* ``collection_season_4`` * ``collection_season_4``
* ``...`` * ``...``
* ``collection_season_20`` * ``collection_season_40``
Example Example
""""""" """""""

View file

@ -64,7 +64,6 @@ presets:
# It is recommended to keep most of this as-is, and use override # It is recommended to keep most of this as-is, and use override
# variables to set them to be what you want. # variables to set them to be what you want.
music_tags: music_tags:
tags:
artist: "{track_artist}" artist: "{track_artist}"
artists: "{track_artist}" artists: "{track_artist}"
albumartist: "{track_artist}" albumartist: "{track_artist}"
@ -76,6 +75,9 @@ presets:
year: "{track_year}" year: "{track_year}"
genre: "{track_genre}" genre: "{track_genre}"
# Optionally embed the thumbnail into the track
embed_thumbnail: False
# For every configurable field, make it an override variable, # For every configurable field, make it an override variable,
# so we can carefully tune different use-cases by only modifying override variables. # so we can carefully tune different use-cases by only modifying override variables.
overrides: overrides:
@ -108,7 +110,6 @@ presets:
preset: "base" preset: "base"
download: download:
download_strategy: "url"
url: "{url}" url: "{url}"
#################################################################################################### ####################################################################################################
@ -164,8 +165,6 @@ presets:
# Download using the multi_url strategy # Download using the multi_url strategy
download: download:
download_strategy: "multi_url"
urls:
# The first URL will be all the artist's tracks. # The first URL will be all the artist's tracks.
# Treat these as singles - an album with a single track # Treat these as singles - an album with a single track
- url: "{sc_artist_url}/tracks" - url: "{sc_artist_url}/tracks"

View file

@ -18,12 +18,10 @@ configuration:
presets: presets:
music_video: music_video:
# Set the download details
download:
# We will only use a single URL to download music video(s). # We will only use a single URL to download music video(s).
# Make {url} an override variable to set later. # Make {url} an override variable to set later.
download_strategy: "url" download:
url: "{url}" - "{url}"
# For advanced YTDL users only; any YTDL parameter can be set here. # For advanced YTDL users only; any YTDL parameter can be set here.
# To download age-restricted videos, you will need to set your cookie # To download age-restricted videos, you will need to set your cookie

View file

@ -14,6 +14,7 @@ disable = [
"R0801", # similar lines "R0801", # similar lines
"R0913", # Too many arguments "R0913", # Too many arguments
"R0901", # too-many-ancestors "R0901", # too-many-ancestors
"R0902", # too-many-instance-attributes
"W0511", # TODO "W0511", # TODO
] ]

View file

@ -27,12 +27,12 @@ package_dir =
packages=find: packages=find:
install_requires = install_requires =
yt-dlp==2023.3.4 yt-dlp==2023.7.6
argparse==1.4.0 argparse==1.4.0
colorama==0.4.6, colorama==0.4.6
mergedeep==1.3.4 mergedeep==1.3.4
mediafile==0.10.1 mediafile==0.12.0
PyYAML==6.0 PyYAML==5.3.1
[options.package_data] [options.package_data]
* = *.yaml * = *.yaml

View file

@ -199,7 +199,7 @@ def _output_transaction_log(
transaction_log_file_contents = "" transaction_log_file_contents = ""
for subscription, transaction_log in transaction_logs: for subscription, transaction_log in transaction_logs:
if transaction_log.is_empty: 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: else:
transaction_log_contents = ( transaction_log_contents = (
f"Transaction log for {subscription.name}:\n" 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 and not config.config_options.experimental.enable_update_with_info_json
): ):
raise ExperimentalFeatureNotEnabled( raise ExperimentalFeatureNotEnabled(
"--update-with-info-json requires setting " "--update-with-info-json requires setting"
"configuration.experimental.update_with_info_json to True. This feature is ", " 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 ", "still being tested and has the ability to destroy files. Ensure you have a ",
"full backup before usage. You have been warned!", "full backup before usage. You have been warned!",
) )

View file

@ -95,7 +95,6 @@ class PersistLogsValidator(StrictDictValidator):
return self._keep_successful_logs.value return self._keep_successful_logs.value
# pylint: disable=too-many-instance-attributes
class ConfigOptions(StrictDictValidator): class ConfigOptions(StrictDictValidator):
_required_keys = {"working_directory"} _required_keys = {"working_directory"}
_optional_keys = { _optional_keys = {

View file

@ -14,3 +14,7 @@ else:
DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe" DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe"
MAX_FILE_NAME_BYTES = os.pathconf("/", "PC_NAME_MAX") 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"

View file

@ -1,17 +1,16 @@
from abc import ABC from abc import ABC
from abc import abstractmethod
from typing import Dict from typing import Dict
from typing import Generic from typing import Generic
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from typing import Type 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 Overrides
from ytdl_sub.config.preset_options import TOptionsValidator
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -24,7 +23,15 @@ class PluginPriority:
# If modify_entry priority is >= to this value, run after split # If modify_entry priority is >= to this value, run after split
MODIFY_ENTRY_AFTER_SPLIT = 10 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.modify_entry = modify_entry
self.post_process = post_process self.post_process = post_process
@ -38,34 +45,33 @@ class PluginPriority:
return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT
PluginOptions = OptionsValidator # pylint: disable=no-self-use,unused-argument
PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions)
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() priority: PluginPriority = PluginPriority()
plugin_options_type: Type[TOptionsValidator]
# If the plugin creates multiple entries from a single entry
is_split_plugin: bool = False
def __init__( def __init__(
self, self,
plugin_options: PluginOptionsT, options: TOptionsValidator,
overrides: Overrides, overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
): ):
DownloadArchiver.__init__(self=self, enhanced_download_archive=enhanced_download_archive) DownloadArchiver.__init__(self=self, enhanced_download_archive=enhanced_download_archive)
self.plugin_options = plugin_options self.plugin_options = options
self.overrides = overrides 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]: def ytdl_options(self) -> Optional[Dict]:
""" """
Returns Returns
@ -73,22 +79,6 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
ytdl options to enable/disable when downloading entries for this specific plugin 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]: def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
""" """
After entry metadata has been gathered, perform preprocessing on the metadata After entry metadata has been gathered, perform preprocessing on the metadata
@ -135,9 +125,30 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
""" """
return None return None
# pylint: enable=no-self-use,unused-argument
def post_process_subscription(self): def post_process_subscription(self):
""" """
After all downloaded files have been post-processed, apply a subscription-wide post process 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 []

View file

@ -11,17 +11,15 @@ from typing import Union
from mergedeep import mergedeep from mergedeep import mergedeep
from ytdl_sub.config.config_validator import ConfigValidator 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_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 OutputOptions
from ytdl_sub.config.preset_options import Overrides 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.config.preset_options import YTDLOptions
from ytdl_sub.downloaders.base_downloader import BaseDownloader from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
from ytdl_sub.entries.entry import Entry 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 PREBUILT_PRESET_NAMES
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
from ytdl_sub.utils.exceptions import ValidationException 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 DictValidator
from ytdl_sub.validators.validators import ListValidator from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.validators.validators import StringListValidator 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 Validator
from ytdl_sub.validators.validators import validation_exception from ytdl_sub.validators.validators import validation_exception
PRESET_KEYS = { PRESET_KEYS = {
"preset", "preset",
"download",
"output_options", "output_options",
"ytdl_options", "ytdl_options",
"overrides", "overrides",
*DownloadStrategyMapping.sources(),
*PluginMapping.plugins(), *PluginMapping.plugins(),
} }
@ -67,9 +64,9 @@ def _parent_preset_error_message(
class PresetPlugins: class PresetPlugins:
def __init__(self): def __init__(self):
self.plugin_types: List[Type[Plugin]] = [] 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 Add a pair of plugin type and options to the list
""" """
@ -77,7 +74,7 @@ class PresetPlugins:
self.plugin_options.append(plugin_options) self.plugin_options.append(plugin_options)
return self return self
def zipped(self) -> Iterable[Tuple[Type[Plugin], PluginOptions]]: def zipped(self) -> Iterable[Tuple[Type[Plugin], OptionsValidator]]:
""" """
Returns Returns
------- -------
@ -85,7 +82,7 @@ class PresetPlugins:
""" """
return zip(self.plugin_types, self.plugin_options) 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 Parameters
---------- ----------
@ -102,49 +99,6 @@ class PresetPlugins:
return None 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): class _PresetShell(StrictDictValidator):
# Have all present keys optional since parent presets could not have all the # 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 # required keys. They will get validated in the init after the mergedeep of dicts
@ -153,70 +107,6 @@ class _PresetShell(StrictDictValidator):
class Preset(_PresetShell): 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 @classmethod
def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None: def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None:
""" """
@ -251,9 +141,7 @@ class Preset(_PresetShell):
presets=config.presets.keys, presets=config.presets.keys,
) )
cls._validate_download_strategy( cls._partial_validate_key(name, value, "download", MultiUrlValidator)
config=config, name=name, value=value, parent_presets=parent_presets
)
cls._partial_validate_key(name, value, "output_options", OutputOptions) cls._partial_validate_key(name, value, "output_options", OutputOptions)
cls._partial_validate_key(name, value, "ytdl_options", YTDLOptions) cls._partial_validate_key(name, value, "ytdl_options", YTDLOptions)
cls._partial_validate_key(name, value, "overrides", Overrides) cls._partial_validate_key(name, value, "overrides", Overrides)
@ -270,55 +158,6 @@ class Preset(_PresetShell):
def _source_variables(self) -> List[str]: def _source_variables(self) -> List[str]:
return Entry.source_variables() 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: def __validate_and_get_plugins(self) -> PresetPlugins:
preset_plugins = PresetPlugins() preset_plugins = PresetPlugins()
@ -477,7 +316,9 @@ class Preset(_PresetShell):
# Perform the merge of parent presets before validating any keys # Perform the merge of parent presets before validating any keys
self.__merge_parent_preset_dicts_if_present(config=config) 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( self.output_options = self._validate_key(
key="output_options", key="output_options",

View file

@ -2,102 +2,23 @@ from typing import Dict
from typing import List from typing import List
from typing import Type from typing import Type
from ytdl_sub.downloaders.base_downloader import BaseDownloader from ytdl_sub.config.plugin import Plugin
from ytdl_sub.downloaders.url.multi_url import MultiUrlDownloader
from ytdl_sub.downloaders.url.url import UrlDownloader
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
from ytdl_sub.plugins.chapters import ChaptersPlugin from ytdl_sub.plugins.chapters import ChaptersPlugin
from ytdl_sub.plugins.date_range import DateRangePlugin 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.file_convert import FileConvertPlugin
from ytdl_sub.plugins.internal.view import ViewPlugin from ytdl_sub.plugins.internal.view import ViewPlugin
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
from ytdl_sub.plugins.music_tags import MusicTagsPlugin from ytdl_sub.plugins.music_tags import MusicTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin 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.regex import RegexPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin 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: class PluginMapping:
""" """
Maps plugins defined in the preset to its respective plugin class Maps plugins defined in the preset to its respective plugin class
@ -107,6 +28,7 @@ class PluginMapping:
"_view": ViewPlugin, "_view": ViewPlugin,
"audio_extract": AudioExtractPlugin, "audio_extract": AudioExtractPlugin,
"date_range": DateRangePlugin, "date_range": DateRangePlugin,
"embed_thumbnail": EmbedThumbnailPlugin,
"file_convert": FileConvertPlugin, "file_convert": FileConvertPlugin,
"match_filters": MatchFiltersPlugin, "match_filters": MatchFiltersPlugin,
"music_tags": MusicTagsPlugin, "music_tags": MusicTagsPlugin,

View file

@ -3,9 +3,11 @@ from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import TypeVar
from yt_dlp.utils import sanitize_filename 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.entries.entry import Entry
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator 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.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import Validator
# pylint: disable=no-self-use # pylint: disable=no-self-use
# pylint: disable=unused-argument # pylint: disable=unused-argument
class OptionsValidator(StrictDictValidator, ABC): class OptionsValidator(Validator, ABC):
""" """
Abstract class that validates options for preset sections (plugins, downloaders) Abstract class that validates options for preset sections (plugins, downloaders)
""" """
@ -69,6 +72,13 @@ class OptionsValidator(StrictDictValidator, ABC):
return None return None
TOptionsValidator = TypeVar("TOptionsValidator", bound=OptionsValidator)
class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC):
pass
# pylint: enable=no-self-use # pylint: enable=no-self-use
# pylint: enable=unused-argument # 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 # Disable for proper docstring formatting
# pylint: disable=line-too-long # 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. 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 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 # 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): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
@ -209,6 +245,7 @@ class OutputOptions(StrictDictValidator):
# optional # optional
thumbnail_name: "{title_sanitized}.{thumbnail_ext}" thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
info_json_name: "{title_sanitized}.{info_json_ext}" info_json_name: "{title_sanitized}.{info_json_ext}"
download_archive_name: ".ytdl-sub-{subscription_name}-download-archive.json"
maintain_download_archive: True maintain_download_archive: True
keep_files_before: now keep_files_before: now
keep_files_after: 19000101 keep_files_after: 19000101
@ -218,7 +255,7 @@ class OutputOptions(StrictDictValidator):
_optional_keys = { _optional_keys = {
"thumbnail_name", "thumbnail_name",
"info_json_name", "info_json_name",
"subtitles_name", "download_archive_name",
"maintain_download_archive", "maintain_download_archive",
"keep_files_before", "keep_files_before",
"keep_files_after", "keep_files_after",
@ -256,6 +293,12 @@ class OutputOptions(StrictDictValidator):
key="info_json_name", validator=StringFormatterFileNameValidator 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( self._maintain_download_archive = self._validate_key_if_present(
key="maintain_download_archive", validator=BoolValidator, default=False key="maintain_download_archive", validator=BoolValidator, default=False
) )
@ -307,6 +350,14 @@ class OutputOptions(StrictDictValidator):
""" """
return self._info_json_name 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 @property
def maintain_download_archive(self) -> bool: def maintain_download_archive(self) -> bool:
""" """

View file

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

View file

@ -5,10 +5,9 @@ from typing import Dict
from typing import Iterable from typing import Iterable
from typing import List from typing import List
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.base_downloader import BaseDownloader from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.base_downloader import BaseDownloaderOptionsT
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import ValidationException 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 from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
class InfoJsonDownloaderOptions(BaseDownloaderValidator): class InfoJsonDownloaderOptions(OptionsDictValidator):
_optional_keys = {"no-op"} _optional_keys = {"no-op"}
class InfoJsonDownloader(BaseDownloader[InfoJsonDownloaderOptions]): class InfoJsonDownloader(SourcePlugin[InfoJsonDownloaderOptions]):
downloader_options_type = InfoJsonDownloaderOptions plugin_options_type = InfoJsonDownloaderOptions
def __init__( def __init__(
self, self,
download_options: BaseDownloaderOptionsT, options: InfoJsonDownloaderOptions,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
download_ytdl_options: YTDLOptionsBuilder, download_ytdl_options: YTDLOptionsBuilder,
metadata_ytdl_options: YTDLOptionsBuilder, metadata_ytdl_options: YTDLOptionsBuilder,
overrides: Overrides, overrides: Overrides,
): ):
super().__init__( super().__init__(
download_options=download_options, options=options,
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
download_ytdl_options=download_ytdl_options, download_ytdl_options=download_ytdl_options,
metadata_ytdl_options=metadata_ytdl_options, metadata_ytdl_options=metadata_ytdl_options,

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

View file

@ -1,7 +1,5 @@
import abc
import contextlib import contextlib
import os import os
from abc import ABC
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from typing import Iterable from typing import Iterable
@ -11,11 +9,12 @@ from typing import Optional
from typing import Set from typing import Set
from typing import Tuple 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.config.preset_options import Overrides
from ytdl_sub.downloaders.base_downloader import BaseDownloader from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.base_downloader import BaseDownloaderOptionsT from ytdl_sub.downloaders.source_plugin import SourcePluginExtension
from ytdl_sub.downloaders.base_downloader import BaseDownloaderPlugin
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
from ytdl_sub.downloaders.url.validators import MultiUrlValidator from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.downloaders.url.validators import UrlThumbnailListValidator from ytdl_sub.downloaders.url.validators import UrlThumbnailListValidator
from ytdl_sub.downloaders.url.validators import UrlValidator 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 SOURCE_ENTRY
from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS
from ytdl_sub.entries.variables.kwargs import UPLOAD_DATE_INDEX 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.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.thumbnail import ThumbnailTypes 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 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 from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
# pylint: disable=too-many-instance-attributes
download_logger = Logger.get(name="downloader") 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: class URLDownloadState:
def __init__(self, entries_total: int): def __init__(self, entries_total: int):
self.entries_total = entries_total self.entries_total = entries_total
self.entries_downloaded = 0 self.entries_downloaded = 0
class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin): class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
priority = PluginPriority(modify_entry=0)
def __init__( def __init__(
self, self,
downloader_options: DownloaderValidator, options: MultiUrlValidator,
overrides: Overrides, overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
): ):
super().__init__( super().__init__(
downloader_options=downloader_options, options=options,
overrides=overrides, overrides=overrides,
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
) )
self._thumbnails_downloaded: Set[str] = set() self._thumbnails_downloaded: Set[str] = set()
self._collection_url_mapping: Dict[str, UrlValidator] = { self._collection_url_mapping: Dict[str, UrlValidator] = {
self.overrides.apply_formatter(collection_url.url): collection_url 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( def _download_parent_thumbnails(
@ -117,11 +82,9 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
# If latest entry, always update the thumbnail on each entry # If latest entry, always update the thumbnail on each entry
if thumbnail_id == ThumbnailTypes.LATEST_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... # 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( self.save_file(
file_name=entry.get_download_thumbnail_name(), file_name=entry.get_download_thumbnail_name(),
output_file_name=thumbnail_name, output_file_name=thumbnail_name,
@ -173,8 +136,14 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
def modify_entry(self, entry: Entry) -> Optional[Entry]: 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: if entry.kwargs_get(COLLECTION_URL) in self._collection_url_mapping:
self._download_url_thumbnails( self._download_url_thumbnails(
collection_url=self._collection_url_mapping[entry.kwargs(COLLECTION_URL)], collection_url=self._collection_url_mapping[entry.kwargs(COLLECTION_URL)],
@ -183,22 +152,24 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
return entry return entry
class UrlDownloaderCollectionVariablePlugin(BaseDownloaderPlugin): class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension):
priority = PluginPriority(modify_entry_metadata=0)
def __init__( def __init__(
self, self,
downloader_options: DownloaderValidator, options: MultiUrlValidator,
overrides: Overrides, overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
): ):
super().__init__( super().__init__(
downloader_options=downloader_options, options=options,
overrides=overrides, overrides=overrides,
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
) )
self._thumbnails_downloaded: Set[str] = set() self._thumbnails_downloaded: Set[str] = set()
self._collection_url_mapping: Dict[str, UrlValidator] = { self._collection_url_mapping: Dict[str, UrlValidator] = {
self.overrides.apply_formatter(collection_url.url): collection_url 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]: def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
@ -220,36 +191,14 @@ class UrlDownloaderCollectionVariablePlugin(BaseDownloaderPlugin):
return entry return entry
class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC): class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
""" """
Class that interacts with ytdl to perform the download of metadata and content, Class that interacts with ytdl to perform the download of metadata and content,
and should translate that to list of Entry objects. and should translate that to list of Entry objects.
""" """
@classmethod plugin_options_type = MultiUrlValidator
def added_plugins( plugin_extensions = [UrlDownloaderThumbnailPlugin, UrlDownloaderCollectionVariablePlugin]
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,
),
]
@classmethod @classmethod
def ytdl_option_defaults(cls) -> Dict: def ytdl_option_defaults(cls) -> Dict:
@ -263,7 +212,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
def __init__( def __init__(
self, self,
download_options: BaseDownloaderOptionsT, options: MultiUrlValidator,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
download_ytdl_options: YTDLOptionsBuilder, download_ytdl_options: YTDLOptionsBuilder,
metadata_ytdl_options: YTDLOptionsBuilder, metadata_ytdl_options: YTDLOptionsBuilder,
@ -272,7 +221,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
""" """
Parameters Parameters
---------- ----------
download_options options
Options validator for this downloader Options validator for this downloader
enhanced_download_archive enhanced_download_archive
Download archive Download archive
@ -284,7 +233,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
Override variables Override variables
""" """
super().__init__( super().__init__(
download_options=download_options, options=options,
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
download_ytdl_options=download_ytdl_options, download_ytdl_options=download_ytdl_options,
metadata_ytdl_options=metadata_ytdl_options, metadata_ytdl_options=metadata_ytdl_options,
@ -349,7 +298,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
@property @property
def collection(self) -> MultiUrlValidator: def collection(self) -> MultiUrlValidator:
"""Return the download options collection""" """Return the download options collection"""
return self.download_options.collection_validator return self.plugin_options
@contextlib.contextmanager @contextlib.contextmanager
def _separate_download_archives(self, clear_info_json_files: bool = False): 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_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
is_thumbnail_downloaded_fn=None is_thumbnail_downloaded_fn=None
if (self.is_dry_run or not self.is_entry_thumbnails_enabled) if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
else entry.is_thumbnail_downloaded, else entry.is_thumbnail_downloaded_via_ytdlp,
url=entry.webpage_url, url=entry.webpage_url,
) )
return Entry(download_entry_dict, working_directory=self.working_directory) return Entry(download_entry_dict, working_directory=self.working_directory)
@ -523,6 +472,11 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
Returns Returns
------- -------
The entry that was downloaded successfully The entry that was downloaded successfully
Raises
------
RejectedVideoReached
If a video was rejected and was not from match_filter
""" """
download_logger.info( download_logger.info(
"Downloading entry %d/%d: %s", "Downloading entry %d/%d: %s",
@ -530,7 +484,17 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
self._url_state.entries_total, self._url_state.entries_total,
entry.title, entry.title,
) )
# 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) 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_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date(
upload_date_standardized=entry.upload_date_standardized upload_date_standardized=entry.upload_date_standardized

View file

@ -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 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 Downloads from multiple URLs. If an entry is returned from more than one URL, it will
resolve to the bottom-most URL settings. resolve to the bottom-most URL settings.
@ -19,7 +15,6 @@ class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
my_example_preset: my_example_preset:
download: download:
# required # required
download_strategy: "multi_url"
urls: urls:
- url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg" - url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
variables: variables:
@ -40,29 +35,3 @@ class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
- name: "season{season_index}-poster.jpg" - name: "season{season_index}-poster.jpg"
uid: "latest_entry" 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

View file

@ -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 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. Downloads from a single URL supported by yt-dlp.
@ -16,7 +14,6 @@ class UrlDownloadOptions(UrlValidator, DownloaderValidator):
my_example_preset: my_example_preset:
download: download:
# required # required
download_strategy: "url"
url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg" url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
# optional # optional
playlist_thumbnails: playlist_thumbnails:
@ -26,15 +23,3 @@ class UrlDownloadOptions(UrlValidator, DownloaderValidator):
uid: "banner_uncropped" uid: "banner_uncropped"
download_reverse: True 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

View file

@ -1,3 +1,4 @@
import copy
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -75,9 +76,7 @@ class UrlValidator(StrictDictValidator):
@property @property
def url(self) -> OverridesStringFormatterValidator: def url(self) -> OverridesStringFormatterValidator:
""" """
Required. URL to download from, listed in priority from lowest (top) to highest (bottom). Required. URL to download from.
If a download exists in more than one URL, it will resolve to the bottom-most one and
inherit those variables.
""" """
return self._url return self._url
@ -147,8 +146,27 @@ class UrlValidator(StrictDictValidator):
return self._download_reverse.value return self._download_reverse.value
class UrlListValidator(ListValidator[UrlValidator]): class UrlStringOrDictValidator(UrlValidator):
_inner_list_type = 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" _expected_value_type_name = "collection url list"
def __init__(self, name, value): def __init__(self, name, value):
@ -180,25 +198,38 @@ class MultiUrlValidator(OptionsValidator):
resolve to the bottom-most URL settings. resolve to the bottom-most URL settings.
""" """
_required_keys = {"urls"}
@classmethod @classmethod
def partial_validate(cls, name: str, value: Any) -> None: def partial_validate(cls, name: str, value: Any) -> None:
""" """
Partially validate a collection Partially validate a collection
""" """
if isinstance(value, dict): if isinstance(value, dict):
value["urls"] = value.get("urls", [{"url": "placeholder"}]) value["url"] = value.get("url", "sadfasdf")
_ = cls(name, value) _ = cls(name, value)
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(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 @property
def urls(self) -> UrlListValidator: def urls(self) -> UrlListValidator:
""" """
Required. A list of :ref:`url` with the addition of the ``variables`` attribute. 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 return self._urls
@ -246,3 +277,11 @@ class MultiUrlValidator(OptionsValidator):
_ = StringFormatterValidator( _ = StringFormatterValidator(
name=f"{self._name}.{source_var_name}", value=source_var_formatter_str name=f"{self._name}.{source_var_name}", value=source_var_formatter_str
).apply_formatter(base_variables) ).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")

View file

@ -86,10 +86,12 @@ class YTDLP:
If the entry fails to download If the entry fails to download
""" """
num_tries = 0 num_tries = 0
entry_files_exist = False
copied_ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides) 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( entry_dict = cls.extract_info(
ytdl_options_overrides=copied_ytdl_options_overrides, **kwargs ytdl_options_overrides=copied_ytdl_options_overrides, **kwargs
) )
@ -102,6 +104,10 @@ class YTDLP:
if is_downloaded and is_thumbnail_downloaded: if is_downloaded and is_thumbnail_downloaded:
return entry_dict 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 # If the video file is downloaded but the thumbnail is not, then do not download
# the video again # the video again
if is_downloaded and not is_thumbnail_downloaded: if is_downloaded and not is_thumbnail_downloaded:
@ -111,7 +117,7 @@ class YTDLP:
time.sleep(cls._EXTRACT_ENTRY_RETRY_WAIT_SEC) time.sleep(cls._EXTRACT_ENTRY_RETRY_WAIT_SEC)
num_tries += 1 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 # even though it is not
if "download_archive" in copied_ytdl_options_overrides: if "download_archive" in copied_ytdl_options_overrides:
del copied_ytdl_options_overrides["download_archive"] del copied_ytdl_options_overrides["download_archive"]
@ -123,6 +129,10 @@ class YTDLP:
cls._EXTRACT_ENTRY_NUM_RETRIES, 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} error_dict = {"ytdl_options": ytdl_options_overrides, "kwargs": kwargs}
raise FileNotDownloadedException( raise FileNotDownloadedException(
f"yt-dlp failed to download an entry with these arguments: {error_dict}" f"yt-dlp failed to download an entry with these arguments: {error_dict}"

View file

@ -54,7 +54,7 @@ class Entry(EntryVariables, BaseEntry):
"""Returns the entry's thumbnail's file path to where it was downloaded""" """Returns the entry's thumbnail's file path to where it was downloaded"""
return str(Path(self.working_directory()) / self.get_download_thumbnail_name()) 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 The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
not match. Return the actual downloaded thumbnail path. 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: with open(self.get_download_info_json_path(), "w", encoding="utf-8") as file:
file.write(kwargs_json) 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 @final
def is_thumbnail_downloaded(self) -> bool: def is_thumbnail_downloaded(self) -> bool:
""" """
Returns 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 @final
def is_downloaded(self) -> bool: def is_downloaded(self) -> bool:

View file

@ -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_ID
from ytdl_sub.entries.variables.kwargs import PLAYLIST_UPLOADER_URL 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 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_COUNT
from ytdl_sub.entries.variables.kwargs import SOURCE_DESCRIPTION from ytdl_sub.entries.variables.kwargs import SOURCE_DESCRIPTION
from ytdl_sub.entries.variables.kwargs import SOURCE_INDEX from ytdl_sub.entries.variables.kwargs import SOURCE_INDEX
@ -668,3 +669,189 @@ class EntryVariables(BaseEntryVariables):
The uploaded date formatted as YYYY-MM-DD The uploaded date formatted as YYYY-MM-DD
""" """
return f"{self.upload_year}-{self.upload_month_padded}-{self.upload_day_padded}" 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}"

View file

@ -46,6 +46,8 @@ UPLOAD_DATE_INDEX = _("upload_date_index", backend=True)
REQUESTED_SUBTITLES = _("requested_subtitles", backend=True) REQUESTED_SUBTITLES = _("requested_subtitles", backend=True)
CHAPTERS = _("chapters", backend=True) CHAPTERS = _("chapters", backend=True)
YTDL_SUB_CUSTOM_CHAPTERS = _("ytdl_sub_custom_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) SPONSORBLOCK_CHAPTERS = _("sponsorblock_chapters", backend=True)
SPLIT_BY_CHAPTERS_PARENT_ENTRY = _("split_by_chapters_parent_entry", backend=True) SPLIT_BY_CHAPTERS_PARENT_ENTRY = _("split_by_chapters_parent_entry", backend=True)
COMMENTS = _("comments", backend=True) COMMENTS = _("comments", backend=True)
@ -60,6 +62,7 @@ EXT = _("ext")
TITLE = _("title") TITLE = _("title")
DESCRIPTION = _("description") DESCRIPTION = _("description")
WEBPAGE_URL = _("webpage_url") WEBPAGE_URL = _("webpage_url")
RELEASE_DATE = _("release_date")
UPLOAD_DATE = _("upload_date") UPLOAD_DATE = _("upload_date")
UPLOADER = _("uploader") UPLOADER = _("uploader")
UPLOADER_ID = _("uploader_id") UPLOADER_ID = _("uploader_id")

View file

@ -3,17 +3,17 @@ from typing import Any
from typing import Dict from typing import Dict
from typing import Optional 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.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry 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.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 AUDIO_CODEC_TYPES_EXTENSION_MAPPING
from ytdl_sub.validators.audo_codec_validator import AudioTypeValidator from ytdl_sub.validators.audo_codec_validator import AudioTypeValidator
from ytdl_sub.validators.validators import FloatValidator from ytdl_sub.validators.validators import FloatValidator
class AudioExtractOptions(PluginOptions): class AudioExtractOptions(OptionsDictValidator):
""" """
Extracts audio from a video file. Extracts audio from a video file.

View file

@ -5,12 +5,12 @@ from typing import List
from typing import Optional from typing import Optional
from typing import Set 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.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.kwargs import COMMENTS from ytdl_sub.entries.variables.kwargs import COMMENTS
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_CUSTOM_CHAPTERS 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.chapters import Chapters
from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
@ -58,7 +58,7 @@ class SponsorBlockCategoryListValidator(ListValidator[SponsorBlockCategoriesVali
_inner_list_type = SponsorBlockCategoriesValidator _inner_list_type = SponsorBlockCategoriesValidator
class ChaptersOptions(PluginOptions): class ChaptersOptions(OptionsDictValidator):
""" """
Embeds chapters to video files if they are present. Additional options to add SponsorBlock 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. chapters and remove specific ones. Can also remove chapters using regex.
@ -302,13 +302,16 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
""" """
chapters = Chapters.from_empty() 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: 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, []): for comment in entry.kwargs_get(COMMENTS, []):
chapters = Chapters.from_string(comment.get("text", "")) chapters = Chapters.from_string(comment.get("text", ""))
if not chapters.is_empty(): if chapters.contains_any_chapters():
break break
if not chapters.is_empty(): # 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()}) entry.add_kwargs({YTDL_SUB_CUSTOM_CHAPTERS: chapters.to_file_metadata_dict()})
if not self.is_dry_run: if not self.is_dry_run:

View file

@ -1,14 +1,14 @@
from typing import Dict from typing import Dict
from typing import Optional 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.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.utils.datetime import to_date_range
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
class DateRangeOptions(PluginOptions): class DateRangeOptions(OptionsDictValidator):
""" """
Only download files uploaded within the specified date range. Only download files uploaded within the specified date range.

View 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")

View file

@ -3,11 +3,11 @@ from typing import Any
from typing import Dict from typing import Dict
from typing import Optional 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.entry import Entry
from ytdl_sub.entries.variables.kwargs import EXT 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 FileNotDownloadedException
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.ffmpeg import FFMPEG
@ -22,7 +22,7 @@ class FileConvertWithValidator(StringSelectValidator):
_select_values = {"yt-dlp", "ffmpeg"} _select_values = {"yt-dlp", "ffmpeg"}
class FileConvertOptions(PluginOptions): class FileConvertOptions(OptionsDictValidator):
""" """
Converts video files from one extension to another. Converts video files from one extension to another.

View file

@ -1,17 +1,16 @@
import copy import copy
from typing import Optional 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.config.preset_options import Overrides
from ytdl_sub.entries.entry import Entry 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.utils.file_handler import FileMetadata
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
class ViewOptions(PluginOptions): class ViewOptions(OptionsDictValidator):
""" """
INTERNAL PLUGIN. Do not expose in documentation INTERNAL PLUGIN. Do not expose in documentation
""" """
@ -27,11 +26,15 @@ class ViewPlugin(Plugin[ViewOptions]):
def __init__( def __init__(
self, self,
plugin_options: OutputDirectoryNfoTagsOptions, options: ViewOptions,
overrides: Overrides, overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive, 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 self._first_entry: Optional[Entry] = None
@classmethod @classmethod

View file

@ -5,15 +5,18 @@ from typing import Optional
from yt_dlp import match_filter_func from yt_dlp import match_filter_func
from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.config.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions 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.utils.logger import Logger
from ytdl_sub.validators.validators import StringListValidator from ytdl_sub.validators.validators import StringListValidator
logger = Logger.get("match_filters") 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. Set ``--match-filters``` to pass into yt-dlp to filter entries from being downloaded.
Uses the same syntax as yt-dlp. Uses the same syntax as yt-dlp.
@ -35,8 +38,12 @@ class MatchFiltersOptions(PluginOptions):
my_example_preset: my_example_preset:
match_filters: match_filters:
filters: filters:
- "original_url!*=/shorts/" - "age_limit<?18"
- "!is_live" - "like_count>?100"
# Other common match-filters
# - "original_url!*=/shorts/ & !is_live"
# - "age_limit<?18"
# - "availability=?public"
""" """
_required_keys = {"filters"} _required_keys = {"filters"}
@ -56,13 +63,15 @@ class MatchFiltersOptions(PluginOptions):
def filters(self) -> List[str]: def filters(self) -> List[str]:
""" """
The filters themselves. If used multiple times, the filter matches if at least one of the 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] return [validator.value for validator in self._filters]
class MatchFiltersPlugin(Plugin[MatchFiltersOptions]): class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
plugin_options_type = MatchFiltersOptions plugin_options_type = MatchFiltersOptions
priority = PluginPriority(modify_entry=PluginPriority.MODIFY_ENTRY_FIRST)
def ytdl_options(self) -> Optional[Dict]: def ytdl_options(self) -> Optional[Dict]:
""" """
@ -75,4 +84,17 @@ class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
logger.debug("Adding match-filter %s", filter_str) logger.debug("Adding match-filter %s", filter_str)
match_filters.append(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

View file

@ -1,3 +1,4 @@
import copy
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from typing import Dict from typing import Dict
@ -5,12 +6,11 @@ from typing import List
import mediafile 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.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.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger 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.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
@ -45,7 +45,7 @@ class MusicTagsValidator(StrictDictValidator):
return self._tags return self._tags
class MusicTagsOptions(PluginOptions): class MusicTagsOptions(OptionsDictValidator):
""" """
Adds tags to every download audio file using Adds tags to every download audio file using
`MediaFile <https://mediafile.readthedocs.io/en/latest/>`_, `MediaFile <https://mediafile.readthedocs.io/en/latest/>`_,
@ -65,34 +65,31 @@ class MusicTagsOptions(PluginOptions):
tags: tags:
artist: "{artist}" artist: "{artist}"
album: "{album}" album: "{album}"
genre: "ytdl-sub"
# Supports id3v2.4 multi-tags # Supports id3v2.4 multi-tags
genres:
- "{genre}"
- "ytdl-sub"
albumartists: albumartists:
- "{artist}" - "{artist}"
- "ytdl-sub" - "ytdl-sub"
# Optional
embed_thumbnail: False
""" """
_required_keys = {"tags"} _optional_keys = {"tags", "embed_thumbnail"}
_optional_keys = {"embed_thumbnail"} _allow_extra_keys = True
@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)
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._tags = self._validate_key(key="tags", validator=MusicTagsValidator)
self._embed_thumbnail = self._validate_key_if_present( self._embed_thumbnail = self._validate_key_if_present(
key="embed_thumbnail", validator=BoolValidator, default=False key="embed_thumbnail", validator=BoolValidator
).value )
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 @property
def tags(self) -> MusicTagsValidator: def tags(self) -> MusicTagsValidator:
@ -107,7 +104,9 @@ class MusicTagsOptions(PluginOptions):
""" """
Optional. Whether to embed the thumbnail into the audio file. 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]): class MusicTagsPlugin(Plugin[MusicTagsOptions]):
@ -124,6 +123,20 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
f"to audio using the audio_extract plugin." 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 # Resolve the tags into this dict
tags_to_write: Dict[str, List[str]] = defaultdict(list) tags_to_write: Dict[str, List[str]] = defaultdict(list)
for tag_name, tag_formatters in self.plugin_options.tags.as_lists.items(): 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]) setattr(audio_file, tag_name, tag_value[0])
if self.plugin_options.embed_thumbnail: if self.plugin_options.embed_thumbnail and entry.is_thumbnail_downloaded():
# convert the entry thumbnail so it is embedded as jpg
convert_download_thumbnail(entry=entry)
with open(entry.get_download_thumbnail_path(), "rb") as thumb: with open(entry.get_download_thumbnail_path(), "rb") as thumb:
mediafile_img = mediafile.Image( mediafile_img = mediafile.Image(
data=thumb.read(), desc="cover", type=mediafile.ImageType.front data=thumb.read(), desc="cover", type=mediafile.ImageType.front

View file

@ -7,9 +7,9 @@ from typing import Dict
from typing import List from typing import List
from typing import Optional 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.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 FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.xml import XmlElement 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 from ytdl_sub.validators.validators import BoolValidator
class SharedNfoTagsOptions(PluginOptions): class SharedNfoTagsOptions(OptionsDictValidator):
""" """
Shared code between NFO tags and Ouptut Directory NFO Tags Shared code between NFO tags and Ouptut Directory NFO Tags
""" """

View file

@ -93,11 +93,11 @@ class OutputDirectoryNfoTagsPlugin(SharedNfoTagsPlugin):
def __init__( def __init__(
self, self,
plugin_options: OutputDirectoryNfoTagsOptions, options: OutputDirectoryNfoTagsOptions,
overrides: Overrides, overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive, 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 self._last_entry: Optional[Entry] = None
def post_process_entry(self, entry: Entry) -> None: def post_process_entry(self, entry: Entry) -> None:

View file

@ -5,11 +5,13 @@ from typing import Optional
from yt_dlp.utils import sanitize_filename 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.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.entries.variables.kwargs import YTDL_SUB_REGEX_SOURCE_VARS
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.plugins.plugin import PluginPriority
from ytdl_sub.utils.exceptions import RegexNoMatchException from ytdl_sub.utils.exceptions import RegexNoMatchException
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.regex_validator import RegexListValidator from ytdl_sub.validators.regex_validator import RegexListValidator
from ytdl_sub.validators.source_variable_validator import SourceVariableNameListValidator 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") logger = Logger.get(name="regex")
class SourceVariableRegex(StrictDictValidator): class VariableRegex(StrictDictValidator):
_required_keys = {"match"} _optional_keys = {"match", "exclude", "capture_group_defaults", "capture_group_names"}
_optional_keys = {"capture_group_defaults", "capture_group_names"}
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(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( self._capture_group_defaults = self._validate_key_if_present(
key="capture_group_defaults", validator=ListFormatterValidator key="capture_group_defaults", validator=ListFormatterValidator
) )
@ -36,6 +38,14 @@ class SourceVariableRegex(StrictDictValidator):
key="capture_group_names", validator=SourceVariableNameListValidator, default=[] 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 # If defaults are to be used, ensure there are the same number of defaults as there are
# capture groups # capture groups
if self._capture_group_defaults is not None and self._match.num_capture_groups != len( 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 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( raise self._validation_exception(
f"number of capture group names must match number of capture groups, " f"number of capture group names must match number of capture groups, "
f"{len(self._capture_group_names.list)} != {self._match.num_capture_groups}" f"{len(self._capture_group_names.list)} != {self._match.num_capture_groups}"
) )
@property @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. string must have the same number of capture groups.
""" """
return self._match 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 @property
def capture_group_names(self) -> Optional[List[str]]: def capture_group_names(self) -> Optional[List[str]]:
""" """
@ -96,19 +116,25 @@ class FromSourceVariablesRegex(StrictDictValidator):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self.source_variable_capture_dict: Dict[str, SourceVariableRegex] = { self.variable_capture_dict: Dict[str, VariableRegex] = {
key: self._validate_key(key=key, validator=SourceVariableRegex) for key in self._keys key: self._validate_key(key=key, validator=VariableRegex) for key in self._keys
} }
class RegexOptions(PluginOptions): class RegexOptions(OptionsDictValidator):
r""" r"""
Performs regex matching on an entry's source variables. Regex can be used to filter entries Performs regex matching on an entry's source or override variables. Regex can be used to filter
from proceeding with download or capture groups to create new source variables. NOTE to 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 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 means ``\d`` should be written as ``\\d``. This is because YAML requires an escape for any
backslash usage. 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: Usage:
.. code-block:: yaml .. code-block:: yaml
@ -137,6 +163,9 @@ class RegexOptions(PluginOptions):
# source variables # source variables
match: 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, # Each capture group creates these new source variables, respectively,
# as well a sanitized version, i.e. `captured_upload_year_sanitized` # as well a sanitized version, i.e. `captured_upload_year_sanitized`
@ -195,9 +224,9 @@ class RegexOptions(PluginOptions):
""" """
for key, regex_options in self.source_variable_capture_dict.items(): for key, regex_options in self.source_variable_capture_dict.items():
# Ensure each variable getting captured is a source variable # 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( 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 # Ensure the capture group names are not existing source/override variables
@ -214,13 +243,13 @@ class RegexOptions(PluginOptions):
) )
@property @property
def source_variable_capture_dict(self) -> Dict[str, SourceVariableRegex]: def source_variable_capture_dict(self) -> Dict[str, VariableRegex]:
""" """
Returns Returns
------- -------
Dict of { source variable: capture options } 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]: def added_source_variables(self) -> List[str]:
""" """
@ -245,12 +274,66 @@ class RegexPlugin(Plugin[RegexOptions]):
modify_entry=PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + 0, 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 Parameters
---------- ----------
entry entry
Entry to add source variables to Entry to add source variables to
is_metadata_stage
Whether this is called at the metadata stage or modify stage
Returns Returns
------- -------
@ -261,33 +344,50 @@ class RegexPlugin(Plugin[RegexOptions]):
ValidationException ValidationException
If no capture and no defaults If no capture and no defaults
""" """
entry_variable_dict = entry.to_dict()
# Iterate each source var to capture and add to the entry # 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(): for (
maybe_capture = regex_options.match.match_any(input_str=entry_variable_dict[source_var]) variable_name,
regex_options,
) in self.plugin_options.source_variable_capture_dict.items():
# If no capture # 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
# 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: if maybe_capture is None:
# and no defaults # and no defaults
if not regex_options.has_defaults: if not regex_options.has_defaults:
# Skip the entry if toggled return self._try_skip_entry(entry=entry, variable_name=variable_name)
if self.plugin_options.skip_if_match_fails:
logger.info(
"Regex failed to match '%s' from '%s', skipping.",
source_var,
entry.title,
)
return None
# Otherwise, error
raise RegexNoMatchException(
f"Regex failed to match '{source_var}' from '{entry.title}'"
)
# otherwise, use defaults (apply them using the original entry source dict) # otherwise, use defaults (apply them using the original entry source dict)
source_variables_and_overrides_dict = dict( source_variables_and_overrides_dict = dict(
entry_variable_dict, **self.overrides.dict_with_format_strings entry.to_dict(), **self.overrides.dict_with_format_strings
) )
# add both the default... # add both the default...
@ -331,3 +431,15 @@ class RegexPlugin(Plugin[RegexOptions]):
) )
return entry 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)

View file

@ -1,5 +1,4 @@
import copy import copy
import os.path
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from typing import List from typing import List
@ -8,20 +7,19 @@ from typing import Tuple
from yt_dlp.utils import sanitize_filename 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.entry import Entry
from ytdl_sub.entries.variables.kwargs import CHAPTERS 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 SPLIT_BY_CHAPTERS_PARENT_ENTRY
from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS
from ytdl_sub.entries.variables.kwargs import UID 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 Chapters
from ytdl_sub.utils.chapters import Timestamp from ytdl_sub.utils.chapters import Timestamp
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata 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 from ytdl_sub.validators.string_select_validator import StringSelectValidator
@ -47,7 +45,7 @@ class WhenNoChaptersValidator(StringSelectValidator):
_select_values = {"pass", "drop", "error"} _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 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``, new source variables ``chapter_title``, ``chapter_title_sanitized``, ``chapter_index``,
@ -101,9 +99,8 @@ class SplitByChaptersOptions(PluginOptions):
return self._when_no_chapters return self._when_no_chapters
class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]):
plugin_options_type = SplitByChaptersOptions plugin_options_type = SplitByChaptersOptions
is_split_plugin = True
def _create_split_entry( def _create_split_entry(
self, source_entry: Entry, title: str, idx: int, chapters: Chapters 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 Tags the entry's audio file using values defined in the metadata options
""" """
split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = [] split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = []
if self.is_dry_run:
chapters = Chapters.from_entry_chapters(entry=entry) 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(),
)
# If no chapters, do not split anything # If no chapters, do not split anything
if not chapters.contains_any_chapters(): 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" 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): for idx, title in enumerate(chapters.titles):
new_uid = _split_video_uid(source_uid=entry.uid, idx=idx) 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 # 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 # 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( FileHandler.copy(
src_file_path=entry.get_download_thumbnail_path(), src_file_path=entry.get_download_thumbnail_path(),
dst_file_path=Path(self.working_directory) dst_file_path=Path(self.working_directory)

View file

@ -3,10 +3,10 @@ from typing import Dict
from typing import List from typing import List
from typing import Optional 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.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry 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 FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
@ -25,7 +25,7 @@ class SubtitlesTypeValidator(StringSelectValidator):
_select_values = SUBTITLE_EXTENSIONS _select_values = SUBTITLE_EXTENSIONS
class SubtitleOptions(PluginOptions): class SubtitleOptions(OptionsDictValidator):
""" """
Defines how to download and store subtitles. Using this plugin creates two new variables: 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. ``lang`` and ``subtitles_ext``. ``lang`` is dynamic since you can download multiple subtitles.

View file

@ -1,15 +1,19 @@
import copy
from typing import Any from typing import Any
from typing import Dict 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.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.ffmpeg import add_ffmpeg_metadata_key_values
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator 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. Adds tags to every downloaded video file using ffmpeg ``-metadata key=value`` args.
@ -20,13 +24,13 @@ class VideoTagsOptions(PluginOptions):
presets: presets:
my_example_preset: my_example_preset:
video_tags: video_tags:
tags:
title: "{title}" title: "{title}"
date: "{upload_date}" date: "{upload_date}"
description: "{description}" description: "{description}"
""" """
_required_keys = {"tags"} _optional_keys = {"tags"}
_allow_extra_keys = True
@classmethod @classmethod
def partial_validate(cls, name: str, value: Any) -> None: def partial_validate(cls, name: str, value: Any) -> None:
@ -39,7 +43,12 @@ class VideoTagsOptions(PluginOptions):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(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 @property
def tags(self) -> DictFormatterValidator: 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 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] = {} tags_to_write: Dict[str, str] = {}
for tag_name, tag_formatter in self.plugin_options.tags.dict.items(): for tag_name, tag_formatter in self.plugin_options.tags.dict.items():
tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)

View file

@ -1,9 +1,7 @@
presets: presets:
_view: _view:
download: download: "{url}"
download_strategy: "url"
url: "{url}"
output_options: output_options:
output_directory: "/tmp/ytdl-sub-view" output_directory: "/tmp/ytdl-sub-view"
file_name: "{uid}.{ext}" file_name: "{uid}.{ext}"

View file

@ -2,8 +2,7 @@ presets:
# TODO: Update this # TODO: Update this
kodi_music_video: kodi_music_video:
download: download:
download_strategy: "url" - "{music_video_url}"
url: "{music_video_url}"
output_options: output_options:
output_directory: "{music_video_directory}" output_directory: "{music_video_directory}"

View file

@ -26,17 +26,18 @@ presets:
# episode_number # episode_number
# episode_number_padded # episode_number_padded
tv_show_genre: "ytdl-sub" tv_show_genre: "ytdl-sub"
tv_show_content_rating: "TV-14"
season_title: "{season_number_padded}" season_title: "{season_number_padded}"
episode_title: "{upload_date_standardized} - {title}" episode_title: "{upload_date_standardized} - {title}"
episode_plot: "{webpage_url}\n\n{description}" episode_plot: "{webpage_url}\n\n{description}"
episode_year: "{upload_year}" episode_year: "{upload_year}"
episode_content_rating: "{tv_show_content_rating}"
episode_date_standardized: "{upload_date_standardized}" episode_date_standardized: "{upload_date_standardized}"
episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {file_title}" episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {file_title}"
episode_file_path: "Season {season_number_padded}/{episode_file_name}" episode_file_path: "Season {season_number_padded}/{episode_file_name}"
_episode_video_tags: _episode_video_tags:
video_tags: video_tags:
tags:
show: "{tv_show_name}" show: "{tv_show_name}"
genre: "{tv_show_genre}" genre: "{tv_show_genre}"
episode_id: "{episode_number}" episode_id: "{episode_number}"
@ -44,6 +45,7 @@ presets:
synopsis: "{episode_plot}" synopsis: "{episode_plot}"
year: "{episode_year}" year: "{episode_year}"
date: "{episode_date_standardized}" date: "{episode_date_standardized}"
contentRating: "{episode_content_rating}"
_episode_nfo_tags: _episode_nfo_tags:
nfo_tags: nfo_tags:
@ -58,6 +60,7 @@ presets:
plot: "{episode_plot}" plot: "{episode_plot}"
year: "{episode_year}" year: "{episode_year}"
aired: "{episode_date_standardized}" aired: "{episode_date_standardized}"
mpaa: "{episode_content_rating}"
output_directory_nfo_tags: output_directory_nfo_tags:
nfo_name: "tvshow.nfo" nfo_name: "tvshow.nfo"
@ -66,6 +69,7 @@ presets:
title: "{tv_show_name}" title: "{tv_show_name}"
genre: genre:
- "{tv_show_genre}" - "{tv_show_genre}"
mpaa: "{tv_show_content_rating}"
#################################################################################################### ####################################################################################################

View file

@ -34,8 +34,6 @@ presets:
# TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart # TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart
_tv_show_by_date: _tv_show_by_date:
download: download:
download_strategy: "multi_url"
urls:
- url: "{url}" - url: "{url}"
playlist_thumbnails: playlist_thumbnails:
- name: "{tv_show_poster_file_name}" - name: "{tv_show_poster_file_name}"
@ -87,9 +85,6 @@ presets:
# TV show from a collection. Must specify additional `tv_show_collection_season` presets in # TV show from a collection. Must specify additional `tv_show_collection_season` presets in
# addition. Each season sets its own `collection_season_number/_padded` # addition. Each season sets its own `collection_season_number/_padded`
_tv_show_collection: _tv_show_collection:
download:
download_strategy: "multi_url"
overrides: overrides:
season_number: "{collection_season_number}" season_number: "{collection_season_number}"
season_number_padded: "{collection_season_number_padded}" season_number_padded: "{collection_season_number_padded}"

View file

@ -21,8 +21,6 @@ presets:
collection_season_1: collection_season_1:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_1_url}" - url: "{collection_season_1_url}"
variables: variables:
collection_season_number: "1" collection_season_number: "1"
@ -46,8 +44,6 @@ presets:
collection_season_2: collection_season_2:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_2_url}" - url: "{collection_season_2_url}"
variables: variables:
collection_season_number: "2" collection_season_number: "2"
@ -65,8 +61,6 @@ presets:
collection_season_3: collection_season_3:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_3_url}" - url: "{collection_season_3_url}"
variables: variables:
collection_season_number: "3" collection_season_number: "3"
@ -84,8 +78,6 @@ presets:
collection_season_4: collection_season_4:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_4_url}" - url: "{collection_season_4_url}"
variables: variables:
collection_season_number: "4" collection_season_number: "4"
@ -103,8 +95,6 @@ presets:
collection_season_5: collection_season_5:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_5_url}" - url: "{collection_season_5_url}"
variables: variables:
collection_season_number: "5" collection_season_number: "5"
@ -122,8 +112,6 @@ presets:
collection_season_6: collection_season_6:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_6_url}" - url: "{collection_season_6_url}"
variables: variables:
collection_season_number: "6" collection_season_number: "6"
@ -141,8 +129,6 @@ presets:
collection_season_7: collection_season_7:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_7_url}" - url: "{collection_season_7_url}"
variables: variables:
collection_season_number: "7" collection_season_number: "7"
@ -160,8 +146,6 @@ presets:
collection_season_8: collection_season_8:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_8_url}" - url: "{collection_season_8_url}"
variables: variables:
collection_season_number: "8" collection_season_number: "8"
@ -179,8 +163,6 @@ presets:
collection_season_9: collection_season_9:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_9_url}" - url: "{collection_season_9_url}"
variables: variables:
collection_season_number: "9" collection_season_number: "9"
@ -198,8 +180,6 @@ presets:
collection_season_10: collection_season_10:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_10_url}" - url: "{collection_season_10_url}"
variables: variables:
collection_season_number: "10" collection_season_number: "10"
@ -217,8 +197,6 @@ presets:
collection_season_11: collection_season_11:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_11_url}" - url: "{collection_season_11_url}"
variables: variables:
collection_season_number: "11" collection_season_number: "11"
@ -236,8 +214,6 @@ presets:
collection_season_12: collection_season_12:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_12_url}" - url: "{collection_season_12_url}"
variables: variables:
collection_season_number: "12" collection_season_number: "12"
@ -255,8 +231,6 @@ presets:
collection_season_13: collection_season_13:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_13_url}" - url: "{collection_season_13_url}"
variables: variables:
collection_season_number: "13" collection_season_number: "13"
@ -274,8 +248,6 @@ presets:
collection_season_14: collection_season_14:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_14_url}" - url: "{collection_season_14_url}"
variables: variables:
collection_season_number: "14" collection_season_number: "14"
@ -293,8 +265,6 @@ presets:
collection_season_15: collection_season_15:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_15_url}" - url: "{collection_season_15_url}"
variables: variables:
collection_season_number: "15" collection_season_number: "15"
@ -312,8 +282,6 @@ presets:
collection_season_16: collection_season_16:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_16_url}" - url: "{collection_season_16_url}"
variables: variables:
collection_season_number: "16" collection_season_number: "16"
@ -331,8 +299,6 @@ presets:
collection_season_17: collection_season_17:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_17_url}" - url: "{collection_season_17_url}"
variables: variables:
collection_season_number: "17" collection_season_number: "17"
@ -350,8 +316,6 @@ presets:
collection_season_18: collection_season_18:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_18_url}" - url: "{collection_season_18_url}"
variables: variables:
collection_season_number: "18" collection_season_number: "18"
@ -369,8 +333,6 @@ presets:
collection_season_19: collection_season_19:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_19_url}" - url: "{collection_season_19_url}"
variables: variables:
collection_season_number: "19" collection_season_number: "19"
@ -388,8 +350,6 @@ presets:
collection_season_20: collection_season_20:
download: download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_20_url}" - url: "{collection_season_20_url}"
variables: variables:
collection_season_number: "20" collection_season_number: "20"
@ -404,3 +364,343 @@ presets:
- tag: "{collection_season_20_name}" - tag: "{collection_season_20_name}"
attributes: 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"

View file

@ -1,6 +1,5 @@
from abc import ABC from abc import ABC
from pathlib import Path from pathlib import Path
from typing import Type
from ytdl_sub.config.config_validator import ConfigOptions from ytdl_sub.config.config_validator import ConfigOptions
from ytdl_sub.config.preset import Preset 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 OutputOptions
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.downloaders.base_downloader import BaseDownloader from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -46,22 +44,13 @@ class BaseSubscription(ABC):
self._preset_options = preset_options self._preset_options = preset_options
self._enhanced_download_archive = EnhancedDownloadArchive( 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, working_directory=self.working_directory,
output_directory=self.output_directory, output_directory=self.output_directory,
) )
@property @property
def downloader_class(self) -> Type[BaseDownloader]: def downloader_options(self) -> MultiUrlValidator:
"""
Returns
-------
This subscription's downloader class
"""
return self._preset_options.downloader
@property
def downloader_options(self) -> BaseDownloaderValidator:
""" """
Returns Returns
------- -------

View file

@ -5,10 +5,12 @@ from typing import List
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset import Preset
from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload 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.utils.yaml import load_yaml
from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import LiteralDictValidator
FILE_PRESET_APPLY_KEY = "__preset__" FILE_PRESET_APPLY_KEY = "__preset__"
FILE_SUBSCRIPTION_VALUE_KEY = "__value__"
class Subscription(SubscriptionDownload): class Subscription(SubscriptionDownload):
@ -64,7 +66,9 @@ class Subscription(SubscriptionDownload):
@classmethod @classmethod
def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]: 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 Parameters
---------- ----------
@ -76,11 +80,17 @@ class Subscription(SubscriptionDownload):
Returns Returns
------- -------
List of subscriptions, for each one in the subscription yaml List of subscriptions, for each one in the subscription yaml
Raises
------
ValidationException
If subscription file is misconfigured
""" """
subscriptions: List["Subscription"] = [] subscriptions: List["Subscription"] = []
subscription_dict = load_yaml(file_path=subscription_path) subscription_dict = load_yaml(file_path=subscription_path)
has_file_preset = FILE_PRESET_APPLY_KEY in subscription_dict 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 a file preset is present...
if has_file_preset: if has_file_preset:
@ -94,12 +104,39 @@ class Subscription(SubscriptionDownload):
config = copy.deepcopy(config) config = copy.deepcopy(config)
config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict 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(): for subscription_key, subscription_object in subscription_dict.items():
# Skip file preset # Skip file preset or value
if subscription_key == FILE_PRESET_APPLY_KEY: if subscription_key in [FILE_PRESET_APPLY_KEY, FILE_SUBSCRIPTION_VALUE_KEY]:
continue 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 it has file_preset, inject it as a parent preset
if has_file_preset: if has_file_preset:
parent_preset = subscription_object.get("preset", []) parent_preset = subscription_object.get("preset", [])

View file

@ -1,4 +1,5 @@
import contextlib import contextlib
import logging
import os import os
import shutil import shutil
from abc import ABC from abc import ABC
@ -6,12 +7,14 @@ from pathlib import Path
from typing import List from typing import List
from typing import Optional 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 InfoJsonDownloader
from ytdl_sub.downloaders.info_json.info_json_downloader import InfoJsonDownloaderOptions 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.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry 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.base_subscription import BaseSubscription
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
from ytdl_sub.utils.datetime import to_date_range 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 FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.file_handler import FileMetadata 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]: def _get_split_plugin(plugins: List[Plugin]) -> Optional[SplitPlugin]:
split_plugins = [plugin for plugin in plugins if plugin.is_split_plugin] split_plugins = [plugin for plugin in plugins if isinstance(plugin, SplitPlugin)]
if len(split_plugins) == 1: if len(split_plugins) == 1:
return split_plugins[0] return split_plugins[0]
@ -67,16 +72,12 @@ class SubscriptionDownload(BaseSubscription, ABC):
entry=entry, entry=entry,
) )
# TODO: see if entry even has a thumbnail # Always pretend to include the thumbnail in a dry-run
if self.output_options.thumbnail_name: if self.output_options.thumbnail_name and (dry_run or entry.is_thumbnail_downloaded()):
output_thumbnail_name = self.overrides.apply_formatter( output_thumbnail_name = self.overrides.apply_formatter(
formatter=self.output_options.thumbnail_name, entry=entry 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 # Copy the thumbnails since they could be used later for other things
self._enhanced_download_archive.save_file_to_output_directory( self._enhanced_download_archive.save_file_to_output_directory(
file_name=entry.get_download_thumbnail_name(), file_name=entry.get_download_thumbnail_name(),
@ -84,6 +85,10 @@ class SubscriptionDownload(BaseSubscription, ABC):
entry=entry, entry=entry,
copy_file=True, 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: if self.output_options.info_json_name:
output_info_json_name = self.overrides.apply_formatter( 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.remove_stale_files(date_range=date_range_to_keep)
self._enhanced_download_archive.save_download_mappings() self._enhanced_download_archive.save_download_mappings()
FileHandler.delete(self._enhanced_download_archive.archive_working_file_path) FileHandler.delete(self._enhanced_download_archive.working_file_path)
FileHandler.delete(self._enhanced_download_archive.mapping_working_file_path)
@contextlib.contextmanager @contextlib.contextmanager
def _remove_empty_directories_in_output_directory(self): 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. List of plugins defined in the subscription, initialized and ready to use.
""" """
# Always add plugins provided by the downloader return [
plugins: List[Plugin] = self.downloader_class.added_plugins( plugin_type(
downloader_options=self.downloader_options, options=plugin_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,
overrides=self.overrides, overrides=self.overrides,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self._enhanced_download_archive,
) )
for plugin_type, plugin_options in self.plugins.zipped()
plugins.append(plugin) ]
return plugins
@classmethod @classmethod
def _cleanup_entry_files(cls, entry: Entry): def _cleanup_entry_files(cls, entry: Entry):
@ -201,7 +196,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
@classmethod @classmethod
def _preprocess_entry(cls, plugins: List[Plugin], entry: Entry) -> Optional[Entry]: def _preprocess_entry(cls, plugins: List[Plugin], entry: Entry) -> Optional[Entry]:
maybe_entry: Optional[Entry] = 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: if (maybe_entry := plugin.modify_entry_metadata(maybe_entry)) is None:
return None return None
@ -244,7 +239,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
self._cleanup_entry_files(entry) self._cleanup_entry_files(entry)
def _process_split_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: ) -> None:
entry_: Optional[Entry] = entry entry_: Optional[Entry] = entry
@ -291,7 +286,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
def _process_subscription( def _process_subscription(
self, self,
plugins: List[Plugin], plugins: List[Plugin],
downloader: BaseDownloader, downloader: SourcePlugin,
dry_run: bool, dry_run: bool,
) -> FileHandlerTransactionLog: ) -> FileHandlerTransactionLog:
with self._subscription_download_context_managers(): with self._subscription_download_context_managers():
@ -339,14 +334,16 @@ class SubscriptionDownload(BaseSubscription, ABC):
dry_run=dry_run, dry_run=dry_run,
) )
downloader = self.downloader_class( downloader = MultiUrlDownloader(
download_options=self.downloader_options, options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self._enhanced_download_archive,
download_ytdl_options=subscription_ytdl_options.download_builder(), download_ytdl_options=subscription_ytdl_options.download_builder(),
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(), metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
overrides=self.overrides, overrides=self.overrides,
) )
plugins.extend(downloader.added_plugins())
return self._process_subscription( return self._process_subscription(
plugins=plugins, plugins=plugins,
downloader=downloader, downloader=downloader,
@ -365,8 +362,27 @@ class SubscriptionDownload(BaseSubscription, ABC):
self._enhanced_download_archive.reinitialize(dry_run=dry_run) self._enhanced_download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins() 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( downloader = InfoJsonDownloader(
download_options=InfoJsonDownloaderOptions(name="no-op", value={}), options=InfoJsonDownloaderOptions(name="no-op", value={}),
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self._enhanced_download_archive,
download_ytdl_options=YTDLOptionsBuilder(), download_ytdl_options=YTDLOptionsBuilder(),
metadata_ytdl_options=YTDLOptionsBuilder(), metadata_ytdl_options=YTDLOptionsBuilder(),

View file

@ -5,15 +5,14 @@ from typing import Optional
from typing import Type from typing import Type
from typing import TypeVar from typing import TypeVar
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset import Preset 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.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
from ytdl_sub.plugins.chapters import ChaptersPlugin from ytdl_sub.plugins.chapters import ChaptersPlugin
from ytdl_sub.plugins.date_range import DateRangePlugin from ytdl_sub.plugins.date_range import DateRangePlugin
from ytdl_sub.plugins.file_convert import FileConvertPlugin from ytdl_sub.plugins.file_convert import FileConvertPlugin
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin 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.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -42,10 +41,6 @@ class SubscriptionYTDLOptions:
return plugin return plugin
return None return None
@property
def _downloader(self) -> Type[BaseDownloader]:
return self._preset.downloader
@property @property
def _global_options(self) -> Dict: def _global_options(self) -> Dict:
""" """
@ -78,14 +73,16 @@ class SubscriptionYTDLOptions:
"writeinfojson": True, "writeinfojson": True,
} }
@property
def _download_only_options(self) -> Dict:
return {"break_on_reject": True}
@property @property
def _output_options(self) -> Dict: def _output_options(self) -> Dict:
ytdl_options = {} ytdl_options = {}
if self._preset.output_options.maintain_download_archive: if self._preset.output_options.maintain_download_archive:
ytdl_options["download_archive"] = str( ytdl_options["download_archive"] = self._enhanced_download_archive.working_file_path
Path(self._working_directory) / self._enhanced_download_archive.archive_file_name
)
return ytdl_options return ytdl_options
@ -110,7 +107,6 @@ class SubscriptionYTDLOptions:
self._global_options, self._global_options,
self._output_options, self._output_options,
self._plugin_ytdl_options(DateRangePlugin), self._plugin_ytdl_options(DateRangePlugin),
self._plugin_ytdl_options(MatchFiltersPlugin),
self._user_ytdl_options, # user ytdl options... self._user_ytdl_options, # user ytdl options...
self._info_json_only_options, # then info_json_only options self._info_json_only_options, # then info_json_only options
) )
@ -125,12 +121,13 @@ class SubscriptionYTDLOptions:
ytdl_options_builder = YTDLOptionsBuilder().add( ytdl_options_builder = YTDLOptionsBuilder().add(
self._global_options, self._global_options,
self._output_options, self._output_options,
self._plugin_ytdl_options(DateRangePlugin), self._plugin_ytdl_options(MatchFiltersPlugin),
self._plugin_ytdl_options(FileConvertPlugin), self._plugin_ytdl_options(FileConvertPlugin),
self._plugin_ytdl_options(SubtitlesPlugin), self._plugin_ytdl_options(SubtitlesPlugin),
self._plugin_ytdl_options(ChaptersPlugin), self._plugin_ytdl_options(ChaptersPlugin),
self._plugin_ytdl_options(AudioExtractPlugin), self._plugin_ytdl_options(AudioExtractPlugin),
self._user_ytdl_options, # user ytdl options... self._user_ytdl_options, # user ytdl options...
self._download_only_options, # then download_only options
) )
# Add dry run options last if enabled # Add dry run options last if enabled
if self._dry_run: if self._dry_run:

View file

@ -1,12 +1,12 @@
import json
import re import re
import subprocess
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from ytdl_sub.entries.entry import Entry 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 from ytdl_sub.utils.file_handler import FileMetadata
@ -202,6 +202,7 @@ class Chapters:
timestamps: List[Timestamp] = [] timestamps: List[Timestamp] = []
titles: List[str] = [] titles: List[str] = []
# Try to accumulate chapters by parsing lines individually
for line in input_str.split("\n"): for line in input_str.split("\n"):
# Timestamp captured, store it # Timestamp captured, store it
if match := Timestamp.TIMESTAMP_REGEX.search(line): if match := Timestamp.TIMESTAMP_REGEX.search(line):
@ -211,53 +212,12 @@ class Chapters:
# Remove timestamp and surrounding whitespace from it # Remove timestamp and surrounding whitespace from it
title_str = re.sub(f"\\s*{re.escape(timestamp_str)}\\s*", " ", line).strip() title_str = re.sub(f"\\s*{re.escape(timestamp_str)}\\s*", " ", line).strip()
titles.append(title_str) titles.append(title_str)
elif len(timestamps) >= 3:
return Chapters(timestamps=timestamps, titles=titles) # If more than 3 timestamps were parsed, return it
# Timestamp was not stored, if only contained 1, reset if len(timestamps) >= 3:
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) return Chapters(timestamps=timestamps, titles=titles)
# Otherwise return empty chapters
return Chapters(timestamps=[], titles=[])
@classmethod @classmethod
def from_entry_chapters(cls, entry: Entry) -> "Chapters": def from_entry_chapters(cls, entry: Entry) -> "Chapters":
@ -274,13 +234,14 @@ class Chapters:
timestamps: List[Timestamp] = [] timestamps: List[Timestamp] = []
titles: List[str] = [] titles: List[str] = []
chapters = {} if entry.kwargs_contains(CHAPTERS):
if entry.kwargs_contains("chapters"): for chapter in entry.kwargs_get(CHAPTERS, []):
chapters = entry.kwargs("chapters") or []
for chapter in chapters:
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"])))) timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
titles.append(chapter["title"]) 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) return Chapters(timestamps=timestamps, titles=titles)

View file

@ -168,6 +168,7 @@ def set_ffmpeg_metadata_chapters(
metadata_file.name, metadata_file.name,
"-map", "-map",
"0", "0",
"-dn", # ignore data streams
"-map_chapters", "-map_chapters",
"1", "1",
"-bitexact", # for reproducibility "-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) 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(): for key, value in key_values.items():
ffmpeg_args.extend(["-metadata", f"{key}={value}"]) ffmpeg_args.extend(["-metadata", f"{key}={value}"])
ffmpeg_args.extend(["-codec", "copy", tmp_file_path]) ffmpeg_args.extend(["-codec", "copy", tmp_file_path])

View file

@ -44,7 +44,19 @@ else:
/ str(working_directory_path).replace("/", "_") / str(working_directory_path).replace("/", "_")
) )
try:
lock_file = open(lock_file_path, "w", encoding="utf-8") 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: try:
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)

View file

@ -1,11 +1,14 @@
import logging
import os import os
import tempfile import tempfile
from subprocess import CalledProcessError
from typing import Optional from typing import Optional
from urllib.request import urlopen from urllib.request import urlopen
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.retry import retry from ytdl_sub.utils.retry import retry
@ -13,38 +16,38 @@ class ThumbnailTypes:
LATEST_ENTRY = "latest_entry" 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 Parameters
---------- ----------
entry entry
Entry with the thumbnail 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() download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
# If it was already converted, do not convert again # If it was already converted, do not convert again
if os.path.isfile(download_thumbnail_path_as_jpg): if entry.is_thumbnail_downloaded():
return return
if not download_thumbnail_path: if not download_thumbnail_path:
if error_if_not_found: logger.warning("Thumbnail for '%s' was not downloaded", entry.title)
raise ValueError("Thumbnail not found")
return return
if not download_thumbnail_path == download_thumbnail_path_as_jpg: if not download_thumbnail_path == download_thumbnail_path_as_jpg:
try:
FFMPEG.run( FFMPEG.run(
["-y", "-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg] ["-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) FileHandler.delete(download_thumbnail_path)

View file

@ -38,9 +38,9 @@ def load_yaml(file_path: str | Path) -> Dict:
with open(file_path, "r", encoding="utf-8") as file: with open(file_path, "r", encoding="utf-8") as file:
output = yaml.safe_load(file) output = yaml.safe_load(file)
except YAMLError as yaml_exception: except YAMLError as yaml_exception:
logger.debug(yaml_exception)
raise InvalidYamlException( 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 ) from yaml_exception
if not isinstance(output, dict): if not isinstance(output, dict):

View file

@ -179,6 +179,16 @@ class DictValidator(Validator):
super().__init__(name, value) super().__init__(name, value)
self.__validator_dict: Dict[str, Validator] = {} 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 @final
@property @property
def _dict(self) -> dict: def _dict(self) -> dict:

View file

@ -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 FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
@dataclass @dataclass
@ -370,21 +369,19 @@ class EnhancedDownloadArchive:
def __init__( def __init__(
self, self,
subscription_name: str, file_name: str,
working_directory: str, working_directory: str,
output_directory: str, output_directory: str,
dry_run: bool = False, dry_run: bool = False,
): ):
self.subscription_name = subscription_name self._file_name = file_name
self._file_handler = FileHandler( self._file_handler = FileHandler(
working_directory=working_directory, output_directory=output_directory, dry_run=dry_run working_directory=working_directory, output_directory=output_directory, dry_run=dry_run
) )
self._download_mapping = self._maybe_load_download_mappings( 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_added: int = 0
self.num_entries_modified: int = 0 self.num_entries_modified: int = 0
self.num_entries_removed: int = 0 self.num_entries_removed: int = 0
@ -418,7 +415,7 @@ class EnhancedDownloadArchive:
dry_run=dry_run, dry_run=dry_run,
) )
self._download_mapping = self._maybe_load_download_mappings( 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 return self
@ -431,16 +428,6 @@ class EnhancedDownloadArchive:
""" """
return self._file_handler.dry_run 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 @property
def working_directory(self) -> str: def working_directory(self) -> str:
""" """
@ -460,40 +447,31 @@ class EnhancedDownloadArchive:
return self._file_handler.output_directory return self._file_handler.output_directory
@property @property
def _mapping_file_name(self) -> str: def file_name(self) -> str:
""" """
Returns Returns
------- -------
The download mapping's file name (no path) The download mapping's file name (no path)
""" """
return f".ytdl-sub-{self.subscription_name}-download-archive.json" return self._file_name
@property @property
def _mapping_output_file_path(self) -> str: def output_file_path(self) -> str:
""" """
Returns Returns
------- -------
The download mapping's file path in the output directory. 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 @property
def mapping_working_file_path(self) -> str: def working_file_path(self) -> str:
""" """
Returns Returns
------- -------
The download mapping's file path in the working directory. The download mapping's file path in the working directory.
""" """
return str(Path(self.working_directory) / self._mapping_file_name) return str(Path(self.working_directory) / self.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)
@property @property
def mapping(self) -> DownloadMappings: def mapping(self) -> DownloadMappings:
@ -526,7 +504,7 @@ class EnhancedDownloadArchive:
return self return self
# Otherwise, create a ytdl download archive file in the working directory. # Otherwise, create a ytdl download archive file in the working directory.
self.mapping.to_download_archive().to_file(self.archive_working_file_path) self.mapping.to_download_archive().to_file(self.working_file_path)
return self return self
@ -566,8 +544,8 @@ class EnhancedDownloadArchive:
self self
""" """
if not self.get_file_handler_transaction_log().is_empty: if not self.get_file_handler_transaction_log().is_empty:
self._download_mapping.to_file(output_json_file=self.mapping_working_file_path) self._download_mapping.to_file(output_json_file=self.working_file_path)
self.save_file_to_output_directory(file_name=self._mapping_file_name) self.save_file_to_output_directory(file_name=self.file_name)
return self return self
def delete_file_from_output_directory(self, file_name: str): def delete_file_from_output_directory(self, file_name: str):

View file

@ -6,7 +6,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
@pytest.fixture @pytest.fixture
def single_song_preset_dict(output_directory): def single_song_preset_dict_old_format(output_directory):
return { return {
"preset": "single", "preset": "single",
# test multi-tags # 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 @pytest.fixture
def multiple_songs_preset_dict(output_directory): def multiple_songs_preset_dict(output_directory):
return { return {
@ -42,7 +62,33 @@ def multiple_songs_preset_dict(output_directory):
class TestAudioExtract: class TestAudioExtract:
@pytest.mark.parametrize("dry_run", [True, False]) @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, self,
music_audio_config, music_audio_config,
single_song_preset_dict, single_song_preset_dict,

View file

@ -11,7 +11,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def sponsorblock_and_subs_preset_dict(output_directory) -> Dict: def sponsorblock_and_subs_preset_dict(output_directory) -> Dict:
return { return {
"preset": "music_video", "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 # override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
"subtitles": { "subtitles": {
@ -46,9 +46,7 @@ def sponsorblock_and_subs_preset_dict(output_directory) -> Dict:
@pytest.fixture @pytest.fixture
def chapters_from_comments_preset_dict(sponsorblock_and_subs_preset_dict: Dict) -> Dict: def chapters_from_comments_preset_dict(sponsorblock_and_subs_preset_dict: Dict) -> Dict:
sponsorblock_and_subs_preset_dict["download"][ sponsorblock_and_subs_preset_dict["download"] = "https://www.youtube.com/watch?v=MO5AWAqe01Y"
"url"
] = "https://www.youtube.com/watch?v=MO5AWAqe01Y"
sponsorblock_and_subs_preset_dict["chapters"] = { sponsorblock_and_subs_preset_dict["chapters"] = {
"embed_chapters": True, "embed_chapters": True,
"allow_chapters_from_comments": True, "allow_chapters_from_comments": True,

View file

@ -10,7 +10,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def preset_dict(output_directory): def preset_dict(output_directory):
return { return {
"preset": "music_video", "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}, "output_options": {"output_directory": output_directory},
# download the worst format so it is fast # download the worst format so it is fast
"ytdl_options": { "ytdl_options": {

View file

@ -1,4 +1,6 @@
import pytest 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.subscriptions.subscription import Subscription
@ -7,7 +9,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def preset_dict(output_directory): def preset_dict(output_directory):
return { return {
"preset": "music_video", "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}, "output_options": {"output_directory": output_directory},
# download the worst format so it is fast # download the worst format so it is fast
"ytdl_options": { "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: class TestFileConvert:
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
def test_match_filters( def test_match_filters_empty(
self, self,
music_video_config, music_video_config,
preset_dict, preset_dict,
@ -34,3 +50,31 @@ class TestFileConvert:
transaction_log = subscription.download(dry_run=dry_run) transaction_log = subscription.download(dry_run=dry_run)
assert transaction_log.is_empty 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",
)

View file

@ -1,8 +1,6 @@
import re import re
import pytest 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.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
@ -11,13 +9,10 @@ from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture @pytest.fixture
def single_song_video_dict(output_directory): def single_song_video_dict(output_directory):
return { return {
"download": { "download": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
"download_strategy": "url",
"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
},
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"}, "output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
# test multi-tags # 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 # download the worst format so it is fast
"ytdl_options": { "ytdl_options": {
"format": "worst[ext=mp4]", "format": "worst[ext=mp4]",

View file

@ -8,7 +8,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def subscription_dict(output_directory): def subscription_dict(output_directory):
return { return {
"preset": "music_video", "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 # override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
# download the worst format so it is fast # download the worst format so it is fast

View file

@ -1,20 +1,23 @@
import copy import copy
import re import re
from typing import Any
from typing import Dict
import mergedeep
import pytest import pytest
from expected_transaction_log import assert_transaction_log_matches 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.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import RegexNoMatchException from ytdl_sub.utils.exceptions import RegexNoMatchException
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture @pytest.fixture
def regex_subscription_dict(output_directory): def regex_subscription_dict_base(output_directory):
return { return {
"preset": "music_video", "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 # override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
# download the worst format so it is fast # download the worst format so it is fast
@ -24,13 +27,6 @@ def regex_subscription_dict(output_directory):
"regex": { "regex": {
# tests that skip_if_match_fails defaults to True # tests that skip_if_match_fails defaults to True
"from": { "from": {
"title": {
"match": [
"should not cap (.+) - (.+)",
".*\\[(.+) - (Feb.+)]", # should filter out march video
],
"capture_group_names": ["title_type", "title_date"],
},
"description": { "description": {
"match": [".*http:\\/\\/(.+).com.*"], "match": [".*http:\\/\\/(.+).com.*"],
"capture_group_names": ["description_website"], "capture_group_names": ["description_website"],
@ -48,6 +44,29 @@ def regex_subscription_dict(output_directory):
}, },
}, },
}, },
"overrides": {
"in_regex_default": "in regex default",
},
}
@pytest.fixture
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": { "nfo_tags": {
"tags": { "tags": {
"title_cap_1": "{title_type}", "title_cap_1": "{title_type}",
@ -60,46 +79,153 @@ def regex_subscription_dict(output_directory):
} }
}, },
"overrides": { "overrides": {
"in_regex_default": "in regex default",
"contains_regex_default": "contains {title_type}", "contains_regex_default": "contains {title_type}",
"contains_regex_sanitized_default": "contains {title_type_sanitized}", "contains_regex_sanitized_default": "contains {title_type_sanitized}",
}, },
} },
)
@pytest.fixture @pytest.fixture
def regex_subscription_dict_no_match_fails(regex_subscription_dict): def regex_subscription_dict_exclude(regex_subscription_dict_base, output_directory):
regex_subscription_dict["regex"]["skip_if_match_fails"] = False return mergedeep.merge(
return regex_subscription_dict 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 @pytest.fixture
def playlist_subscription(music_video_config, regex_subscription_dict): def playlist_subscription(music_video_config, regex_subscription_dict):
playlist_preset = Preset.from_dict( return Subscription.from_dict(
config=music_video_config, config=music_video_config,
preset_name="regex_capture_playlist_test", preset_name="regex_capture_playlist_test",
preset_dict=regex_subscription_dict, 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, config=music_video_config,
preset_name="regex_capture_playlist_test",
preset_dict=regex_subscription_dict,
) )
@pytest.fixture @pytest.fixture
def playlist_subscription_no_match_fails( def playlist_subscription_exclude(
music_video_config, regex_subscription_dict_no_match_fails music_video_config: ConfigFile, regex_subscription_dict_exclude: Dict[str, Any]
): ) -> Subscription:
playlist_preset = Preset.from_dict( return Subscription.from_dict(
config=music_video_config, config=music_video_config,
preset_name="regex_capture_playlist_test", preset_name="regex_exclude_playlist_test",
preset_dict=regex_subscription_dict_no_match_fails, 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, 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", 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): def test_regex_fails_no_match(self, playlist_subscription_no_match_fails, output_directory):
with pytest.raises( with pytest.raises(
RegexNoMatchException, RegexNoMatchException,
@ -122,6 +277,34 @@ class TestRegex:
): ):
_ = playlist_subscription_no_match_fails.download(dry_run=True) _ = 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( def test_regex_fails_capture_group_is_source_variable(
self, regex_subscription_dict, music_video_config self, regex_subscription_dict, music_video_config
): ):
@ -164,7 +347,9 @@ class TestRegex:
) )
with pytest.raises( with pytest.raises(
ValidationException, 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( _ = Subscription.from_dict(
config=music_video_config, config=music_video_config,

View file

@ -32,7 +32,9 @@ def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict
{ {
"regex": { "regex": {
"from": { "from": {
"chapter_title": { # Ensure regex can handle override variables that come from the
# post-metadata stage
"override_chapter_title": {
"match": r"\d+\. (.+)", "match": r"\d+\. (.+)",
"capture_group_names": "captured_track_title", "capture_group_names": "captured_track_title",
"capture_group_defaults": "{chapter_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": { "overrides": {
"override_chapter_title": "{chapter_title}",
"track_title": "{captured_track_title}", "track_title": "{captured_track_title}",
"track_album": "{captured_track_album}", "track_album": "{captured_track_album}",
"track_artist": "{captured_track_artist}", "track_artist": "{captured_track_artist}",
@ -133,7 +136,7 @@ class TestSplitByChapters:
mergedeep.merge( mergedeep.merge(
yt_album_as_chapters_with_regex_preset_dict, 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}, "split_by_chapters": {"when_no_chapters": when_no_chapters},
}, },
) )

View file

@ -10,7 +10,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def single_video_subs_embed_preset_dict(output_directory): def single_video_subs_embed_preset_dict(output_directory):
return { return {
"preset": "music_video", "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 # override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
"subtitles": { "subtitles": {

View file

@ -43,7 +43,7 @@ class TestChannel:
expected md5 file hashes. expected md5 file hashes.
""" """
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [False])
def test_full_channel_download( def test_full_channel_download(
self, self,
channel_as_tv_show_config, channel_as_tv_show_config,

View file

@ -1,23 +1,30 @@
from unittest.mock import patch
import pytest import pytest
from conftest import preset_dict_to_dl_args from conftest import preset_dict_to_dl_args
from e2e.conftest import mock_run_from_cli from e2e.conftest import mock_run_from_cli
from expected_download import assert_expected_downloads from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches 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.subscriptions.subscription import Subscription
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
@pytest.fixture @pytest.fixture
def single_video_preset_dict(output_directory): def single_video_preset_dict_old_video_tags_format(output_directory):
return { return {
"preset": "music_video", "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 # override the output directory with our fixture-generated dir
"output_options": { "output_options": {
"output_directory": output_directory, "output_directory": output_directory,
"maintain_download_archive": False, "maintain_download_archive": False,
}, },
# embed thumb into the video
"embed_thumbnail": True,
# download the worst format so it is fast # download the worst format so it is fast
"ytdl_options": { "ytdl_options": {
"format": "worst[ext=mp4]", "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 @pytest.fixture
def single_tv_show_video_nulled_values_preset_dict(output_directory): def single_tv_show_video_nulled_values_preset_dict(output_directory):
return { return {
@ -49,6 +80,13 @@ def single_tv_show_video_nulled_values_preset_dict(output_directory):
"format": "worst[ext=mp4]", "format": "worst[ext=mp4]",
"max_downloads": 2, "max_downloads": 2,
}, },
# test override variables added by ytdl-sub
"nfo_tags": {
"tags": {
"subscription_name": "{subscription_name}",
"subscription_name_sanitized": "{subscription_name_sanitized}",
}
},
"overrides": { "overrides": {
"url": "https://www.youtube.com/@ProjectZombie603", "url": "https://www.youtube.com/@ProjectZombie603",
"tv_show_name": "Project Zombie", "tv_show_name": "Project Zombie",
@ -63,7 +101,26 @@ def single_video_preset_dict_dl_args(single_video_preset_dict):
class TestYoutubeVideo: 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( def test_single_video_download(
self, self,
music_video_config, music_video_config,
@ -89,6 +146,44 @@ class TestYoutubeVideo:
expected_download_summary_file_name="youtube/test_video.json", 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]) @pytest.mark.parametrize("dry_run", [True, False])
def test_single_video_download_from_cli_dl( def test_single_video_download_from_cli_dl(
self, self,

View file

@ -124,7 +124,6 @@ def assert_expected_downloads(
dry_run: bool, dry_run: bool,
expected_download_summary_file_name: str, expected_download_summary_file_name: str,
ignore_md5_hashes_for: Optional[List[str]] = None, ignore_md5_hashes_for: Optional[List[str]] = None,
regenerate_expected_download_summary: bool = REGENERATE_FIXTURES,
): ):
if dry_run: if dry_run:
output_directory_contents = list(Path(output_directory).rglob("*")) output_directory_contents = list(Path(output_directory).rglob("*"))
@ -134,7 +133,11 @@ def assert_expected_downloads(
return return
summary_full_path = _EXPECTED_DOWNLOADS_SUMMARY_PATH / expected_download_summary_file_name 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) os.makedirs(os.path.dirname(summary_full_path), exist_ok=True)
ExpectedDownloads.from_directory(directory_path=output_directory).to_summary_file( ExpectedDownloads.from_directory(directory_path=output_directory).to_summary_file(
summary_file_path=summary_full_path summary_file_path=summary_full_path

View file

@ -14,7 +14,6 @@ def assert_transaction_log_matches(
output_directory: Path, output_directory: Path,
transaction_log: FileHandlerTransactionLog, transaction_log: FileHandlerTransactionLog,
transaction_log_summary_file_name: str, transaction_log_summary_file_name: str,
regenerate_transaction_log: bool = REGENERATE_FIXTURES,
): ):
""" """
Parameters Parameters
@ -26,16 +25,14 @@ def assert_transaction_log_matches(
transaction_log_summary_file_name transaction_log_summary_file_name
Name if the transaction log summary to compare. Name if the transaction log summary to compare.
Lives in tests/e2e/resources/transaction_log_summaries 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 transaction_log_path = _TRANSACTION_LOG_SUMMARY_PATH / transaction_log_summary_file_name
summary = transaction_log.to_output_message(output_directory=output_directory) summary = transaction_log.to_output_message(output_directory=output_directory)
# Write the expected summary file if regenerate is True # USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
if regenerate_transaction_log: # 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) os.makedirs(os.path.dirname(transaction_log_path), exist_ok=True)
with open(transaction_log_path, "w", encoding="utf-8") as summary_file: with open(transaction_log_path, "w", encoding="utf-8") as summary_file:
summary_file.write( summary_file.write(

View file

@ -1,19 +1,19 @@
{ {
".ytdl-sub-jb-download-archive.json": "9f5ca17dc0d842ac4c296054c0011ace", ".ytdl-sub-jb-download-archive.json": "9f5ca17dc0d842ac4c296054c0011ace",
"El Jazzy Chavo/[2022] S950 Funk/01 - Spark it.mp3": "6f21b6f977609382fa9b9fa9817b28cc", "El Jazzy Chavo/[2022] S950 Funk/01 - Spark it.mp3": "f1fcc87884ced84be016391e3989b5c1",
"El Jazzy Chavo/[2022] S950 Funk/02 - Astronomikal Funk.mp3": "870199f5e75ea170051122f7220e2f42", "El Jazzy Chavo/[2022] S950 Funk/02 - Astronomikal Funk.mp3": "d07701404b8ddaa2e38eb51f7e2ac2d8",
"El Jazzy Chavo/[2022] S950 Funk/03 - Impulse.mp3": "3a14e5e034016ad131186caf94be00da", "El Jazzy Chavo/[2022] S950 Funk/03 - Impulse.mp3": "43118db203e1d9427036102cfb39a1e3",
"El Jazzy Chavo/[2022] S950 Funk/04 - September.mp3": "c58520ef3f7f8944151ee0fb95fb0ac5", "El Jazzy Chavo/[2022] S950 Funk/04 - September.mp3": "3d75c5068e3dbb93dd86fc650c7ca84d",
"El Jazzy Chavo/[2022] S950 Funk/05 - Interlune.mp3": "5a7365a253ac4a5af54e550508a40b66", "El Jazzy Chavo/[2022] S950 Funk/05 - Interlune.mp3": "63897d8af920f31af2183a17bc697c11",
"El Jazzy Chavo/[2022] S950 Funk/06 - Ninjutsu Techniques.mp3": "07b3f74ef78cb0c869c86dded054b1e9", "El Jazzy Chavo/[2022] S950 Funk/06 - Ninjutsu Techniques.mp3": "a500409583ee30e4cb68d64af2b32596",
"El Jazzy Chavo/[2022] S950 Funk/07 - Space Funk Flow.mp3": "fef74e222258c4845e8b51e05acd240f", "El Jazzy Chavo/[2022] S950 Funk/07 - Space Funk Flow.mp3": "1e1a12a5a42a607abf342ec4f301f471",
"El Jazzy Chavo/[2022] S950 Funk/08 - Cellar Groove.mp3": "743927c47df60e811d367f5ef969faaf", "El Jazzy Chavo/[2022] S950 Funk/08 - Cellar Groove.mp3": "a18ceff8785e8849d166eb2710caafa4",
"El Jazzy Chavo/[2022] S950 Funk/09 - Solar Wind.mp3": "dd97643e3dc5decefb543da3a6e31356", "El Jazzy Chavo/[2022] S950 Funk/09 - Solar Wind.mp3": "a41665337e3fe05d7c7d9d8e6ddde737",
"El Jazzy Chavo/[2022] S950 Funk/10 - Interlune II.mp3": "37717cbd8f837657047616743937595b", "El Jazzy Chavo/[2022] S950 Funk/10 - Interlune II.mp3": "8d2f3158ac9bac51aafbdbe6eb43154b",
"El Jazzy Chavo/[2022] S950 Funk/11 - Ghetto Anthem.mp3": "54089f39836d469f391f6850926a392d", "El Jazzy Chavo/[2022] S950 Funk/11 - Ghetto Anthem.mp3": "39085bae1b0f8544acd08f74f88cacfd",
"El Jazzy Chavo/[2022] S950 Funk/12 - Street Degree.mp3": "ddd3dc74d9bc07b988e4e4da363624dd", "El Jazzy Chavo/[2022] S950 Funk/12 - Street Degree.mp3": "561f0d8fbc62f87f2149c17ee0a53ce2",
"El Jazzy Chavo/[2022] S950 Funk/13 - Trouble World.mp3": "90dacf561b52852312f9e3f06084bfca", "El Jazzy Chavo/[2022] S950 Funk/13 - Trouble World.mp3": "1ad07dc6a2d6ced78449bc46a2909335",
"El Jazzy Chavo/[2022] S950 Funk/14 - Relaxation.mp3": "12dc503e0f758ae0b962e941727f272b", "El Jazzy Chavo/[2022] S950 Funk/14 - Relaxation.mp3": "ba8addb0d59f26a84bbb79a6cdd1af11",
"El Jazzy Chavo/[2022] S950 Funk/15 - Journey From Within.mp3": "c593e610ac6853b3795781b6650fc59e", "El Jazzy Chavo/[2022] S950 Funk/15 - Journey From Within.mp3": "bd97135290482c6bbe6eac7d3a700f11",
"El Jazzy Chavo/[2022] S950 Funk/folder.jpg": "62e51365dd309e76a6323f927f6fcc94" "El Jazzy Chavo/[2022] S950 Funk/folder.jpg": "62e51365dd309e76a6323f927f6fcc94"
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-chapters_from_comments-download-archive.json": "122723ce8d257eebb05178daa26141f6", ".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]-thumb.jpg": "c12e6a6f242680d1096a1a99d74a62c6",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "207f8c8aa7a383aa1acb99a6836f2fb6", "JMC/JMC - Move 78 - Automated Improvisation [Full Album].info.json": "31d3e187632cffa84879027c9c7ddb2f",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "40a74798dddf486a656c761516eb0528", "JMC/JMC - Move 78 - Automated Improvisation [Full Album].mp4": "b82e8eb3f912a4ee2b14d90332fdce89",
"JMC/JMC - Move 78 - Automated Improvisation [Full Album].nfo": "7a65b184d24c68fc0ec5380432250f5b" "JMC/JMC - Move 78 - Automated Improvisation [Full Album].nfo": "7a65b184d24c68fc0ec5380432250f5b"
} }

View file

@ -1,14 +1,14 @@
{ {
"Project Zombie/.ytdl-sub-recent-download-archive.json": "ca7275efd7f2ebe489b8ca4007dc5361", "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-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.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-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.info.json": "e143a26148069d0477dc45896afba93e",
"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.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
"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.nfo": "c2f06f9eb5b42c777808a35587e5941e",
"Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee", "Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
"Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73", "Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
"Project Zombie/tvshow.nfo": "3adb1769f4ca5a0c4dd59a9f662a4780" "Project Zombie/tvshow.nfo": "bc3d17c23371da3af6db1108faea5a84"
} }

View file

@ -1,10 +1,10 @@
{ {
"Project Zombie/.ytdl-sub-recent-download-archive.json": "46170edaf7cc51e5f59ef5d4c0476200", "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-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.info.json": "e143a26148069d0477dc45896afba93e",
"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.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
"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.nfo": "c2f06f9eb5b42c777808a35587e5941e",
"Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee", "Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
"Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73", "Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
"Project Zombie/tvshow.nfo": "3adb1769f4ca5a0c4dd59a9f662a4780" "Project Zombie/tvshow.nfo": "bc3d17c23371da3af6db1108faea5a84"
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-file_convert_test-download-archive.json": "65aa49c7345f9fc201e42ddc4a96b19b", ".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-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.mp4": "390177bcc076e309d1534b8bb5afdcc7",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a" "Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a"
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-file_convert_test-download-archive.json": "74813dccf4e9732e49f5dc3c2d66f3be", ".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-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.mkv": "b5f248b560f89f3f2a83fcdcd197d486",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a" "Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a"
} }

View file

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

View file

@ -1,15 +1,15 @@
{ {
".ytdl-sub-split_by_chapters_video-download-archive.json": "c3fb0b4f31caaa10ac7954ea93da33c4", ".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]/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": "d5432e6a4f7af9810b0e7c8eebe4898a", "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": "cf03da6688a73373f9295dc595156e11", "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": "b7b943b6f3395c05433b8532364d63d6", "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": "0d9719268900d4abcd8c0f51ad3af92c", "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": "bbd9ec616a0f7d3c5d13c04bb118681e", "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": "2e59ecdc0f8050bcf190a7898d7ae968", "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": "e58966f3326b876f0ab97459b681f76f", "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": "d6939215b73457fce4e026f8c9b57ecc", "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": "82d0e7d8070fd15d71c2e11c0285d426", "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": "3edb11e8cd5f270dd42ee62d9a9aa4ff", "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" "Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
} }

View file

@ -1,5 +1,5 @@
{ {
".ytdl-sub-split_by_chapters_with_regex_video-download-archive.json": "4008e43668447f1a3a6a55520a6ff475", ".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" "Project Zombie/[2010] Oblivion Mod Falcor p.1/folder.jpg": "fb95b510681676e81c321171fc23143e"
} }

View file

@ -1,15 +1,15 @@
{ {
".ytdl-sub-split_by_chapters_with_regex_video-download-archive.json": "9798e8289742586d0efd295a97c6c906", ".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/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "c10cff170976c3c5139f6073eeb3e25e",
"Alfa Mist/[2017] Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "5ee032da9965c51913fcfa0319d061bd", "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": "82c1f35bdccfb4ec146f33661ac94d6b", "Alfa Mist/[2017] Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "aa5c6fbab58dd2be2660bfb65be6a172",
"Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "040b38c249e478d8832dcea8bfeca17f", "Alfa Mist/[2017] Nocturne/04 - What If (Interlude).mp3": "a64ce02ed1188276c9744f92cfc81e8e",
"Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "06f7e57fecdc7b4d2e37c7652791ce19", "Alfa Mist/[2017] Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "a6dbe96141515caa32f3df4f66e46f75",
"Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "91a03449c33bf9dcff5a9654a8525626", "Alfa Mist/[2017] Nocturne/06 - Closer (Feat. Lester Duval).mp3": "9b8ff5357109c376cce4bdd25e724d7b",
"Alfa Mist/[2017] Nocturne/07 - Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "54c26621b8d4e74f37217c836479a077", "Alfa Mist/[2017] Nocturne/07 - Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "022b077892b838c478c65ba3b5f00b55",
"Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "2704327ac5998086e77a77da164e2afd", "Alfa Mist/[2017] Nocturne/08 - Dreams (Feat. Carmody).mp3": "75c9c0f789c378c45bb176aa6cc6ec57",
"Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "75b8a26d3099aa2d9ba488b33c4eb1e7", "Alfa Mist/[2017] Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "af59a3ac869249ac6ef2586fa6c59009",
"Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "0bf0979c99c55ef986efaba55c0dc858", "Alfa Mist/[2017] Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "27d36f90dc87264c57e88f3663b29467",
"Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "78950fbfe459de8e9aa951ab6fa153bf", "Alfa Mist/[2017] Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "be7085f9a536dfd2f70ce001c65a416c",
"Alfa Mist/[2017] Nocturne/folder.jpg": "bd3685acc53072e591bae2505ecb0648" "Alfa Mist/[2017] Nocturne/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75", ".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75",
"Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "5657c5b92f8980b20d8bbee0fdc7e5d8", "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": "a2a3a34e02e26a6c0265530d4499473b", "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": "b2d6388b4ddf8e3fbca042cb123672c3", "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" "Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530"
} }

View file

@ -1,5 +1,5 @@
{ {
".ytdl-sub-single_song_test-download-archive.json": "c8ff22ec3304c9f8dab18cedaed4e8b4", ".ytdl-sub-single_song_test-download-archive.json": "c8ff22ec3304c9f8dab18cedaed4e8b4",
"YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/01 - YouTube Rewind 2019 For the Record #YouTubeRewind.mp3": "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" "YouTube/[2019] YouTube Rewind 2019 For the Record #YouTubeRewind/folder.jpg": "50ee47c80f679029f5d3503bb91b045a"
} }

View file

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

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-sponsorblock_with_embedded_subs_test-download-archive.json": "40abc64a8fe93a10406bff1548a2cbe2", ".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-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.info.json": "324f582f8a69d3844019e650b382773f",
"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.mp4": "20a9ddbececcc2c75d6df06e88007b5c",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "b9bd35e4f260c728774d8dd31f83637c" "JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "b9bd35e4f260c728774d8dd31f83637c"
} }

View file

@ -1,7 +1,7 @@
{ {
".ytdl-sub-subtitles_embedded_test-download-archive.json": "1aa35c1d2663ac721f33f8374c00af1a", ".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-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.mp4": "1f1f91f85b162c20596a2413e704b809",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48" "JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48"
} }

View file

@ -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-thumb.jpg": "50ee47c80f679029f5d3503bb91b045a",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.de.srt": "b343c3bb9257b7ee7ba38f570a115b37", "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.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.mp4": "1f1f91f85b162c20596a2413e704b809",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48" "JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48"
} }

View file

@ -1,19 +1,19 @@
{ {
".ytdl-sub-jb-download-archive.json": "1a99156e9ece62539fb2608416a07200", ".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] 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/[2021] Purple Clouds/folder.jpg": "967892be44b8c47e1be73f055a7c6f08",
"j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "768e148a2b24ed2de299ca4899d8e533", "j_b/[2022] Acoustic Treats/01 - 20160426 184214.mp3": "bcc88eb579311b59ede4c13cdf24bcb9",
"j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "a9cd2d2176f94fdad8965c1fbbc3396a", "j_b/[2022] Acoustic Treats/02 - 20160502 123150.mp3": "239d10c17047662c760cce834548d91d",
"j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "f3c74e7e8595505d15a384226d0c906e", "j_b/[2022] Acoustic Treats/03 - 20160504 143832.mp3": "2d6e977e3e665012fdeaa0e8c2ee7375",
"j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "d13a3008d9a39da3e5fdcd0c279718e8", "j_b/[2022] Acoustic Treats/04 - 20160601 221234.mp3": "9da5b26fc231dfd0c8f09a2a3d2fe116",
"j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "3a40d1108c4f6a0d004eea2151001d66", "j_b/[2022] Acoustic Treats/05 - 20160601 222440.mp3": "8fb1cb262175921dbdddd6783664fa65",
"j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "8224406bb95e100f4ebcdf7fec3db1c0", "j_b/[2022] Acoustic Treats/06 - 20170604 190236.mp3": "529846e26a18be616deb3f12d858b8ea",
"j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "1ea96a8581eb81e45564617f75bd467e", "j_b/[2022] Acoustic Treats/07 - 20170612 193646.mp3": "ba2be1ace7b0c31964bf0b8b15781b27",
"j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "df14e3b41386cf408b4d1549428a17a5", "j_b/[2022] Acoustic Treats/08 - 20170628 215206.mp3": "ad248c0392ced4f6d548a4852fd99f1f",
"j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "f52df647e1f5cf15df720d62b5ad64b1", "j_b/[2022] Acoustic Treats/09 - Finding Home.mp3": "52326cbd96e6f243cae77b0f68f00ef6",
"j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "429ac1c752822230803c1a452987e492", "j_b/[2022] Acoustic Treats/10 - Shallow Water WIP.mp3": "a265ed7e4d3a014d09e79ac5f4fe3fc7",
"j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "9322445a6a7cdd506038b8714cb1731f", "j_b/[2022] Acoustic Treats/11 - Untold History.mp3": "94d548773dded0089e73b26e6f923d9a",
"j_b/[2022] Acoustic Treats/folder.jpg": "967892be44b8c47e1be73f055a7c6f08" "j_b/[2022] Acoustic Treats/folder.jpg": "967892be44b8c47e1be73f055a7c6f08"
} }

View file

@ -1,20 +1,20 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059", "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-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.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-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.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-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.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-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.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/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039" "Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
} }

View file

@ -1,36 +1,36 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "2bf8edeaf8b5658c4a42a9faa9bb2f60", "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-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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.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/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039" "Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
} }

View file

@ -1,22 +1,22 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "456d8882fc5e35d74f19b386d1d9a059", "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-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.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-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.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-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.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-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.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/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a", "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"
} }

View file

@ -1,38 +1,38 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "2bf8edeaf8b5658c4a42a9faa9bb2f60", "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-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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.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/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a", "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"
} }

View file

@ -1,20 +1,20 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b", "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-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.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-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.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-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.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-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.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/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039" "Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
} }

View file

@ -1,36 +1,36 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "126aec3c19ad3a56faceed442515faf5", "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-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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.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/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039" "Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
} }

View file

@ -1,22 +1,22 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "e30d83126c2c6981c936ed894a6f159b", "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-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.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-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.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-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.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-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.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/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a", "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"
} }

View file

@ -1,38 +1,38 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "126aec3c19ad3a56faceed442515faf5", "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-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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.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/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a", "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"
} }

View file

@ -1,20 +1,20 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "05539b932348da7e949a63eb71f77efc", "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-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.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-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.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-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.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-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.mp4": "e94af7eed1fe7d2512d042979ca878dd",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "7f90134d9346efe476cc5a1e531f8ecd", "Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039" "Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
} }

View file

@ -1,36 +1,36 @@
{ {
"Best Prebuilt TV Show by Date/.ytdl-sub-subscription_test-download-archive.json": "6baee42e9095883c0ed77e6b5829e2c2", "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-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.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-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.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-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.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-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.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-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.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-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.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-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.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-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.mp4": "e94af7eed1fe7d2512d042979ca878dd",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "7f90134d9346efe476cc5a1e531f8ecd", "Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039" "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