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

View file

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

View file

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

View file

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

View file

@ -165,6 +165,15 @@ date_range
:member-order: bysource
:exclude-members: partial_validate
-------------------------------------------------------------------------------
embed_thumbnail
''''''''''''''''
.. autoclass:: ytdl_sub.plugins.embed_thumbnail.EmbedThumbnailOptions()
-------------------------------------------------------------------------------
file_convert
''''''''''''
.. autoclass:: ytdl_sub.plugins.file_convert.FileConvertOptions()
@ -185,8 +194,6 @@ match_filters
music_tags
''''''''''
.. autoclass:: ytdl_sub.plugins.music_tags.MusicTagsOptions()
:members:
:exclude-members: partial_validate
-------------------------------------------------------------------------------
@ -213,8 +220,8 @@ regex
.. autoclass:: ytdl_sub.plugins.regex.RegexOptions()
:members: skip_if_match_fails
.. autoclass:: ytdl_sub.plugins.regex.SourceVariableRegex()
:members: match, capture_group_names, capture_group_defaults
.. autoclass:: ytdl_sub.plugins.regex.VariableRegex()
:members: match, capture_group_names, capture_group_defaults, exclude
:member-order: bysource
:exclude-members: partial_validate
@ -241,8 +248,6 @@ subtitles
video_tags
''''''''''
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions()
:members:
:exclude-members: partial_validate
-------------------------------------------------------------------------------
@ -306,6 +311,32 @@ by using the file-wide ``__preset__``:
This ``subscription.yaml`` is equivalent to the one above it because all
subscriptions automatically set ``__preset__`` as a `parent preset`_.
File Subscription Value
^^^^^^^^^^^^^^^^^^^^^^^
With a clever config and use of ``__preset__``, your subscriptions can typically boil
down to a name and url. You can set ``__value__`` to the name of an override variable,
and use the override variable ``subscription_name`` to achieve one-liner subscriptions.
Using the example above, we can do:
.. code-block:: yaml
:caption: subscription.yaml
__preset__:
preset: "playlist_preset_ex"
overrides:
playlist_name: "{subscription_name}"
__value__: "url"
# single-line subscription
"diy-playlist": "https://youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg"
``"diy-playlist"`` gets assigned to the ``playlist_name`` override variable by setting
it with ``subscription_name`` , and the url gets assigned to ``url`` by setting ``__value__``
to write values to it.
Traditional subscriptions that can override presets will still work when using ``__value__``.
-------------------------------------------------------------------------------
.. _source-variables:
@ -318,6 +349,12 @@ Source Variables
:inherited-members:
:undoc-members:
Override Variables
------------------
.. autoclass:: ytdl_sub.config.preset_options.OverridesVariables()
:members:
-------------------------------------------------------------------------------
Config Types

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
presets
config
deprecation_notices

View file

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

View file

@ -64,17 +64,19 @@ presets:
# It is recommended to keep most of this as-is, and use override
# variables to set them to be what you want.
music_tags:
tags:
artist: "{track_artist}"
artists: "{track_artist}"
albumartist: "{track_artist}"
albumartists: "{track_artist}"
title: "{track_title}"
album: "{track_album}"
track: "{track_number}"
tracktotal: "{track_total}"
year: "{track_year}"
genre: "{track_genre}"
artist: "{track_artist}"
artists: "{track_artist}"
albumartist: "{track_artist}"
albumartists: "{track_artist}"
title: "{track_title}"
album: "{track_album}"
track: "{track_number}"
tracktotal: "{track_total}"
year: "{track_year}"
genre: "{track_genre}"
# Optionally embed the thumbnail into the track
embed_thumbnail: False
# For every configurable field, make it an override variable,
# so we can carefully tune different use-cases by only modifying override variables.
@ -108,7 +110,6 @@ presets:
preset: "base"
download:
download_strategy: "url"
url: "{url}"
####################################################################################################
@ -164,27 +165,25 @@ presets:
# Download using the multi_url strategy
download:
download_strategy: "multi_url"
urls:
# The first URL will be all the artist's tracks.
# Treat these as singles - an album with a single track
- url: "{sc_artist_url}/tracks"
variables:
sc_track_album: "{title}"
sc_track_number: "1"
sc_track_number_padded: "01"
sc_track_total: "1"
sc_track_year: "{upload_year}"
# Set the second URL to the artist's albums. If a track belongs to both
# to an album and tracks (in the URL above), it will resolve to this
# URL and include the album metadata we set below.
- url: "{sc_artist_url}/albums"
variables:
sc_track_album: "{playlist_title}"
sc_track_number: "{playlist_index}"
sc_track_number_padded: "{playlist_index_padded}"
sc_track_total: "{playlist_count}"
sc_track_year: "{playlist_max_upload_year}"
# The first URL will be all the artist's tracks.
# Treat these as singles - an album with a single track
- url: "{sc_artist_url}/tracks"
variables:
sc_track_album: "{title}"
sc_track_number: "1"
sc_track_number_padded: "01"
sc_track_total: "1"
sc_track_year: "{upload_year}"
# Set the second URL to the artist's albums. If a track belongs to both
# to an album and tracks (in the URL above), it will resolve to this
# URL and include the album metadata we set below.
- url: "{sc_artist_url}/albums"
variables:
sc_track_album: "{playlist_title}"
sc_track_number: "{playlist_index}"
sc_track_number_padded: "{playlist_index_padded}"
sc_track_total: "{playlist_count}"
sc_track_year: "{playlist_max_upload_year}"
# Override various track properties using playlist variables.
overrides:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,3 +14,7 @@ else:
DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe"
MAX_FILE_NAME_BYTES = os.pathconf("/", "PC_NAME_MAX")
# Historically was hardcoded to this value. Use this as the default
# if download_archive_path is not specified
DEFAULT_DOWNLOAD_ARCHIVE_NAME = ".ytdl-sub-{subscription_name}-download-archive.json"

View file

@ -1,17 +1,16 @@
from abc import ABC
from abc import abstractmethod
from typing import Dict
from typing import Generic
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypeVar
from ytdl_sub.config.preset_options import OptionsValidator
from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.config.preset_options import TOptionsValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -24,7 +23,15 @@ class PluginPriority:
# If modify_entry priority is >= to this value, run after split
MODIFY_ENTRY_AFTER_SPLIT = 10
def __init__(self, modify_entry: int = 0, post_process: int = 0):
# if post_process is >= to this value, run after file_convert
POST_PROCESS_AFTER_FILE_CONVERT = 10
MODIFY_ENTRY_FIRST = 0
def __init__(
self, modify_entry_metadata: int = 5, modify_entry: int = 5, post_process: int = 5
):
self.modify_entry_metadata = modify_entry_metadata
self.modify_entry = modify_entry
self.post_process = post_process
@ -38,34 +45,33 @@ class PluginPriority:
return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT
PluginOptions = OptionsValidator
PluginOptionsT = TypeVar("PluginOptionsT", bound=PluginOptions)
# pylint: disable=no-self-use,unused-argument
class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
class BasePlugin(DownloadArchiver, Generic[TOptionsValidator], ABC):
"""
Class to define the new plugin functionality
Shared code amongst all SourcePlugins (downloaders) and Plugins (post-download modification)
"""
plugin_options_type: Type[PluginOptionsT] = NotImplemented
priority: PluginPriority = PluginPriority()
# If the plugin creates multiple entries from a single entry
is_split_plugin: bool = False
plugin_options_type: Type[TOptionsValidator]
def __init__(
self,
plugin_options: PluginOptionsT,
options: TOptionsValidator,
overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive,
):
DownloadArchiver.__init__(self=self, enhanced_download_archive=enhanced_download_archive)
self.plugin_options = plugin_options
self.plugin_options = options
self.overrides = overrides
# TODO pass yaml snake case name in the class somewhere, and use it for the logger
self._logger = Logger.get(self.__class__.__name__)
# pylint: disable=no-self-use,unused-argument
class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
"""
Class to define the new plugin functionality
"""
def ytdl_options(self) -> Optional[Dict]:
"""
Returns
@ -73,22 +79,6 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
ytdl options to enable/disable when downloading entries for this specific plugin
"""
def split(self, entry: Entry) -> List[Tuple[Entry, FileMetadata]]:
"""
Very specialized function that takes an entry and creates multiple entries from it.
Should mark ``is_split_plugin`` on the plugin class.
Parameters
----------
entry
Entry to create multiple entries from
Returns
-------
List of entries and metadata created from the source entry
"""
return []
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
"""
After entry metadata has been gathered, perform preprocessing on the metadata
@ -135,9 +125,30 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
"""
return None
# pylint: enable=no-self-use,unused-argument
def post_process_subscription(self):
"""
After all downloaded files have been post-processed, apply a subscription-wide post process
"""
class SplitPlugin(Plugin[TOptionsValidator], Generic[TOptionsValidator], ABC):
"""
Plugin that splits entries into zero or more entries
"""
@abstractmethod
def split(self, entry: Entry) -> List[Tuple[Entry, FileMetadata]]:
"""
Very specialized function that takes an entry and creates multiple entries from it.
Should mark ``is_split_plugin`` on the plugin class.
Parameters
----------
entry
Entry to create multiple entries from
Returns
-------
List of entries and metadata created from the source entry
"""
return []

View file

@ -11,17 +11,15 @@ from typing import Union
from mergedeep import mergedeep
from ytdl_sub.config.config_validator import ConfigValidator
from ytdl_sub.config.preset_class_mappings import DownloadStrategyMapping
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_class_mappings import PluginMapping
from ytdl_sub.config.preset_options import OptionsValidator
from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.config.preset_options import TOptionsValidator
from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.downloaders.base_downloader import BaseDownloader
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.plugins.plugin import PluginOptionsT
from ytdl_sub.prebuilt_presets import PREBUILT_PRESET_NAMES
from ytdl_sub.prebuilt_presets import PUBLISHED_PRESET_NAMES
from ytdl_sub.utils.exceptions import ValidationException
@ -35,16 +33,15 @@ from ytdl_sub.validators.string_formatter_validators import StringFormatterValid
from ytdl_sub.validators.validators import DictValidator
from ytdl_sub.validators.validators import ListValidator
from ytdl_sub.validators.validators import StringListValidator
from ytdl_sub.validators.validators import StringValidator
from ytdl_sub.validators.validators import Validator
from ytdl_sub.validators.validators import validation_exception
PRESET_KEYS = {
"preset",
"download",
"output_options",
"ytdl_options",
"overrides",
*DownloadStrategyMapping.sources(),
*PluginMapping.plugins(),
}
@ -67,9 +64,9 @@ def _parent_preset_error_message(
class PresetPlugins:
def __init__(self):
self.plugin_types: List[Type[Plugin]] = []
self.plugin_options: List[PluginOptions] = []
self.plugin_options: List[OptionsValidator] = []
def add(self, plugin_type: Type[Plugin], plugin_options: PluginOptions) -> "PresetPlugins":
def add(self, plugin_type: Type[Plugin], plugin_options: OptionsValidator) -> "PresetPlugins":
"""
Add a pair of plugin type and options to the list
"""
@ -77,7 +74,7 @@ class PresetPlugins:
self.plugin_options.append(plugin_options)
return self
def zipped(self) -> Iterable[Tuple[Type[Plugin], PluginOptions]]:
def zipped(self) -> Iterable[Tuple[Type[Plugin], OptionsValidator]]:
"""
Returns
-------
@ -85,7 +82,7 @@ class PresetPlugins:
"""
return zip(self.plugin_types, self.plugin_options)
def get(self, plugin_type: Type[PluginOptionsT]) -> Optional[PluginOptionsT]:
def get(self, plugin_type: Type[TOptionsValidator]) -> Optional[TOptionsValidator]:
"""
Parameters
----------
@ -102,49 +99,6 @@ class PresetPlugins:
return None
class DownloadStrategyValidator(StrictDictValidator):
"""
Ensures a download strategy exists for a source. Does not validate any more than that.
The respective Downloader's option validator will do that.
"""
# All media sources must define a download strategy
_required_keys = {"download_strategy"}
# Extra fields will be strict-validated using other StictDictValidators
_allow_extra_keys = True
def __init__(self, name: str, value: Any):
super().__init__(name=name, value=value)
self.download_strategy_name = self._validate_key(
key="download_strategy",
validator=StringValidator,
).value
def get(self, downloader_source: str) -> Type[BaseDownloader]:
"""
Parameters
----------
downloader_source:
Name of the download source
Returns
-------
The downloader class
Raises
------
ValidationException
If the download strategy is invalid
"""
try:
return DownloadStrategyMapping.get(
source=downloader_source, download_strategy=self.download_strategy_name
)
except ValueError as value_exc:
raise self._validation_exception(error_message=value_exc)
class _PresetShell(StrictDictValidator):
# Have all present keys optional since parent presets could not have all the
# required keys. They will get validated in the init after the mergedeep of dicts
@ -153,70 +107,6 @@ class _PresetShell(StrictDictValidator):
class Preset(_PresetShell):
@classmethod
def _validate_download_strategy(
cls, config: ConfigValidator, name: str, value: Dict, parent_presets: StringListValidator
) -> None:
sources: List[str] = []
for source_name in DownloadStrategyMapping.sources():
if source_name in value:
sources.append(source_name)
if len(sources) > 1:
raise validation_exception(
name=name,
error_message=f"Contains the sources {', '.join(sources)} but can only have one",
)
# If no sources, nothing more to validate
if not sources:
return
# Ensure all parent/child presets use the same download strategy
preset_strs = [preset.value for preset in parent_presets.list] + [name]
download_strategy: Optional[str] = None
download_strategy_preset: Optional[str] = None
for parent_preset in preset_strs:
# See if the parent has a download strategy
parent_download_strategy = (
config.presets.dict.get(parent_preset, {})
.get("download", {})
.get("download_strategy")
)
# If the parent download strategy is None, do nothing
if parent_download_strategy is None:
continue
# If download strategy is not set, then set it to parent download strategy
if download_strategy is None:
download_strategy = parent_download_strategy
download_strategy_preset = parent_preset
# If it's set and does not match the parent download strategy, error
if download_strategy is not None and download_strategy != parent_download_strategy:
raise ValidationException(
f"Preset {download_strategy_preset} uses download strategy {download_strategy}"
f", but is inherited by preset {parent_preset} which uses download strategy "
f"{parent_download_strategy}."
)
source_name = sources[0]
source_dict = copy.deepcopy(value[source_name])
# Set the parent download strategy if present
if download_strategy:
source_dict["download_strategy"] = download_strategy
downloader = DownloadStrategyValidator(name=f"{name}.{source_name}", value=source_dict).get(
downloader_source=source_name
)
del source_dict["download_strategy"]
downloader.downloader_options_type.partial_validate(
name=f"{name}.{source_name}", value=source_dict
)
@classmethod
def preset_partial_validate(cls, config: ConfigValidator, name: str, value: Any) -> None:
"""
@ -251,9 +141,7 @@ class Preset(_PresetShell):
presets=config.presets.keys,
)
cls._validate_download_strategy(
config=config, name=name, value=value, parent_presets=parent_presets
)
cls._partial_validate_key(name, value, "download", MultiUrlValidator)
cls._partial_validate_key(name, value, "output_options", OutputOptions)
cls._partial_validate_key(name, value, "ytdl_options", YTDLOptions)
cls._partial_validate_key(name, value, "overrides", Overrides)
@ -270,55 +158,6 @@ class Preset(_PresetShell):
def _source_variables(self) -> List[str]:
return Entry.source_variables()
def __validate_and_get_downloader(self, downloader_source: str) -> Type[BaseDownloader]:
return self._validate_key(key=downloader_source, validator=DownloadStrategyValidator).get(
downloader_source=downloader_source
)
def __validate_and_get_downloader_options(
self, downloader_source: str, downloader: Type[BaseDownloader]
) -> BaseDownloaderValidator:
# Remove the download_strategy key before validating it against the downloader options
# TODO: make this cleaner
del self._dict[downloader_source]["download_strategy"]
return self._validate_key(
key=downloader_source, validator=downloader.downloader_options_type
)
def __validate_and_get_downloader_and_options(
self,
) -> Tuple[Type[BaseDownloader], BaseDownloaderValidator]:
downloader: Optional[Type[BaseDownloader]] = None
download_options: Optional[BaseDownloaderValidator] = None
downloader_sources = DownloadStrategyMapping.sources()
for key in self._keys:
# skip if the key is not a download source
if key not in downloader_sources:
continue
# Ensure there are not multiple sources, i.e. youtube and soundcloud
if downloader:
raise self._validation_exception(
f"'{self._name}' can only have one of the following sources: "
f"{', '.join(downloader_sources)}"
)
downloader = self.__validate_and_get_downloader(downloader_source=key)
download_options = self.__validate_and_get_downloader_options(
downloader_source=key, downloader=downloader
)
# If downloader was not set, error since it is required
if not downloader:
raise self._validation_exception(
f"'{self._name} must have one of the following sources: "
f"{', '.join(downloader_sources)}"
)
return downloader, download_options
def __validate_and_get_plugins(self) -> PresetPlugins:
preset_plugins = PresetPlugins()
@ -477,7 +316,9 @@ class Preset(_PresetShell):
# Perform the merge of parent presets before validating any keys
self.__merge_parent_preset_dicts_if_present(config=config)
self.downloader, self.downloader_options = self.__validate_and_get_downloader_and_options()
self.downloader_options: MultiUrlValidator = self._validate_key(
key="download", validator=MultiUrlValidator
)
self.output_options = self._validate_key(
key="output_options",

View file

@ -2,102 +2,23 @@ from typing import Dict
from typing import List
from typing import Type
from ytdl_sub.downloaders.base_downloader import BaseDownloader
from ytdl_sub.downloaders.url.multi_url import MultiUrlDownloader
from ytdl_sub.downloaders.url.url import UrlDownloader
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.plugins.audio_extract import AudioExtractPlugin
from ytdl_sub.plugins.chapters import ChaptersPlugin
from ytdl_sub.plugins.date_range import DateRangePlugin
from ytdl_sub.plugins.embed_thumbnail import EmbedThumbnailPlugin
from ytdl_sub.plugins.file_convert import FileConvertPlugin
from ytdl_sub.plugins.internal.view import ViewPlugin
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
from ytdl_sub.plugins.music_tags import MusicTagsPlugin
from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.regex import RegexPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin
class DownloadStrategyMapping:
"""
Maps downloader strategies defined in the preset to its respective downloader class
"""
_MAPPING: Dict[str, Dict[str, Type[BaseDownloader]]] = {
"download": {
"multi_url": MultiUrlDownloader,
"url": UrlDownloader,
},
}
@classmethod
def sources(cls) -> List[str]:
"""
Returns
-------
Available download sources
"""
return sorted(list(cls._MAPPING.keys()))
@classmethod
def _validate_is_source(cls, source: str) -> None:
"""
Ensure the source exists
"""
if source not in cls.sources():
raise ValueError(
f"Tried to use source '{source}' which does not exist. Available sources: "
f"{', '.join(cls.sources())}"
)
@classmethod
def source_download_strategies(cls, source: str) -> List[str]:
"""
Parameters
----------
source
Name of the source
Returns
-------
Available download strategies for the given source
"""
cls._validate_is_source(source)
return sorted(list(cls._MAPPING[source].keys()))
@classmethod
def _validate_is_download_strategy(cls, source: str, download_strategy: str) -> None:
"""
Ensure the download strategy for the given source exists
"""
if download_strategy not in cls.source_download_strategies(source):
raise ValueError(
f"Tried to use download strategy '{download_strategy}' with source '{source}', "
f"which does not exist. Available download strategies: "
f"{', '.join(cls.source_download_strategies(source))}"
)
@classmethod
def get(cls, source: str, download_strategy: str) -> Type[BaseDownloader]:
"""
Parameters
----------
source:
The source (i.e. 'youtube', 'soundcloud') of the downloader
download_strategy:
The download strategy name
Returns
-------
The downloader class
"""
cls._validate_is_download_strategy(source, download_strategy)
return cls._MAPPING[source][download_strategy]
class PluginMapping:
"""
Maps plugins defined in the preset to its respective plugin class
@ -107,6 +28,7 @@ class PluginMapping:
"_view": ViewPlugin,
"audio_extract": AudioExtractPlugin,
"date_range": DateRangePlugin,
"embed_thumbnail": EmbedThumbnailPlugin,
"file_convert": FileConvertPlugin,
"match_filters": MatchFiltersPlugin,
"music_tags": MusicTagsPlugin,

View file

@ -3,9 +3,11 @@ from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import TypeVar
from yt_dlp.utils import sanitize_filename
from ytdl_sub.config.defaults import DEFAULT_DOWNLOAD_ARCHIVE_NAME
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.validators.file_path_validators import OverridesStringFormatterFilePathValidator
@ -17,11 +19,12 @@ from ytdl_sub.validators.string_formatter_validators import OverridesStringForma
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import Validator
# pylint: disable=no-self-use
# pylint: disable=unused-argument
class OptionsValidator(StrictDictValidator, ABC):
class OptionsValidator(Validator, ABC):
"""
Abstract class that validates options for preset sections (plugins, downloaders)
"""
@ -69,6 +72,13 @@ class OptionsValidator(StrictDictValidator, ABC):
return None
TOptionsValidator = TypeVar("TOptionsValidator", bound=OptionsValidator)
class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC):
pass
# pylint: enable=no-self-use
# pylint: enable=unused-argument
@ -110,9 +120,45 @@ class YTDLOptions(LiteralDictValidator):
"""
class OverridesVariables(DictFormatterValidator):
"""
Override variables that are automatically added to every subscription.
"""
def _add_override_variable(self, key_name: str, format_string: str, sanitize: bool = False):
if sanitize:
key_name = f"{key_name}_sanitized"
format_string = sanitize_filename(format_string)
self._value[key_name] = StringFormatterValidator(
name="__should_never_fail__",
value=format_string,
)
def __init__(self, name, value):
super().__init__(name, value)
# Add sanitized and non-sanitized override variables
for sanitize in [True, False]:
self._add_override_variable(
key_name="subscription_name",
format_string=self.subscription_name,
sanitize=sanitize,
)
@property
def subscription_name(self) -> str:
"""
Returns
-------
Name of the subscription
"""
return self._root_name
# Disable for proper docstring formatting
# pylint: disable=line-too-long
class Overrides(DictFormatterValidator):
class Overrides(OverridesVariables):
"""
Optional. This section allows you to define variables that can be used in any string formatter.
For example, if you want your file and thumbnail files to match without copy-pasting a large
@ -142,16 +188,6 @@ class Overrides(DictFormatterValidator):
# pylint: enable=line-too-long
def _add_override_variable(self, key_name: str, format_string: str, sanitize: bool = False):
if sanitize:
key_name = f"{key_name}_sanitized"
format_string = sanitize_filename(format_string)
self._value[key_name] = StringFormatterValidator(
name="__should_never_fail__",
value=format_string,
)
def __init__(self, name, value):
super().__init__(name, value)
@ -209,6 +245,7 @@ class OutputOptions(StrictDictValidator):
# optional
thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
info_json_name: "{title_sanitized}.{info_json_ext}"
download_archive_name: ".ytdl-sub-{subscription_name}-download-archive.json"
maintain_download_archive: True
keep_files_before: now
keep_files_after: 19000101
@ -218,7 +255,7 @@ class OutputOptions(StrictDictValidator):
_optional_keys = {
"thumbnail_name",
"info_json_name",
"subtitles_name",
"download_archive_name",
"maintain_download_archive",
"keep_files_before",
"keep_files_after",
@ -256,6 +293,12 @@ class OutputOptions(StrictDictValidator):
key="info_json_name", validator=StringFormatterFileNameValidator
)
self._download_archive_name = self._validate_key_if_present(
key="download_archive_name",
validator=OverridesStringFormatterValidator,
default=DEFAULT_DOWNLOAD_ARCHIVE_NAME,
)
self._maintain_download_archive = self._validate_key_if_present(
key="maintain_download_archive", validator=BoolValidator, default=False
)
@ -307,6 +350,14 @@ class OutputOptions(StrictDictValidator):
"""
return self._info_json_name
@property
def download_archive_name(self) -> Optional[OverridesStringFormatterValidator]:
"""
Optional. The file name to store a subscriptions download archive placed relative to
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
"""
return self._download_archive_name
@property
def maintain_download_archive(self) -> bool:
"""

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

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 os
from abc import ABC
from pathlib import Path
from typing import Dict
from typing import Iterable
@ -11,11 +9,12 @@ from typing import Optional
from typing import Set
from typing import Tuple
from yt_dlp.utils import RejectedVideoReached
from ytdl_sub.config.plugin import PluginPriority
from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.base_downloader import BaseDownloader
from ytdl_sub.downloaders.base_downloader import BaseDownloaderOptionsT
from ytdl_sub.downloaders.base_downloader import BaseDownloaderPlugin
from ytdl_sub.downloaders.base_downloader import BaseDownloaderValidator
from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.source_plugin import SourcePluginExtension
from ytdl_sub.downloaders.url.validators import MultiUrlValidator
from ytdl_sub.downloaders.url.validators import UrlThumbnailListValidator
from ytdl_sub.downloaders.url.validators import UrlValidator
@ -31,75 +30,41 @@ from ytdl_sub.entries.variables.kwargs import REQUESTED_SUBTITLES
from ytdl_sub.entries.variables.kwargs import SOURCE_ENTRY
from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS
from ytdl_sub.entries.variables.kwargs import UPLOAD_DATE_INDEX
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_MATCH_FILTER_REJECT
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.thumbnail import ThumbnailTypes
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
# pylint: disable=too-many-instance-attributes
download_logger = Logger.get(name="downloader")
class DownloaderValidator(BaseDownloaderValidator, ABC):
"""
Placeholder class to define downloader options
"""
@property
@abc.abstractmethod
def collection_validator(self) -> MultiUrlValidator:
"""
Returns
-------
MultiUrlValidator
To determine how the entries are downloaded
"""
def added_source_variables(self) -> List[str]:
"""
Returns
-------
Added source variables on the collection
"""
return self.collection_validator.added_source_variables()
def validate_with_variables(
self, source_variables: List[str], override_variables: Dict[str, str]
) -> None:
"""
Validates any source variables added by the collection
"""
self.collection_validator.validate_with_variables(
source_variables=source_variables, override_variables=override_variables
)
class URLDownloadState:
def __init__(self, entries_total: int):
self.entries_total = entries_total
self.entries_downloaded = 0
class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
priority = PluginPriority(modify_entry=0)
def __init__(
self,
downloader_options: DownloaderValidator,
options: MultiUrlValidator,
overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive,
):
super().__init__(
downloader_options=downloader_options,
options=options,
overrides=overrides,
enhanced_download_archive=enhanced_download_archive,
)
self._thumbnails_downloaded: Set[str] = set()
self._collection_url_mapping: Dict[str, UrlValidator] = {
self.overrides.apply_formatter(collection_url.url): collection_url
for collection_url in downloader_options.collection_validator.urls.list
for collection_url in options.urls.list
}
def _download_parent_thumbnails(
@ -117,11 +82,9 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
# If latest entry, always update the thumbnail on each entry
if thumbnail_id == ThumbnailTypes.LATEST_ENTRY:
# Make sure the entry's thumbnail is converted to jpg
convert_download_thumbnail(entry, error_if_not_found=False)
# always save in dry-run even if it doesn't exist...
if self.is_dry_run or os.path.isfile(entry.get_download_thumbnail_path()):
if self.is_dry_run or entry.is_thumbnail_downloaded():
self.save_file(
file_name=entry.get_download_thumbnail_name(),
output_file_name=thumbnail_name,
@ -173,8 +136,14 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
def modify_entry(self, entry: Entry) -> Optional[Entry]:
"""
Use the entry to download thumbnails (or move if LATEST_ENTRY)
Use the entry to download thumbnails (or move if LATEST_ENTRY).
In addition, convert the entry thumbnail to jpg
"""
# We always convert entry thumbnails to jpgs, and is performed here to be done
# as early as possible in the plugin pipeline (downstream plugins depend on it being jpg)
if not self.is_dry_run:
try_convert_download_thumbnail(entry=entry)
if entry.kwargs_get(COLLECTION_URL) in self._collection_url_mapping:
self._download_url_thumbnails(
collection_url=self._collection_url_mapping[entry.kwargs(COLLECTION_URL)],
@ -183,22 +152,24 @@ class UrlDownloaderThumbnailPlugin(BaseDownloaderPlugin):
return entry
class UrlDownloaderCollectionVariablePlugin(BaseDownloaderPlugin):
class UrlDownloaderCollectionVariablePlugin(SourcePluginExtension):
priority = PluginPriority(modify_entry_metadata=0)
def __init__(
self,
downloader_options: DownloaderValidator,
options: MultiUrlValidator,
overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive,
):
super().__init__(
downloader_options=downloader_options,
options=options,
overrides=overrides,
enhanced_download_archive=enhanced_download_archive,
)
self._thumbnails_downloaded: Set[str] = set()
self._collection_url_mapping: Dict[str, UrlValidator] = {
self.overrides.apply_formatter(collection_url.url): collection_url
for collection_url in downloader_options.collection_validator.urls.list
for collection_url in options.urls.list
}
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
@ -220,36 +191,14 @@ class UrlDownloaderCollectionVariablePlugin(BaseDownloaderPlugin):
return entry
class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
"""
Class that interacts with ytdl to perform the download of metadata and content,
and should translate that to list of Entry objects.
"""
@classmethod
def added_plugins(
cls,
downloader_options: BaseDownloaderOptionsT,
enhanced_download_archive: EnhancedDownloadArchive,
overrides: Overrides,
) -> List[Plugin]:
"""
Adds
1. URL thumbnail download plugin
2. Collection variable plugin to add to each entry
"""
return [
UrlDownloaderThumbnailPlugin(
downloader_options=downloader_options,
overrides=overrides,
enhanced_download_archive=enhanced_download_archive,
),
UrlDownloaderCollectionVariablePlugin(
downloader_options=downloader_options,
overrides=overrides,
enhanced_download_archive=enhanced_download_archive,
),
]
plugin_options_type = MultiUrlValidator
plugin_extensions = [UrlDownloaderThumbnailPlugin, UrlDownloaderCollectionVariablePlugin]
@classmethod
def ytdl_option_defaults(cls) -> Dict:
@ -263,7 +212,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
def __init__(
self,
download_options: BaseDownloaderOptionsT,
options: MultiUrlValidator,
enhanced_download_archive: EnhancedDownloadArchive,
download_ytdl_options: YTDLOptionsBuilder,
metadata_ytdl_options: YTDLOptionsBuilder,
@ -272,7 +221,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
"""
Parameters
----------
download_options
options
Options validator for this downloader
enhanced_download_archive
Download archive
@ -284,7 +233,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
Override variables
"""
super().__init__(
download_options=download_options,
options=options,
enhanced_download_archive=enhanced_download_archive,
download_ytdl_options=download_ytdl_options,
metadata_ytdl_options=metadata_ytdl_options,
@ -349,7 +298,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
@property
def collection(self) -> MultiUrlValidator:
"""Return the download options collection"""
return self.download_options.collection_validator
return self.plugin_options
@contextlib.contextmanager
def _separate_download_archives(self, clear_info_json_files: bool = False):
@ -401,7 +350,7 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
is_thumbnail_downloaded_fn=None
if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
else entry.is_thumbnail_downloaded,
else entry.is_thumbnail_downloaded_via_ytdlp,
url=entry.webpage_url,
)
return Entry(download_entry_dict, working_directory=self.working_directory)
@ -523,6 +472,11 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
Returns
-------
The entry that was downloaded successfully
Raises
------
RejectedVideoReached
If a video was rejected and was not from match_filter
"""
download_logger.info(
"Downloading entry %d/%d: %s",
@ -530,7 +484,17 @@ class BaseUrlDownloader(BaseDownloader[BaseDownloaderOptionsT], ABC):
self._url_state.entries_total,
entry.title,
)
download_entry = self._extract_entry_info_with_retry(entry=entry)
# Match-filters are applied at the download stage (not metadata stage).
# If the download is rejected, and match_filter is present in the ytdl options,
# then filter downstream in the match_filter plugin
try:
download_entry = self._extract_entry_info_with_retry(entry=entry)
except RejectedVideoReached:
if "match_filter" in self.download_ytdl_options:
entry.add_kwargs({YTDL_SUB_MATCH_FILTER_REJECT: True})
return entry
raise
upload_date_idx = self._enhanced_download_archive.mapping.get_num_entries_with_upload_date(
upload_date_standardized=entry.upload_date_standardized

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
class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
# TODO: Remove later - keep for docstring
class MultiUrlDownloadOptions(MultiUrlValidator):
"""
Downloads from multiple URLs. If an entry is returned from more than one URL, it will
resolve to the bottom-most URL settings.
@ -19,7 +15,6 @@ class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
my_example_preset:
download:
# required
download_strategy: "multi_url"
urls:
- url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
variables:
@ -40,29 +35,3 @@ class MultiUrlDownloadOptions(MultiUrlValidator, DownloaderValidator):
- name: "season{season_index}-poster.jpg"
uid: "latest_entry"
"""
@property
def collection_validator(self) -> MultiUrlValidator:
"""Returns itself!"""
return self
def validate_with_variables(
self, source_variables: List[str], override_variables: Dict[str, str]
) -> None:
"""
Validates any source variables added by the collection
"""
super().validate_with_variables(
source_variables=source_variables, override_variables=override_variables
)
has_non_empty_url = False
for url_validator in self.urls.list:
has_non_empty_url |= bool(url_validator.url.apply_formatter(override_variables))
if not has_non_empty_url:
raise self._validation_exception("Must contain at least one url that is non-empty")
class MultiUrlDownloader(BaseUrlDownloader[MultiUrlDownloadOptions]):
downloader_options_type = MultiUrlDownloadOptions

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
class UrlDownloadOptions(UrlValidator, DownloaderValidator):
# TODO: Remove later - keep for docstring
class UrlDownloadOptions(UrlValidator):
"""
Downloads from a single URL supported by yt-dlp.
@ -16,7 +14,6 @@ class UrlDownloadOptions(UrlValidator, DownloaderValidator):
my_example_preset:
download:
# required
download_strategy: "url"
url: "youtube.com/channel/UCsvn_Po0SmunchJYtttWpOxMg"
# optional
playlist_thumbnails:
@ -26,15 +23,3 @@ class UrlDownloadOptions(UrlValidator, DownloaderValidator):
uid: "banner_uncropped"
download_reverse: True
"""
@property
def collection_validator(self) -> MultiUrlValidator:
"""Returns itself!"""
return MultiUrlValidator(
name=self._name,
value={"urls": [self._value]},
)
class UrlDownloader(BaseUrlDownloader[UrlDownloadOptions]):
downloader_options_type = UrlDownloadOptions

View file

@ -1,3 +1,4 @@
import copy
from typing import Any
from typing import Dict
from typing import List
@ -75,9 +76,7 @@ class UrlValidator(StrictDictValidator):
@property
def url(self) -> OverridesStringFormatterValidator:
"""
Required. URL to download from, listed in priority from lowest (top) to highest (bottom).
If a download exists in more than one URL, it will resolve to the bottom-most one and
inherit those variables.
Required. URL to download from.
"""
return self._url
@ -147,8 +146,27 @@ class UrlValidator(StrictDictValidator):
return self._download_reverse.value
class UrlListValidator(ListValidator[UrlValidator]):
_inner_list_type = UrlValidator
class UrlStringOrDictValidator(UrlValidator):
"""
URL validator that supports a single string like:
download:
- "https://"
or
download:
- url: "https://"
"""
_expected_value_type = (dict, str)
def __init__(self, name, value):
super().__init__(name, {"url": value} if isinstance(value, str) else value)
class UrlListValidator(ListValidator[UrlStringOrDictValidator]):
_inner_list_type = UrlStringOrDictValidator
_expected_value_type_name = "collection url list"
def __init__(self, name, value):
@ -180,25 +198,38 @@ class MultiUrlValidator(OptionsValidator):
resolve to the bottom-most URL settings.
"""
_required_keys = {"urls"}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a collection
"""
if isinstance(value, dict):
value["urls"] = value.get("urls", [{"url": "placeholder"}])
value["url"] = value.get("url", "sadfasdf")
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
self._urls = self._validate_key(key="urls", validator=UrlListValidator)
# Copy since we're popping things
value_copy = copy.deepcopy(value)
if isinstance(value, dict):
# Pop old required field in case it's still there
value_copy.pop("download_strategy", None)
# Deal with old multi-url download strategy
if isinstance(value, dict) and "urls" in value_copy:
self._urls = UrlListValidator(name=name, value=value_copy["urls"])
else:
self._urls = UrlListValidator(name=name, value=value_copy)
@property
def urls(self) -> UrlListValidator:
"""
Required. A list of :ref:`url` with the addition of the ``variables`` attribute.
Multiple URLs should be listed in the order of priority, with the lowest priority being the
top-most, and highest priority being the bottom-most. If a download exists in more than
one URL, it will resolve to the bottom-most one (the highest priority) and
inherit those variables.
"""
return self._urls
@ -246,3 +277,11 @@ class MultiUrlValidator(OptionsValidator):
_ = StringFormatterValidator(
name=f"{self._name}.{source_var_name}", value=source_var_formatter_str
).apply_formatter(base_variables)
# Ensure at least URL is non-empty
has_non_empty_url = False
for url_validator in self.urls.list:
has_non_empty_url |= bool(url_validator.url.apply_formatter(override_variables))
if not has_non_empty_url:
raise self._validation_exception("Must contain at least one url that is non-empty")

View file

@ -86,10 +86,12 @@ class YTDLP:
If the entry fails to download
"""
num_tries = 0
entry_files_exist = False
copied_ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides)
while not entry_files_exist and num_tries < cls._EXTRACT_ENTRY_NUM_RETRIES:
is_downloaded = False
entry_dict: Optional[Dict] = None
while num_tries < cls._EXTRACT_ENTRY_NUM_RETRIES:
entry_dict = cls.extract_info(
ytdl_options_overrides=copied_ytdl_options_overrides, **kwargs
)
@ -102,6 +104,10 @@ class YTDLP:
if is_downloaded and is_thumbnail_downloaded:
return entry_dict
# Always add check_formats
# See https://github.com/yt-dlp/yt-dlp/issues/502
copied_ytdl_options_overrides["check_formats"] = True
# If the video file is downloaded but the thumbnail is not, then do not download
# the video again
if is_downloaded and not is_thumbnail_downloaded:
@ -111,7 +117,7 @@ class YTDLP:
time.sleep(cls._EXTRACT_ENTRY_RETRY_WAIT_SEC)
num_tries += 1
# Remove the download archive so it can retry without thinking its already downloaded,
# Remove the download archive to retry without thinking its already downloaded,
# even though it is not
if "download_archive" in copied_ytdl_options_overrides:
del copied_ytdl_options_overrides["download_archive"]
@ -123,6 +129,10 @@ class YTDLP:
cls._EXTRACT_ENTRY_NUM_RETRIES,
)
# Still return if the media file downloaded (thumbnail could be missing)
if is_downloaded and entry_dict is not None:
return entry_dict
error_dict = {"ytdl_options": ytdl_options_overrides, "kwargs": kwargs}
raise FileNotDownloadedException(
f"yt-dlp failed to download an entry with these arguments: {error_dict}"

View file

@ -54,7 +54,7 @@ class Entry(EntryVariables, BaseEntry):
"""Returns the entry's thumbnail's file path to where it was downloaded"""
return str(Path(self.working_directory()) / self.get_download_thumbnail_name())
def get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
def try_get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
"""
The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
not match. Return the actual downloaded thumbnail path.
@ -83,14 +83,23 @@ class Entry(EntryVariables, BaseEntry):
with open(self.get_download_info_json_path(), "w", encoding="utf-8") as file:
file.write(kwargs_json)
@final
def is_thumbnail_downloaded_via_ytdlp(self) -> bool:
"""
Returns
-------
True if ANY thumbnail file exist locally. False otherwise.
"""
return self.try_get_ytdlp_download_thumbnail_path() is not None
@final
def is_thumbnail_downloaded(self) -> bool:
"""
Returns
-------
True if the thumbnail file exist locally. False otherwise.
True if the thumbnail file exists and is its proper format. False otherwise.
"""
return self.get_ytdlp_download_thumbnail_path() is not None
return os.path.isfile(self.get_download_thumbnail_path())
@final
def is_downloaded(self) -> bool:

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_URL
from ytdl_sub.entries.variables.kwargs import PLAYLIST_WEBPAGE_URL
from ytdl_sub.entries.variables.kwargs import RELEASE_DATE
from ytdl_sub.entries.variables.kwargs import SOURCE_COUNT
from ytdl_sub.entries.variables.kwargs import SOURCE_DESCRIPTION
from ytdl_sub.entries.variables.kwargs import SOURCE_INDEX
@ -668,3 +669,189 @@ class EntryVariables(BaseEntryVariables):
The uploaded date formatted as YYYY-MM-DD
"""
return f"{self.upload_year}-{self.upload_month_padded}-{self.upload_day_padded}"
@property
def release_date(self: Self) -> str:
"""
Returns
-------
str
The entry's release date, in YYYYMMDD format. If not present, return the upload date.
"""
return self.kwargs_get(RELEASE_DATE, self.upload_date)
@property
def release_year(self: Self) -> int:
"""
Returns
-------
int
The entry's release year
"""
return int(self.release_date[:4])
@property
def release_year_truncated(self: Self) -> int:
"""
Returns
-------
int
The last two digits of the release year, i.e. 22 in 2022
"""
return int(str(self.release_year)[-2:])
@property
def release_year_truncated_reversed(self: Self) -> int:
"""
Returns
-------
int
The release year truncated, but reversed using ``100 - {release_year_truncated}``, i.e.
2022 returns ``100 - 22`` = ``78``
"""
return 100 - self.release_year_truncated
@property
def release_month_reversed(self: Self) -> int:
"""
Returns
-------
int
The release month, but reversed
using ``13 - {release_month}``, i.e. March returns ``10``
"""
return 13 - self.release_month
@property
def release_month_reversed_padded(self: Self) -> str:
"""
Returns
-------
str
The reversed release month, but padded. i.e. November returns "02"
"""
return pad(self.release_month_reversed)
@property
def release_month_padded(self: Self) -> str:
"""
Returns
-------
str
The entry's release month padded to two digits, i.e. March returns "03"
"""
return self.release_date[4:6]
@property
def release_day_padded(self: Self) -> str:
"""
Returns
-------
str
The entry's release day padded to two digits, i.e. the fifth returns "05"
"""
return self.release_date[6:8]
@property
def release_month(self: Self) -> int:
"""
Returns
-------
int
The release month as an integer (no padding).
"""
return int(self.release_month_padded.lstrip("0"))
@property
def release_day(self: Self) -> int:
"""
Returns
-------
int
The release day as an integer (no padding).
"""
return int(self.release_day_padded.lstrip("0"))
@property
def release_day_reversed(self: Self) -> int:
"""
Returns
-------
int
The release day, but reversed using ``{total_days_in_month} + 1 - {release_day}``,
i.e. August 8th would have release_day_reversed of ``31 + 1 - 8`` = ``24``
"""
total_days_in_month = _days_in_month[self.release_month]
if self.release_month == 2 and self.release_year % 4 == 0: # leap year
total_days_in_month += 1
return total_days_in_month + 1 - self.release_day
@property
def release_day_reversed_padded(self: Self) -> str:
"""
Returns
-------
str
The reversed release day, but padded. i.e. August 30th returns "02".
"""
return pad(self.release_day_reversed)
@property
def release_day_of_year(self: Self) -> int:
"""
Returns
-------
int
The day of the year, i.e. February 1st returns ``32``
"""
output = sum(_days_in_month[: self.release_month]) + self.release_day
if self.release_month > 2 and self.release_year % 4 == 0:
output += 1
return output
@property
def release_day_of_year_padded(self: Self) -> str:
"""
Returns
-------
str
The release day of year, but padded i.e. February 1st returns "032"
"""
return pad(self.release_day_of_year, width=3)
@property
def release_day_of_year_reversed(self: Self) -> int:
"""
Returns
-------
int
The release day, but reversed using ``{total_days_in_year} + 1 - {release_day}``,
i.e. February 2nd would have release_day_of_year_reversed of ``365 + 1 - 32`` = ``334``
"""
total_days_in_year = 365
if self.release_year % 4 == 0:
total_days_in_year += 1
return total_days_in_year + 1 - self.release_day_of_year
@property
def release_day_of_year_reversed_padded(self: Self) -> str:
"""
Returns
-------
str
The reversed release day of year, but padded i.e. December 31st returns "001"
"""
return pad(self.release_day_of_year_reversed, width=3)
@property
def release_date_standardized(self: Self) -> str:
"""
Returns
-------
str
The release date formatted as YYYY-MM-DD
"""
return f"{self.release_year}-{self.release_month_padded}-{self.release_day_padded}"

View file

@ -46,6 +46,8 @@ UPLOAD_DATE_INDEX = _("upload_date_index", backend=True)
REQUESTED_SUBTITLES = _("requested_subtitles", backend=True)
CHAPTERS = _("chapters", backend=True)
YTDL_SUB_CUSTOM_CHAPTERS = _("ytdl_sub_custom_chapters", backend=True)
YTDL_SUB_REGEX_SOURCE_VARS = _("ytdl_sub_regex_source_vars", backend=True)
YTDL_SUB_MATCH_FILTER_REJECT = _("ytdl_sub_match_filter_reject", backend=True)
SPONSORBLOCK_CHAPTERS = _("sponsorblock_chapters", backend=True)
SPLIT_BY_CHAPTERS_PARENT_ENTRY = _("split_by_chapters_parent_entry", backend=True)
COMMENTS = _("comments", backend=True)
@ -60,6 +62,7 @@ EXT = _("ext")
TITLE = _("title")
DESCRIPTION = _("description")
WEBPAGE_URL = _("webpage_url")
RELEASE_DATE = _("release_date")
UPLOAD_DATE = _("upload_date")
UPLOADER = _("uploader")
UPLOADER_ID = _("uploader_id")

View file

@ -3,17 +3,17 @@ from typing import Any
from typing import Dict
from typing import Optional
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.exceptions import FileNotDownloadedException
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_TYPES_EXTENSION_MAPPING
from ytdl_sub.validators.audo_codec_validator import AudioTypeValidator
from ytdl_sub.validators.validators import FloatValidator
class AudioExtractOptions(PluginOptions):
class AudioExtractOptions(OptionsDictValidator):
"""
Extracts audio from a video file.

View file

@ -5,12 +5,12 @@ from typing import List
from typing import Optional
from typing import Set
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.kwargs import COMMENTS
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_CUSTOM_CHAPTERS
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters
from ytdl_sub.utils.file_handler import FileMetadata
@ -58,7 +58,7 @@ class SponsorBlockCategoryListValidator(ListValidator[SponsorBlockCategoriesVali
_inner_list_type = SponsorBlockCategoriesValidator
class ChaptersOptions(PluginOptions):
class ChaptersOptions(OptionsDictValidator):
"""
Embeds chapters to video files if they are present. Additional options to add SponsorBlock
chapters and remove specific ones. Can also remove chapters using regex.
@ -302,21 +302,24 @@ class ChaptersPlugin(Plugin[ChaptersOptions]):
"""
chapters = Chapters.from_empty()
# If there are no embedded chapters, and comment chapters are allowed...
if not _contains_any_chapters(entry) and self.plugin_options.allow_chapters_from_comments:
# Try to get chapters from comments
for comment in entry.kwargs_get(COMMENTS, []):
chapters = Chapters.from_string(comment.get("text", ""))
if not chapters.is_empty():
if chapters.contains_any_chapters():
break
if not chapters.is_empty():
entry.add_kwargs({YTDL_SUB_CUSTOM_CHAPTERS: chapters.to_file_metadata_dict()})
# If some are actually found, add a special kwarg and embed them
if chapters.contains_any_chapters():
entry.add_kwargs({YTDL_SUB_CUSTOM_CHAPTERS: chapters.to_file_metadata_dict()})
if not self.is_dry_run:
set_ffmpeg_metadata_chapters(
file_path=entry.get_download_file_path(),
chapters=chapters,
file_duration_sec=entry.kwargs("duration"),
)
if not self.is_dry_run:
set_ffmpeg_metadata_chapters(
file_path=entry.get_download_file_path(),
chapters=chapters,
file_duration_sec=entry.kwargs("duration"),
)
return entry

View file

@ -1,14 +1,14 @@
from typing import Dict
from typing import Optional
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.datetime import to_date_range
from ytdl_sub.validators.string_datetime import StringDatetimeValidator
class DateRangeOptions(PluginOptions):
class DateRangeOptions(OptionsDictValidator):
"""
Only download files uploaded within the specified date range.

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

View file

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

View file

@ -5,15 +5,18 @@ from typing import Optional
from yt_dlp import match_filter_func
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.plugin import PluginPriority
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_MATCH_FILTER_REJECT
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.validators import StringListValidator
logger = Logger.get("match_filters")
class MatchFiltersOptions(PluginOptions):
class MatchFiltersOptions(OptionsDictValidator):
"""
Set ``--match-filters``` to pass into yt-dlp to filter entries from being downloaded.
Uses the same syntax as yt-dlp.
@ -35,8 +38,12 @@ class MatchFiltersOptions(PluginOptions):
my_example_preset:
match_filters:
filters:
- "original_url!*=/shorts/"
- "!is_live"
- "age_limit<?18"
- "like_count>?100"
# Other common match-filters
# - "original_url!*=/shorts/ & !is_live"
# - "age_limit<?18"
# - "availability=?public"
"""
_required_keys = {"filters"}
@ -56,13 +63,15 @@ class MatchFiltersOptions(PluginOptions):
def filters(self) -> List[str]:
"""
The filters themselves. If used multiple times, the filter matches if at least one of the
conditions are met.
conditions are met. For logical AND's between match filters, use the ``&`` operator in
a single match filter.
"""
return [validator.value for validator in self._filters]
class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
plugin_options_type = MatchFiltersOptions
priority = PluginPriority(modify_entry=PluginPriority.MODIFY_ENTRY_FIRST)
def ytdl_options(self) -> Optional[Dict]:
"""
@ -75,4 +84,17 @@ class MatchFiltersPlugin(Plugin[MatchFiltersOptions]):
logger.debug("Adding match-filter %s", filter_str)
match_filters.append(filter_str)
return {"match_filter": match_filter_func(match_filters)}
return {
"match_filter": match_filter_func(match_filters),
}
def modify_entry(self, entry: Entry) -> Optional[Entry]:
"""
If an entry is marked as not being downloaded due to a match_filter reject,
do not propagate the entry further (especially since there is no entry file!)
"""
if entry.kwargs_get(YTDL_SUB_MATCH_FILTER_REJECT, False):
logger.info("Entry rejected by match-filter, skipping ..")
return None
return entry

View file

@ -1,3 +1,4 @@
import copy
from collections import defaultdict
from typing import Any
from typing import Dict
@ -5,12 +6,11 @@ from typing import List
import mediafile
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
@ -45,7 +45,7 @@ class MusicTagsValidator(StrictDictValidator):
return self._tags
class MusicTagsOptions(PluginOptions):
class MusicTagsOptions(OptionsDictValidator):
"""
Adds tags to every download audio file using
`MediaFile <https://mediafile.readthedocs.io/en/latest/>`_,
@ -65,34 +65,31 @@ class MusicTagsOptions(PluginOptions):
tags:
artist: "{artist}"
album: "{album}"
genre: "ytdl-sub"
# Supports id3v2.4 multi-tags
genres:
- "{genre}"
- "ytdl-sub"
albumartists:
- "{artist}"
- "ytdl-sub"
# Optional
embed_thumbnail: False
"""
_required_keys = {"tags"}
_optional_keys = {"embed_thumbnail"}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate music tags
"""
if isinstance(value, dict):
value["tags"] = value.get("tags", {})
_ = cls(name, value)
_optional_keys = {"tags", "embed_thumbnail"}
_allow_extra_keys = True
def __init__(self, name, value):
super().__init__(name, value)
self._tags = self._validate_key(key="tags", validator=MusicTagsValidator)
self._embed_thumbnail = self._validate_key_if_present(
key="embed_thumbnail", validator=BoolValidator, default=False
).value
key="embed_thumbnail", validator=BoolValidator
)
new_tags_dict: Dict[str, Any] = copy.deepcopy(value)
old_tags_dict = new_tags_dict.pop("tags", {})
new_tags_dict.pop("embed_thumbnail", None)
self._is_old_format = len(old_tags_dict) > 0 or self._embed_thumbnail is not None
self._tags = MusicTagsValidator(name=name, value=dict(old_tags_dict, **new_tags_dict))
@property
def tags(self) -> MusicTagsValidator:
@ -107,7 +104,9 @@ class MusicTagsOptions(PluginOptions):
"""
Optional. Whether to embed the thumbnail into the audio file.
"""
return self._embed_thumbnail
if self._embed_thumbnail is None:
return False
return self._embed_thumbnail.value
class MusicTagsPlugin(Plugin[MusicTagsOptions]):
@ -124,6 +123,20 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
f"to audio using the audio_extract plugin."
)
# pylint: disable=protected-access
if self.plugin_options._is_old_format:
logger.warning(
"music_tags.tags is now deprecated. Place your tags directly under music_tags "
"instead. The old format will be removed in October of 2023. See "
"https://ytdl-sub.readthedocs.io/en/latest/deprecation_notices.html#music-tags "
"for more details."
)
if self.plugin_options.embed_thumbnail:
logger.warning(
"music_tags.embed_thumbnail is also deprecated. Use the dedicated "
"embed_thumbnail plugin instead. This will be removed in October of 2023."
)
# Resolve the tags into this dict
tags_to_write: Dict[str, List[str]] = defaultdict(list)
for tag_name, tag_formatters in self.plugin_options.tags.as_lists.items():
@ -148,10 +161,7 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
)
setattr(audio_file, tag_name, tag_value[0])
if self.plugin_options.embed_thumbnail:
# convert the entry thumbnail so it is embedded as jpg
convert_download_thumbnail(entry=entry)
if self.plugin_options.embed_thumbnail and entry.is_thumbnail_downloaded():
with open(entry.get_download_thumbnail_path(), "rb") as thumb:
mediafile_img = mediafile.Image(
data=thumb.read(), desc="cover", type=mediafile.ImageType.front

View file

@ -7,9 +7,9 @@ from typing import Dict
from typing import List
from typing import Optional
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.xml import XmlElement
@ -23,7 +23,7 @@ from ytdl_sub.validators.string_formatter_validators import StringFormatterValid
from ytdl_sub.validators.validators import BoolValidator
class SharedNfoTagsOptions(PluginOptions):
class SharedNfoTagsOptions(OptionsDictValidator):
"""
Shared code between NFO tags and Ouptut Directory NFO Tags
"""

View file

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

View file

@ -5,11 +5,13 @@ from typing import Optional
from yt_dlp.utils import sanitize_filename
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.plugin import PluginPriority
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.plugins.plugin import PluginPriority
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_REGEX_SOURCE_VARS
from ytdl_sub.utils.exceptions import RegexNoMatchException
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.regex_validator import RegexListValidator
from ytdl_sub.validators.source_variable_validator import SourceVariableNameListValidator
@ -21,14 +23,14 @@ from ytdl_sub.validators.validators import BoolValidator
logger = Logger.get(name="regex")
class SourceVariableRegex(StrictDictValidator):
class VariableRegex(StrictDictValidator):
_required_keys = {"match"}
_optional_keys = {"capture_group_defaults", "capture_group_names"}
_optional_keys = {"match", "exclude", "capture_group_defaults", "capture_group_names"}
def __init__(self, name, value):
super().__init__(name, value)
self._match = self._validate_key(key="match", validator=RegexListValidator)
self._match = self._validate_key_if_present(key="match", validator=RegexListValidator)
self._exclude = self._validate_key_if_present(key="exclude", validator=RegexListValidator)
self._capture_group_defaults = self._validate_key_if_present(
key="capture_group_defaults", validator=ListFormatterValidator
)
@ -36,6 +38,14 @@ class SourceVariableRegex(StrictDictValidator):
key="capture_group_names", validator=SourceVariableNameListValidator, default=[]
)
if self._match is None and self._exclude is None:
raise self._validation_exception("must specify either `match` or `exclude`")
if self._match is None and (self._capture_group_defaults or self._capture_group_names.list):
raise self._validation_exception(
"capture group parameters requires at least one `match` to be specified"
)
# If defaults are to be used, ensure there are the same number of defaults as there are
# capture groups
if self._capture_group_defaults is not None and self._match.num_capture_groups != len(
@ -47,20 +57,30 @@ class SourceVariableRegex(StrictDictValidator):
)
# If there are capture groups, ensure there are capture group names
if len(self._capture_group_names.list) != self._match.num_capture_groups:
if self._match and (len(self._capture_group_names.list) != self._match.num_capture_groups):
raise self._validation_exception(
f"number of capture group names must match number of capture groups, "
f"{len(self._capture_group_names.list)} != {self._match.num_capture_groups}"
)
@property
def match(self) -> RegexListValidator:
def match(self) -> Optional[RegexListValidator]:
"""
Required. List of regex strings to try to match against a source variable. Each regex
List of regex strings to try to match against a source or override variable. Each regex
string must have the same number of capture groups.
"""
return self._match
@property
def exclude(self) -> Optional[RegexListValidator]:
"""
List of regex strings to try to match against a source or override variable. If one of the
regex strings match, then the entry will be skipped. If both ``exclude`` and ``match`` are
specified, entries will get skipped if the regex matches against both ``exclude`` and
``match``.
"""
return self._exclude
@property
def capture_group_names(self) -> Optional[List[str]]:
"""
@ -96,19 +116,25 @@ class FromSourceVariablesRegex(StrictDictValidator):
def __init__(self, name, value):
super().__init__(name, value)
self.source_variable_capture_dict: Dict[str, SourceVariableRegex] = {
key: self._validate_key(key=key, validator=SourceVariableRegex) for key in self._keys
self.variable_capture_dict: Dict[str, VariableRegex] = {
key: self._validate_key(key=key, validator=VariableRegex) for key in self._keys
}
class RegexOptions(PluginOptions):
class RegexOptions(OptionsDictValidator):
r"""
Performs regex matching on an entry's source variables. Regex can be used to filter entries
from proceeding with download or capture groups to create new source variables. NOTE to
Performs regex matching on an entry's source or override variables. Regex can be used to filter
entries from proceeding with download or capture groups to create new source variables. NOTE to
use backslashes anywhere in your regex, i.e. ``\d``, you must add another backslash escape. This
means ``\d`` should be written as ``\\d``. This is because YAML requires an escape for any
backslash usage.
If you want to regex-search multiple source variables to create a logical OR effect, you can
create an override variable that contains the concatenation of them, and search that with regex.
For example, creating the override variable ``"title_and_description": "{title} {description}"``
and using ``title_and_description`` can regex match/exclude from either ``title`` or
``description``.
Usage:
.. code-block:: yaml
@ -128,7 +154,7 @@ class RegexOptions(PluginOptions):
# This will only download videos with "[Official Video]" in it. Note that we
# double backslash to make YAML happy
match:
- '\\[Official Video\\]'
- '\\[Official Video\\]'
# For each entry's `description` value...
description:
@ -136,14 +162,17 @@ class RegexOptions(PluginOptions):
# This tries to scrape a date from the description and produce new
# source variables
match:
- "([0-9]{4})-([0-9]{2})-([0-9]{2})"
- "([0-9]{4})-([0-9]{2})-([0-9]{2})"
# Exclude any entry where the description contains #short
exclude:
- "#short"
# Each capture group creates these new source variables, respectively,
# as well a sanitized version, i.e. `captured_upload_year_sanitized`
capture_group_names:
- "captured_upload_year"
- "captured_upload_month"
- "captured_upload_day"
- "captured_upload_year"
- "captured_upload_month"
- "captured_upload_day"
# And if the string does not match, use these as respective default
# values for the new source variables.
@ -195,9 +224,9 @@ class RegexOptions(PluginOptions):
"""
for key, regex_options in self.source_variable_capture_dict.items():
# Ensure each variable getting captured is a source variable
if key not in source_variables:
if key not in source_variables and key not in override_variables:
raise self._validation_exception(
f"cannot regex capture '{key}' because it is not a source variable"
f"cannot regex capture '{key}' because it is not a source or override variable"
)
# Ensure the capture group names are not existing source/override variables
@ -214,13 +243,13 @@ class RegexOptions(PluginOptions):
)
@property
def source_variable_capture_dict(self) -> Dict[str, SourceVariableRegex]:
def source_variable_capture_dict(self) -> Dict[str, VariableRegex]:
"""
Returns
-------
Dict of { source variable: capture options }
"""
return self._from.source_variable_capture_dict
return self._from.variable_capture_dict
def added_source_variables(self) -> List[str]:
"""
@ -245,12 +274,66 @@ class RegexPlugin(Plugin[RegexOptions]):
modify_entry=PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + 0,
)
def modify_entry(self, entry: Entry) -> Optional[Entry]:
@classmethod
def _add_processed_regex_variable_name(cls, entry: Entry, source_var: str) -> None:
if not entry.kwargs_contains(YTDL_SUB_REGEX_SOURCE_VARS):
entry.add_kwargs({YTDL_SUB_REGEX_SOURCE_VARS: []})
entry.kwargs(YTDL_SUB_REGEX_SOURCE_VARS).append(source_var)
@classmethod
def _contains_processed_regex_variable(cls, entry: Entry, variable_name: str) -> bool:
return variable_name in entry.kwargs_get(YTDL_SUB_REGEX_SOURCE_VARS, [])
def _try_skip_entry(self, entry: Entry, variable_name: str) -> None:
# Skip the entry if toggled
if self.plugin_options.skip_if_match_fails:
logger.info(
"Regex failed to match '%s' from '%s', skipping.",
variable_name,
entry.title,
)
return None
# Otherwise, error
raise RegexNoMatchException(f"Regex failed to match '{variable_name}' from '{entry.title}'")
def _can_process_at_metadata_stage(self, entry: Entry, variable_name: str) -> bool:
# If the variable is an override...
if variable_name in self.overrides.dict:
# Try to see if it can resolve
try:
self.overrides.apply_formatter(
formatter=self.overrides.dict[variable_name],
entry=entry,
)
# If it can not from missing variables (from post-metadata stage), return False
except StringFormattingVariableNotFoundException:
return False
# If it is a source variable and not present, return false
elif variable_name not in entry.to_dict():
return False
return True
def _get_regex_input_string(self, entry: Entry, variable_name: str) -> str:
# Apply override formatter if it's an override
if variable_name in self.overrides.dict:
return self.overrides.apply_formatter(
formatter=self.overrides.dict[variable_name],
entry=entry,
)
# Otherwise pluck from the entry's source variable
return entry.to_dict()[variable_name]
def _modify_entry_metadata(self, entry: Entry, is_metadata_stage: bool) -> Optional[Entry]:
"""
Parameters
----------
entry
Entry to add source variables to
is_metadata_stage
Whether this is called at the metadata stage or modify stage
Returns
-------
@ -261,73 +344,102 @@ class RegexPlugin(Plugin[RegexOptions]):
ValidationException
If no capture and no defaults
"""
entry_variable_dict = entry.to_dict()
# Iterate each source var to capture and add to the entry
for source_var, regex_options in self.plugin_options.source_variable_capture_dict.items():
maybe_capture = regex_options.match.match_any(input_str=entry_variable_dict[source_var])
for (
variable_name,
regex_options,
) in self.plugin_options.source_variable_capture_dict.items():
# If no capture
if maybe_capture is None:
# and no defaults
if not regex_options.has_defaults:
# Skip the entry if toggled
if self.plugin_options.skip_if_match_fails:
logger.info(
"Regex failed to match '%s' from '%s', skipping.",
source_var,
entry.title,
)
return None
# Record which regex source variables are processed, to
# process as many variables as possible in the metadata stage, then the rest
# after the media file has been downloaded.
if self._contains_processed_regex_variable(entry, variable_name):
continue
# Otherwise, error
raise RegexNoMatchException(
f"Regex failed to match '{source_var}' from '{entry.title}'"
# If it's the metadata stage, and it can't be processed, skip until post-metadata
if is_metadata_stage and not self._can_process_at_metadata_stage(
entry=entry, variable_name=variable_name
):
continue
self._add_processed_regex_variable_name(entry, variable_name)
regex_input_str = self._get_regex_input_string(
entry=entry,
variable_name=variable_name,
)
if (
regex_options.exclude is not None
and regex_options.exclude.match_any(input_str=regex_input_str) is not None
):
return self._try_skip_entry(entry=entry, variable_name=variable_name)
# If match is present
if regex_options.match is not None:
maybe_capture = regex_options.match.match_any(input_str=regex_input_str)
# And nothing matched
if maybe_capture is None:
# and no defaults
if not regex_options.has_defaults:
return self._try_skip_entry(entry=entry, variable_name=variable_name)
# otherwise, use defaults (apply them using the original entry source dict)
source_variables_and_overrides_dict = dict(
entry.to_dict(), **self.overrides.dict_with_format_strings
)
# otherwise, use defaults (apply them using the original entry source dict)
source_variables_and_overrides_dict = dict(
entry_variable_dict, **self.overrides.dict_with_format_strings
)
# add both the default...
entry.add_variables(
variables_to_add={
regex_options.capture_group_names[i]: default.apply_formatter(
variable_dict=source_variables_and_overrides_dict
)
for i, default in enumerate(regex_options.capture_group_defaults)
},
)
# and sanitized default
entry.add_variables(
variables_to_add={
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
default.apply_formatter(
# add both the default...
entry.add_variables(
variables_to_add={
regex_options.capture_group_names[i]: default.apply_formatter(
variable_dict=source_variables_and_overrides_dict
)
)
for i, default in enumerate(regex_options.capture_group_defaults)
},
)
# There is a capture, add the source variables to the entry as
# {source_var}_capture_1, {source_var}_capture_2, ...
else:
# Add the value...
entry.add_variables(
variables_to_add={
regex_options.capture_group_names[i]: capture
for i, capture in enumerate(maybe_capture)
},
)
# And the sanitized value
entry.add_variables(
variables_to_add={
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
capture
)
for i, capture in enumerate(maybe_capture)
},
)
for i, default in enumerate(regex_options.capture_group_defaults)
},
)
# and sanitized default
entry.add_variables(
variables_to_add={
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
default.apply_formatter(
variable_dict=source_variables_and_overrides_dict
)
)
for i, default in enumerate(regex_options.capture_group_defaults)
},
)
# There is a capture, add the source variables to the entry as
# {source_var}_capture_1, {source_var}_capture_2, ...
else:
# Add the value...
entry.add_variables(
variables_to_add={
regex_options.capture_group_names[i]: capture
for i, capture in enumerate(maybe_capture)
},
)
# And the sanitized value
entry.add_variables(
variables_to_add={
f"{regex_options.capture_group_names[i]}_sanitized": sanitize_filename(
capture
)
for i, capture in enumerate(maybe_capture)
},
)
return entry
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
"""
Perform regex at the metadata stage
"""
return self._modify_entry_metadata(entry, is_metadata_stage=True)
def modify_entry(self, entry: Entry) -> Optional[Entry]:
"""
Perform regex at the metadata stage
"""
return self._modify_entry_metadata(entry, is_metadata_stage=False)

View file

@ -1,5 +1,4 @@
import copy
import os.path
from pathlib import Path
from typing import Any
from typing import List
@ -8,20 +7,19 @@ from typing import Tuple
from yt_dlp.utils import sanitize_filename
from ytdl_sub.config.plugin import SplitPlugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.kwargs import CHAPTERS
from ytdl_sub.entries.variables.kwargs import SPLIT_BY_CHAPTERS_PARENT_ENTRY
from ytdl_sub.entries.variables.kwargs import SPONSORBLOCK_CHAPTERS
from ytdl_sub.entries.variables.kwargs import UID
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.chapters import Timestamp
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.validators.string_select_validator import StringSelectValidator
@ -47,7 +45,7 @@ class WhenNoChaptersValidator(StringSelectValidator):
_select_values = {"pass", "drop", "error"}
class SplitByChaptersOptions(PluginOptions):
class SplitByChaptersOptions(OptionsDictValidator):
"""
Splits a file by chapters into multiple files. Each file becomes its own entry with the
new source variables ``chapter_title``, ``chapter_title_sanitized``, ``chapter_index``,
@ -101,9 +99,8 @@ class SplitByChaptersOptions(PluginOptions):
return self._when_no_chapters
class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]):
plugin_options_type = SplitByChaptersOptions
is_split_plugin = True
def _create_split_entry(
self, source_entry: Entry, title: str, idx: int, chapters: Chapters
@ -164,14 +161,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
Tags the entry's audio file using values defined in the metadata options
"""
split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = []
if self.is_dry_run:
chapters = Chapters.from_entry_chapters(entry=entry)
else:
chapters = Chapters.from_embedded_chapters(
ffprobe_path=FFMPEG.ffprobe_path(),
file_path=entry.get_download_file_path(),
)
chapters = Chapters.from_entry_chapters(entry=entry)
# If no chapters, do not split anything
if not chapters.contains_any_chapters():
@ -192,11 +182,6 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
f"Tried to split '{entry.title}' by chapters but it has no chapters"
)
# convert the entry thumbnail early so we do not have to guess the thumbnail extension
# when copying it. Do not error if it's not found, in case thumbnail_name is not set
if not self.is_dry_run:
convert_download_thumbnail(entry=entry, error_if_not_found=False)
for idx, title in enumerate(chapters.titles):
new_uid = _split_video_uid(source_uid=entry.uid, idx=idx)
@ -217,7 +202,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
# Copy the original vid thumbnail to the working directory with the new uid. This so
# downstream logic thinks this split video has its own thumbnail
if os.path.isfile(entry.get_download_thumbnail_path()):
if entry.is_thumbnail_downloaded():
FileHandler.copy(
src_file_path=entry.get_download_thumbnail_path(),
dst_file_path=Path(self.working_directory)

View file

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

View file

@ -1,15 +1,19 @@
import copy
from typing import Any
from typing import Dict
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.ffmpeg import add_ffmpeg_metadata_key_values
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
logger = Logger.get("video_tags")
class VideoTagsOptions(PluginOptions):
class VideoTagsOptions(OptionsDictValidator):
"""
Adds tags to every downloaded video file using ffmpeg ``-metadata key=value`` args.
@ -20,13 +24,13 @@ class VideoTagsOptions(PluginOptions):
presets:
my_example_preset:
video_tags:
tags:
title: "{title}"
date: "{upload_date}"
description: "{description}"
title: "{title}"
date: "{upload_date}"
description: "{description}"
"""
_required_keys = {"tags"}
_optional_keys = {"tags"}
_allow_extra_keys = True
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
@ -39,7 +43,12 @@ class VideoTagsOptions(PluginOptions):
def __init__(self, name, value):
super().__init__(name, value)
self._tags = self._validate_key(key="tags", validator=DictFormatterValidator)
new_tags_dict: Dict[str, Any] = copy.deepcopy(value)
old_tags_dict = new_tags_dict.pop("tags", {})
self._is_old_format = len(old_tags_dict) > 0
self._tags = DictFormatterValidator(name=name, value=dict(old_tags_dict, **new_tags_dict))
@property
def tags(self) -> DictFormatterValidator:
@ -56,6 +65,15 @@ class VideoTagsPlugin(Plugin[VideoTagsOptions]):
"""
Tags the entry's audio file using values defined in the metadata options
"""
# pylint: disable=protected-access
if self.plugin_options._is_old_format:
logger.warning(
"video_tags.tags is now deprecated. Place your tags directly under video_tags "
"instead. The old format will be removed in October of 2023. See "
"https://ytdl-sub.readthedocs.io/en/latest/deprecation_notices.html#video-tags "
"for more details."
)
tags_to_write: Dict[str, str] = {}
for tag_name, tag_formatter in self.plugin_options.tags.dict.items():
tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry)

View file

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

View file

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

View file

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

View file

@ -34,33 +34,31 @@ presets:
# TV show from one or more sources. Uses {url}'s avatar and banner as poster and fanart
_tv_show_by_date:
download:
download_strategy: "multi_url"
urls:
- url: "{url}"
playlist_thumbnails:
- name: "{tv_show_poster_file_name}"
uid: "avatar_uncropped"
- name: "{tv_show_fanart_file_name}"
uid: "banner_uncropped"
- url: "{url2}"
- url: "{url3}"
- url: "{url4}"
- url: "{url5}"
- url: "{url6}"
- url: "{url7}"
- url: "{url8}"
- url: "{url9}"
- url: "{url10}"
- url: "{url11}"
- url: "{url12}"
- url: "{url13}"
- url: "{url14}"
- url: "{url15}"
- url: "{url16}"
- url: "{url17}"
- url: "{url18}"
- url: "{url19}"
- url: "{url20}"
- url: "{url}"
playlist_thumbnails:
- name: "{tv_show_poster_file_name}"
uid: "avatar_uncropped"
- name: "{tv_show_fanart_file_name}"
uid: "banner_uncropped"
- url: "{url2}"
- url: "{url3}"
- url: "{url4}"
- url: "{url5}"
- url: "{url6}"
- url: "{url7}"
- url: "{url8}"
- url: "{url9}"
- url: "{url10}"
- url: "{url11}"
- url: "{url12}"
- url: "{url13}"
- url: "{url14}"
- url: "{url15}"
- url: "{url16}"
- url: "{url17}"
- url: "{url18}"
- url: "{url19}"
- url: "{url20}"
overrides:
url2: ""
url3: ""
@ -87,9 +85,6 @@ presets:
# TV show from a collection. Must specify additional `tv_show_collection_season` presets in
# addition. Each season sets its own `collection_season_number/_padded`
_tv_show_collection:
download:
download_strategy: "multi_url"
overrides:
season_number: "{collection_season_number}"
season_number_padded: "{collection_season_number_padded}"

View file

@ -21,21 +21,19 @@ presets:
collection_season_1:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_1_url}"
variables:
collection_season_number: "1"
collection_season_number_padded: "01"
playlist_thumbnails:
# Use latest_entry first, then see if YT channel artwork exists
# ONLY FOR SEASON 1! The channel artwork will be the show's artwork.
- name: "{season_poster_file_name}"
uid: "latest_entry"
- name: "{tv_show_poster_file_name}"
uid: "avatar_uncropped"
- name: "{tv_show_fanart_file_name}"
uid: "banner_uncropped"
- url: "{collection_season_1_url}"
variables:
collection_season_number: "1"
collection_season_number_padded: "01"
playlist_thumbnails:
# Use latest_entry first, then see if YT channel artwork exists
# ONLY FOR SEASON 1! The channel artwork will be the show's artwork.
- name: "{season_poster_file_name}"
uid: "latest_entry"
- name: "{tv_show_poster_file_name}"
uid: "avatar_uncropped"
- name: "{tv_show_fanart_file_name}"
uid: "banner_uncropped"
output_directory_nfo_tags:
tags:
@ -46,15 +44,13 @@ presets:
collection_season_2:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_2_url}"
variables:
collection_season_number: "2"
collection_season_number_padded: "02"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_2_url}"
variables:
collection_season_number: "2"
collection_season_number_padded: "02"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -65,15 +61,13 @@ presets:
collection_season_3:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_3_url}"
variables:
collection_season_number: "3"
collection_season_number_padded: "03"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_3_url}"
variables:
collection_season_number: "3"
collection_season_number_padded: "03"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -84,15 +78,13 @@ presets:
collection_season_4:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_4_url}"
variables:
collection_season_number: "4"
collection_season_number_padded: "04"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_4_url}"
variables:
collection_season_number: "4"
collection_season_number_padded: "04"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -103,15 +95,13 @@ presets:
collection_season_5:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_5_url}"
variables:
collection_season_number: "5"
collection_season_number_padded: "05"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_5_url}"
variables:
collection_season_number: "5"
collection_season_number_padded: "05"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -122,15 +112,13 @@ presets:
collection_season_6:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_6_url}"
variables:
collection_season_number: "6"
collection_season_number_padded: "06"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_6_url}"
variables:
collection_season_number: "6"
collection_season_number_padded: "06"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -141,15 +129,13 @@ presets:
collection_season_7:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_7_url}"
variables:
collection_season_number: "7"
collection_season_number_padded: "07"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_7_url}"
variables:
collection_season_number: "7"
collection_season_number_padded: "07"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -160,15 +146,13 @@ presets:
collection_season_8:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_8_url}"
variables:
collection_season_number: "8"
collection_season_number_padded: "08"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_8_url}"
variables:
collection_season_number: "8"
collection_season_number_padded: "08"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -179,15 +163,13 @@ presets:
collection_season_9:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_9_url}"
variables:
collection_season_number: "9"
collection_season_number_padded: "09"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_9_url}"
variables:
collection_season_number: "9"
collection_season_number_padded: "09"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -198,15 +180,13 @@ presets:
collection_season_10:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_10_url}"
variables:
collection_season_number: "10"
collection_season_number_padded: "10"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_10_url}"
variables:
collection_season_number: "10"
collection_season_number_padded: "10"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -217,15 +197,13 @@ presets:
collection_season_11:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_11_url}"
variables:
collection_season_number: "11"
collection_season_number_padded: "11"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_11_url}"
variables:
collection_season_number: "11"
collection_season_number_padded: "11"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -236,15 +214,13 @@ presets:
collection_season_12:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_12_url}"
variables:
collection_season_number: "12"
collection_season_number_padded: "12"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_12_url}"
variables:
collection_season_number: "12"
collection_season_number_padded: "12"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -255,15 +231,13 @@ presets:
collection_season_13:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_13_url}"
variables:
collection_season_number: "13"
collection_season_number_padded: "13"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_13_url}"
variables:
collection_season_number: "13"
collection_season_number_padded: "13"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -274,15 +248,13 @@ presets:
collection_season_14:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_14_url}"
variables:
collection_season_number: "14"
collection_season_number_padded: "14"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_14_url}"
variables:
collection_season_number: "14"
collection_season_number_padded: "14"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -293,15 +265,13 @@ presets:
collection_season_15:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_15_url}"
variables:
collection_season_number: "15"
collection_season_number_padded: "15"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_15_url}"
variables:
collection_season_number: "15"
collection_season_number_padded: "15"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -312,15 +282,13 @@ presets:
collection_season_16:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_16_url}"
variables:
collection_season_number: "16"
collection_season_number_padded: "16"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_16_url}"
variables:
collection_season_number: "16"
collection_season_number_padded: "16"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -331,15 +299,13 @@ presets:
collection_season_17:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_17_url}"
variables:
collection_season_number: "17"
collection_season_number_padded: "17"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_17_url}"
variables:
collection_season_number: "17"
collection_season_number_padded: "17"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -350,15 +316,13 @@ presets:
collection_season_18:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_18_url}"
variables:
collection_season_number: "18"
collection_season_number_padded: "18"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_18_url}"
variables:
collection_season_number: "18"
collection_season_number_padded: "18"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -369,15 +333,13 @@ presets:
collection_season_19:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_19_url}"
variables:
collection_season_number: "19"
collection_season_number_padded: "19"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_19_url}"
variables:
collection_season_number: "19"
collection_season_number_padded: "19"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
@ -388,19 +350,357 @@ presets:
collection_season_20:
download:
download_strategy: "multi_url"
urls:
- url: "{collection_season_20_url}"
variables:
collection_season_number: "20"
collection_season_number_padded: "20"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
- url: "{collection_season_20_url}"
variables:
collection_season_number: "20"
collection_season_number_padded: "20"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_20_name}"
attributes:
number: "20"
number: "20"
collection_season_21:
download:
- url: "{collection_season_21_url}"
variables:
collection_season_number: "21"
collection_season_number_padded: "21"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_21_name}"
attributes:
number: "21"
collection_season_22:
download:
- url: "{collection_season_22_url}"
variables:
collection_season_number: "22"
collection_season_number_padded: "22"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_22_name}"
attributes:
number: "22"
collection_season_23:
download:
- url: "{collection_season_23_url}"
variables:
collection_season_number: "23"
collection_season_number_padded: "23"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_23_name}"
attributes:
number: "23"
collection_season_24:
download:
- url: "{collection_season_24_url}"
variables:
collection_season_number: "24"
collection_season_number_padded: "24"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_24_name}"
attributes:
number: "24"
collection_season_25:
download:
- url: "{collection_season_25_url}"
variables:
collection_season_number: "25"
collection_season_number_padded: "25"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_25_name}"
attributes:
number: "25"
collection_season_26:
download:
- url: "{collection_season_26_url}"
variables:
collection_season_number: "26"
collection_season_number_padded: "26"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_26_name}"
attributes:
number: "26"
collection_season_27:
download:
- url: "{collection_season_27_url}"
variables:
collection_season_number: "27"
collection_season_number_padded: "27"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_27_name}"
attributes:
number: "27"
collection_season_28:
download:
- url: "{collection_season_28_url}"
variables:
collection_season_number: "28"
collection_season_number_padded: "28"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_28_name}"
attributes:
number: "28"
collection_season_29:
download:
- url: "{collection_season_29_url}"
variables:
collection_season_number: "29"
collection_season_number_padded: "29"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_29_name}"
attributes:
number: "29"
collection_season_30:
download:
- url: "{collection_season_30_url}"
variables:
collection_season_number: "30"
collection_season_number_padded: "30"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_30_name}"
attributes:
number: "30"
collection_season_31:
download:
- url: "{collection_season_31_url}"
variables:
collection_season_number: "31"
collection_season_number_padded: "31"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_31_name}"
attributes:
number: "31"
collection_season_32:
download:
- url: "{collection_season_32_url}"
variables:
collection_season_number: "32"
collection_season_number_padded: "32"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_32_name}"
attributes:
number: "32"
collection_season_33:
download:
- url: "{collection_season_33_url}"
variables:
collection_season_number: "33"
collection_season_number_padded: "33"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_33_name}"
attributes:
number: "33"
collection_season_34:
download:
- url: "{collection_season_34_url}"
variables:
collection_season_number: "34"
collection_season_number_padded: "34"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_34_name}"
attributes:
number: "34"
collection_season_35:
download:
- url: "{collection_season_35_url}"
variables:
collection_season_number: "35"
collection_season_number_padded: "35"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_35_name}"
attributes:
number: "35"
collection_season_36:
download:
- url: "{collection_season_36_url}"
variables:
collection_season_number: "36"
collection_season_number_padded: "36"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_36_name}"
attributes:
number: "36"
collection_season_37:
download:
- url: "{collection_season_37_url}"
variables:
collection_season_number: "37"
collection_season_number_padded: "37"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_37_name}"
attributes:
number: "37"
collection_season_38:
download:
- url: "{collection_season_38_url}"
variables:
collection_season_number: "38"
collection_season_number_padded: "38"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_38_name}"
attributes:
number: "38"
collection_season_39:
download:
- url: "{collection_season_39_url}"
variables:
collection_season_number: "39"
collection_season_number_padded: "39"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_39_name}"
attributes:
number: "39"
collection_season_40:
download:
- url: "{collection_season_40_url}"
variables:
collection_season_number: "40"
collection_season_number_padded: "40"
playlist_thumbnails:
- name: "{season_poster_file_name}"
uid: "latest_entry"
output_directory_nfo_tags:
tags:
namedseason:
- tag: "{collection_season_40_name}"
attributes:
number: "40"

View file

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

View file

@ -5,10 +5,12 @@ from typing import List
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.preset import Preset
from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.yaml import load_yaml
from ytdl_sub.validators.validators import LiteralDictValidator
FILE_PRESET_APPLY_KEY = "__preset__"
FILE_SUBSCRIPTION_VALUE_KEY = "__value__"
class Subscription(SubscriptionDownload):
@ -64,7 +66,9 @@ class Subscription(SubscriptionDownload):
@classmethod
def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]:
"""
Loads subscriptions from a file and applies __preset__ to all of them if present.
Loads subscriptions from a file and applies ``__preset__`` to all of them if present.
If a subscription is in the form of key: value, it will set value to the override
variable defined in ``__value__``.
Parameters
----------
@ -76,11 +80,17 @@ class Subscription(SubscriptionDownload):
Returns
-------
List of subscriptions, for each one in the subscription yaml
Raises
------
ValidationException
If subscription file is misconfigured
"""
subscriptions: List["Subscription"] = []
subscription_dict = load_yaml(file_path=subscription_path)
has_file_preset = FILE_PRESET_APPLY_KEY in subscription_dict
has_file_subscription_value = FILE_SUBSCRIPTION_VALUE_KEY in subscription_dict
# If a file preset is present...
if has_file_preset:
@ -94,12 +104,39 @@ class Subscription(SubscriptionDownload):
config = copy.deepcopy(config)
config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict
if has_file_subscription_value:
if not isinstance(subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY], str):
raise ValidationException(
f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription"
f"must be a string that corresponds to an override variable"
)
for subscription_key, subscription_object in subscription_dict.items():
# Skip file preset
if subscription_key == FILE_PRESET_APPLY_KEY:
# Skip file preset or value
if subscription_key in [FILE_PRESET_APPLY_KEY, FILE_SUBSCRIPTION_VALUE_KEY]:
continue
# If the subscription obj is just a string, set it to the override variable
# defined in FILE_SUBSCRIPTION_VALUE_KEY
if isinstance(subscription_object, str) and has_file_subscription_value:
subscription_object = {
"overrides": {
subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY]: subscription_object
}
}
elif isinstance(subscription_object, dict):
pass
elif isinstance(subscription_object, str) and not has_file_subscription_value:
raise ValidationException(
f"Subscription {subscription_key} is a string, but "
f"{FILE_SUBSCRIPTION_VALUE_KEY} is not set to an override variable"
)
else:
raise ValidationException(
f"Subscription {subscription_key} should be in the form of a preset"
)
# If it has file_preset, inject it as a parent preset
if has_file_preset:
parent_preset = subscription_object.get("preset", [])

View file

@ -1,4 +1,5 @@
import contextlib
import logging
import os
import shutil
from abc import ABC
@ -6,12 +7,14 @@ from pathlib import Path
from typing import List
from typing import Optional
from ytdl_sub.downloaders.base_downloader import BaseDownloader
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.plugin import SplitPlugin
from ytdl_sub.downloaders.info_json.info_json_downloader import InfoJsonDownloader
from ytdl_sub.downloaders.info_json.info_json_downloader import InfoJsonDownloaderOptions
from ytdl_sub.downloaders.source_plugin import SourcePlugin
from ytdl_sub.downloaders.url.downloader import MultiUrlDownloader
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.subscriptions.base_subscription import BaseSubscription
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
from ytdl_sub.utils.datetime import to_date_range
@ -19,11 +22,13 @@ from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
from ytdl_sub.utils.logger import Logger
logger: logging.Logger = Logger.get()
def _get_split_plugin(plugins: List[Plugin]) -> Optional[Plugin]:
split_plugins = [plugin for plugin in plugins if plugin.is_split_plugin]
def _get_split_plugin(plugins: List[Plugin]) -> Optional[SplitPlugin]:
split_plugins = [plugin for plugin in plugins if isinstance(plugin, SplitPlugin)]
if len(split_plugins) == 1:
return split_plugins[0]
@ -67,16 +72,12 @@ class SubscriptionDownload(BaseSubscription, ABC):
entry=entry,
)
# TODO: see if entry even has a thumbnail
if self.output_options.thumbnail_name:
# Always pretend to include the thumbnail in a dry-run
if self.output_options.thumbnail_name and (dry_run or entry.is_thumbnail_downloaded()):
output_thumbnail_name = self.overrides.apply_formatter(
formatter=self.output_options.thumbnail_name, entry=entry
)
# We always convert entry thumbnails to jpgs, and is performed here
if not dry_run:
convert_download_thumbnail(entry=entry)
# Copy the thumbnails since they could be used later for other things
self._enhanced_download_archive.save_file_to_output_directory(
file_name=entry.get_download_thumbnail_name(),
@ -84,6 +85,10 @@ class SubscriptionDownload(BaseSubscription, ABC):
entry=entry,
copy_file=True,
)
elif not entry.is_thumbnail_downloaded():
logger.warning(
"Cannot save thumbnail for '%s' because it is not available", entry.title
)
if self.output_options.info_json_name:
output_info_json_name = self.overrides.apply_formatter(
@ -144,8 +149,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
self._enhanced_download_archive.remove_stale_files(date_range=date_range_to_keep)
self._enhanced_download_archive.save_download_mappings()
FileHandler.delete(self._enhanced_download_archive.archive_working_file_path)
FileHandler.delete(self._enhanced_download_archive.mapping_working_file_path)
FileHandler.delete(self._enhanced_download_archive.working_file_path)
@contextlib.contextmanager
def _remove_empty_directories_in_output_directory(self):
@ -174,23 +178,14 @@ class SubscriptionDownload(BaseSubscription, ABC):
-------
List of plugins defined in the subscription, initialized and ready to use.
"""
# Always add plugins provided by the downloader
plugins: List[Plugin] = self.downloader_class.added_plugins(
downloader_options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive,
overrides=self.overrides,
)
for plugin_type, plugin_options in self.plugins.zipped():
plugin = plugin_type(
plugin_options=plugin_options,
return [
plugin_type(
options=plugin_options,
overrides=self.overrides,
enhanced_download_archive=self._enhanced_download_archive,
)
plugins.append(plugin)
return plugins
for plugin_type, plugin_options in self.plugins.zipped()
]
@classmethod
def _cleanup_entry_files(cls, entry: Entry):
@ -201,7 +196,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
@classmethod
def _preprocess_entry(cls, plugins: List[Plugin], entry: Entry) -> Optional[Entry]:
maybe_entry: Optional[Entry] = entry
for plugin in plugins:
for plugin in sorted(plugins, key=lambda _plugin: _plugin.priority.modify_entry_metadata):
if (maybe_entry := plugin.modify_entry_metadata(maybe_entry)) is None:
return None
@ -244,7 +239,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
self._cleanup_entry_files(entry)
def _process_split_entry(
self, split_plugin: Plugin, plugins: List[Plugin], dry_run: bool, entry: Entry
self, split_plugin: SplitPlugin, plugins: List[Plugin], dry_run: bool, entry: Entry
) -> None:
entry_: Optional[Entry] = entry
@ -291,7 +286,7 @@ class SubscriptionDownload(BaseSubscription, ABC):
def _process_subscription(
self,
plugins: List[Plugin],
downloader: BaseDownloader,
downloader: SourcePlugin,
dry_run: bool,
) -> FileHandlerTransactionLog:
with self._subscription_download_context_managers():
@ -339,14 +334,16 @@ class SubscriptionDownload(BaseSubscription, ABC):
dry_run=dry_run,
)
downloader = self.downloader_class(
download_options=self.downloader_options,
downloader = MultiUrlDownloader(
options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive,
download_ytdl_options=subscription_ytdl_options.download_builder(),
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
overrides=self.overrides,
)
plugins.extend(downloader.added_plugins())
return self._process_subscription(
plugins=plugins,
downloader=downloader,
@ -365,8 +362,27 @@ class SubscriptionDownload(BaseSubscription, ABC):
self._enhanced_download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins()
subscription_ytdl_options = SubscriptionYTDLOptions(
preset=self._preset_options,
plugins=plugins,
enhanced_download_archive=self._enhanced_download_archive,
working_directory=self.working_directory,
dry_run=dry_run,
)
# Re-add the original downloader class' plugins
plugins.extend(
MultiUrlDownloader(
options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive,
download_ytdl_options=subscription_ytdl_options.download_builder(),
metadata_ytdl_options=subscription_ytdl_options.metadata_builder(),
overrides=self.overrides,
).added_plugins()
)
downloader = InfoJsonDownloader(
download_options=InfoJsonDownloaderOptions(name="no-op", value={}),
options=InfoJsonDownloaderOptions(name="no-op", value={}),
enhanced_download_archive=self._enhanced_download_archive,
download_ytdl_options=YTDLOptionsBuilder(),
metadata_ytdl_options=YTDLOptionsBuilder(),

View file

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

View file

@ -1,12 +1,12 @@
import json
import re
import subprocess
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.variables.kwargs import CHAPTERS
from ytdl_sub.entries.variables.kwargs import YTDL_SUB_CUSTOM_CHAPTERS
from ytdl_sub.utils.file_handler import FileMetadata
@ -202,6 +202,7 @@ class Chapters:
timestamps: List[Timestamp] = []
titles: List[str] = []
# Try to accumulate chapters by parsing lines individually
for line in input_str.split("\n"):
# Timestamp captured, store it
if match := Timestamp.TIMESTAMP_REGEX.search(line):
@ -211,53 +212,12 @@ class Chapters:
# Remove timestamp and surrounding whitespace from it
title_str = re.sub(f"\\s*{re.escape(timestamp_str)}\\s*", " ", line).strip()
titles.append(title_str)
elif len(timestamps) >= 3:
return Chapters(timestamps=timestamps, titles=titles)
# Timestamp was not stored, if only contained 1, reset
else:
timestamps = []
titles = []
return Chapters(timestamps=timestamps, titles=titles)
@classmethod
def from_embedded_chapters(cls, ffprobe_path: str, file_path: str) -> "Chapters":
"""
Parameters
----------
ffprobe_path
Path to ffprobe executable
file_path
File to read ffmpeg chapter metadata from
Returns
-------
Chapters object
"""
proc = subprocess.run(
[
ffprobe_path,
"-loglevel",
"quiet",
"-print_format",
"json",
"-show_chapters",
"--",
file_path,
],
check=True,
stdout=subprocess.PIPE,
encoding="utf-8",
)
embedded_chapters = json.loads(proc.stdout)
timestamps: List[Timestamp] = []
titles: List[str] = []
for chapter in embedded_chapters["chapters"]:
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
titles.append(chapter["tags"]["title"])
return Chapters(timestamps=timestamps, titles=titles)
# If more than 3 timestamps were parsed, return it
if len(timestamps) >= 3:
return Chapters(timestamps=timestamps, titles=titles)
# Otherwise return empty chapters
return Chapters(timestamps=[], titles=[])
@classmethod
def from_entry_chapters(cls, entry: Entry) -> "Chapters":
@ -274,13 +234,14 @@ class Chapters:
timestamps: List[Timestamp] = []
titles: List[str] = []
chapters = {}
if entry.kwargs_contains("chapters"):
chapters = entry.kwargs("chapters") or []
for chapter in chapters:
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
titles.append(chapter["title"])
if entry.kwargs_contains(CHAPTERS):
for chapter in entry.kwargs_get(CHAPTERS, []):
timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"]))))
titles.append(chapter["title"])
elif entry.kwargs_contains(YTDL_SUB_CUSTOM_CHAPTERS):
for start_time, title in entry.kwargs_get(YTDL_SUB_CUSTOM_CHAPTERS, {}).items():
timestamps.append(Timestamp.from_str(start_time))
titles.append(title)
return Chapters(timestamps=timestamps, titles=titles)

View file

@ -168,6 +168,7 @@ def set_ffmpeg_metadata_chapters(
metadata_file.name,
"-map",
"0",
"-dn", # ignore data streams
"-map_chapters",
"1",
"-bitexact", # for reproducibility
@ -192,7 +193,13 @@ def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -
"""
tmp_file_path = FFMPEG.tmp_file_path(file_path)
ffmpeg_args = ["-i", file_path, "-map", "0"]
ffmpeg_args = [
"-i",
file_path,
"-map",
"0",
"-dn", # ignore data streams
]
for key, value in key_values.items():
ffmpeg_args.extend(["-metadata", f"{key}={value}"])
ffmpeg_args.extend(["-codec", "copy", tmp_file_path])

View file

@ -44,7 +44,19 @@ else:
/ str(working_directory_path).replace("/", "_")
)
lock_file = open(lock_file_path, "w", encoding="utf-8")
try:
lock_file = open(lock_file_path, "w", encoding="utf-8")
except FileNotFoundError as exc:
# pylint: disable=line-too-long
raise ValidationException(
"Failed to create a file-lock to prevent multiple instances of ytdl-sub from "
"colliding with each other. If you get this error, it typically means it tried to "
"create the file in a directory that is not a part of the same filesystem that "
"ytdl-sub is running on. See "
"https://ytdl-sub.readthedocs.io/en/latest/config.html#ytdl_sub.config.config_validator.ConfigOptions.lock_directory "
"on how to change the directory that this lock gets written to."
) from exc
# pylint: enable=line-too-long
try:
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)

View file

@ -1,11 +1,14 @@
import logging
import os
import tempfile
from subprocess import CalledProcessError
from typing import Optional
from urllib.request import urlopen
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.retry import retry
@ -13,39 +16,39 @@ class ThumbnailTypes:
LATEST_ENTRY = "latest_entry"
def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None:
logger: logging.Logger = Logger.get("thumbnail")
def try_convert_download_thumbnail(entry: Entry) -> None:
"""
Converts an entry's downloaded thumbnail into jpg format
Converts an entry's downloaded thumbnail into jpg format.
Log with a warning if the thumbnail is not found or fails to convert
Parameters
----------
entry
Entry with the thumbnail
error_if_not_found
If the thumbnail is not found, error if True.
Raises
------
ValueError
Entry thumbnail file not found
"""
download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path()
download_thumbnail_path: Optional[str] = entry.try_get_ytdlp_download_thumbnail_path()
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
# If it was already converted, do not convert again
if os.path.isfile(download_thumbnail_path_as_jpg):
if entry.is_thumbnail_downloaded():
return
if not download_thumbnail_path:
if error_if_not_found:
raise ValueError("Thumbnail not found")
logger.warning("Thumbnail for '%s' was not downloaded", entry.title)
return
if not download_thumbnail_path == download_thumbnail_path_as_jpg:
FFMPEG.run(
["-y", "-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg]
)
FileHandler.delete(download_thumbnail_path)
try:
FFMPEG.run(
["-y", "-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg]
)
except CalledProcessError:
logger.warning("Failed to convert thumbnail for '%s' to jpg", entry.title)
finally:
FileHandler.delete(download_thumbnail_path)
@retry(times=3, exceptions=(Exception,))

View file

@ -38,9 +38,9 @@ def load_yaml(file_path: str | Path) -> Dict:
with open(file_path, "r", encoding="utf-8") as file:
output = yaml.safe_load(file)
except YAMLError as yaml_exception:
logger.debug(yaml_exception)
raise InvalidYamlException(
f"'{file_path}' has invalid YAML, copy-paste it into a YAML checker to find the issue."
f"'{file_path}' has invalid YAML:\n{yaml_exception}\n\n"
f"Copy-pasting it into a YAML parser can also help find the issue."
) from yaml_exception
if not isinstance(output, dict):

View file

@ -179,6 +179,16 @@ class DictValidator(Validator):
super().__init__(name, value)
self.__validator_dict: Dict[str, Validator] = {}
@final
@property
def _root_name(self) -> str:
"""
Returns
-------
"first" from the first.element.of.the.name
"""
return self._name.split(".")[0]
@final
@property
def _dict(self) -> dict:

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

View file

@ -6,7 +6,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
@pytest.fixture
def single_song_preset_dict(output_directory):
def single_song_preset_dict_old_format(output_directory):
return {
"preset": "single",
# test multi-tags
@ -23,6 +23,26 @@ def single_song_preset_dict(output_directory):
}
@pytest.fixture
def single_song_preset_dict(output_directory):
return {
"preset": "single",
# test multi-tags
"music_tags": {"genres": ["multi_tag_1", "multi_tag_2"]},
# test the new embed_thumbnail plugin
"embed_thumbnail": True,
# download the worst format so it is fast
"ytdl_options": {
"format": "worst[ext=mp4]",
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
},
"overrides": {
"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
"music_directory": output_directory,
},
}
@pytest.fixture
def multiple_songs_preset_dict(output_directory):
return {
@ -42,7 +62,33 @@ def multiple_songs_preset_dict(output_directory):
class TestAudioExtract:
@pytest.mark.parametrize("dry_run", [True, False])
def test_audio_extract_single_song(
def test_audio_extract_single_song_old_format(
self,
music_audio_config,
single_song_preset_dict_old_format,
output_directory,
dry_run,
):
subscription = Subscription.from_dict(
config=music_audio_config,
preset_name="single_song_test",
preset_dict=single_song_preset_dict_old_format,
)
transaction_log = subscription.download(dry_run=dry_run)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="plugins/test_audio_extract_single_old_format.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name="plugins/test_audio_extract_single_old_format.json",
)
@pytest.mark.parametrize("dry_run", [True, False])
def test_audio_extract_single_song_new_format(
self,
music_audio_config,
single_song_preset_dict,

View file

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

View file

@ -10,7 +10,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def preset_dict(output_directory):
return {
"preset": "music_video",
"download": {"url": "https://www.youtube.com/watch?v=2zYF9JLHDmA"},
"download": "https://www.youtube.com/watch?v=2zYF9JLHDmA",
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast
"ytdl_options": {

View file

@ -1,4 +1,6 @@
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.subscriptions.subscription import Subscription
@ -7,7 +9,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def preset_dict(output_directory):
return {
"preset": "music_video",
"download": {"url": "https://www.youtube.com/watch?v=2zYF9JLHDmA"},
"download": "https://www.youtube.com/watch?v=2zYF9JLHDmA",
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast
"ytdl_options": {
@ -17,9 +19,23 @@ def preset_dict(output_directory):
}
@pytest.fixture
def playlist_preset_dict(output_directory):
return {
"preset": "music_video",
"download": "https://www.youtube.com/playlist?list=PL5BC0FC26BECA5A35",
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast
"ytdl_options": {
"postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility
},
"match_filters": {"filters": ["view_count > 20000"]},
}
class TestFileConvert:
@pytest.mark.parametrize("dry_run", [True, False])
def test_match_filters(
def test_match_filters_empty(
self,
music_video_config,
preset_dict,
@ -34,3 +50,31 @@ class TestFileConvert:
transaction_log = subscription.download(dry_run=dry_run)
assert transaction_log.is_empty
@pytest.mark.parametrize("dry_run", [True, False])
def test_match_filters_partial(
self,
music_video_config,
playlist_preset_dict,
output_directory,
dry_run,
):
subscription = Subscription.from_dict(
config=music_video_config,
preset_name="match_filter_test",
preset_dict=playlist_preset_dict,
)
transaction_log = subscription.download(dry_run=dry_run)
summary_path = "plugins/match_filters"
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name=f"{summary_path}/test_match_filters_partial.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=dry_run,
expected_download_summary_file_name=f"{summary_path}/test_match_filters_partial.json",
)

View file

@ -1,8 +1,6 @@
import re
import pytest
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
@ -11,13 +9,10 @@ from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture
def single_song_video_dict(output_directory):
return {
"download": {
"download_strategy": "url",
"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
},
"download": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
"output_options": {"output_directory": output_directory, "file_name": "will_error.mp4"},
# test multi-tags
"music_tags": {"embed_thumbnail": True, "tags": {"genres": ["multi_tag_1", "multi_tag_2"]}},
"music_tags": {"genres": ["multi_tag_1", "multi_tag_2"]},
# download the worst format so it is fast
"ytdl_options": {
"format": "worst[ext=mp4]",

View file

@ -8,7 +8,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def subscription_dict(output_directory):
return {
"preset": "music_video",
"download": {"url": "https://www.youtube.com/shorts/ucYmEqmlhFw"},
"download": "https://www.youtube.com/shorts/ucYmEqmlhFw",
# override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast

View file

@ -1,20 +1,23 @@
import copy
import re
from typing import Any
from typing import Dict
import mergedeep
import pytest
from expected_transaction_log import assert_transaction_log_matches
from ytdl_sub.config.preset import Preset
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import RegexNoMatchException
from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture
def regex_subscription_dict(output_directory):
def regex_subscription_dict_base(output_directory):
return {
"preset": "music_video",
"download": {"url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35"},
"download": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35",
# override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory},
# download the worst format so it is fast
@ -24,13 +27,6 @@ def regex_subscription_dict(output_directory):
"regex": {
# tests that skip_if_match_fails defaults to True
"from": {
"title": {
"match": [
"should not cap (.+) - (.+)",
".*\\[(.+) - (Feb.+)]", # should filter out march video
],
"capture_group_names": ["title_type", "title_date"],
},
"description": {
"match": [".*http:\\/\\/(.+).com.*"],
"capture_group_names": ["description_website"],
@ -48,58 +44,188 @@ def regex_subscription_dict(output_directory):
},
},
},
"nfo_tags": {
"tags": {
"title_cap_1": "{title_type}",
"title_cap_1_sanitized": "{title_type_sanitized}",
"title_cap_2": "{title_date}",
"desc_cap": "{description_website}",
"upload_date_both_caps": "{upload_captured_year} and {upload_captured_month}",
"override_with_capture_variable": "{contains_regex_default}",
"override_with_capture_variable_sanitized": "{contains_regex_sanitized_default}",
}
},
"overrides": {
"in_regex_default": "in regex default",
"contains_regex_default": "contains {title_type}",
"contains_regex_sanitized_default": "contains {title_type_sanitized}",
},
}
@pytest.fixture
def regex_subscription_dict_no_match_fails(regex_subscription_dict):
regex_subscription_dict["regex"]["skip_if_match_fails"] = False
return regex_subscription_dict
def regex_subscription_dict(regex_subscription_dict_base, output_directory):
return mergedeep.merge(
regex_subscription_dict_base,
{
"regex": {
# tests that skip_if_match_fails defaults to True
"from": {
"title": {
"match": [
"should not cap (.+) - (.+)",
".*\\[(.+) - (Feb.+)]", # should filter out march video
],
"capture_group_names": ["title_type", "title_date"],
},
},
},
"nfo_tags": {
"tags": {
"title_cap_1": "{title_type}",
"title_cap_1_sanitized": "{title_type_sanitized}",
"title_cap_2": "{title_date}",
"desc_cap": "{description_website}",
"upload_date_both_caps": "{upload_captured_year} and {upload_captured_month}",
"override_with_capture_variable": "{contains_regex_default}",
"override_with_capture_variable_sanitized": "{contains_regex_sanitized_default}",
}
},
"overrides": {
"contains_regex_default": "contains {title_type}",
"contains_regex_sanitized_default": "contains {title_type_sanitized}",
},
},
)
@pytest.fixture
def regex_subscription_dict_exclude(regex_subscription_dict_base, output_directory):
return mergedeep.merge(
regex_subscription_dict_base,
{
"regex": {
# tests that skip_if_match_fails defaults to True
"from": {
"title": {
"exclude": [
"should not cap",
".*Feb.*", # should filter out march video
],
},
},
},
},
)
@pytest.fixture
def regex_subscription_dict_match_and_exclude(regex_subscription_dict_base, output_directory):
return mergedeep.merge(
regex_subscription_dict_base,
{
"regex": {
# tests that skip_if_match_fails defaults to True
"from": {
"title": {
"match": [
"should not cap (.+) - (.+)",
".*\\[(.+) - (Feb.+)]", # should filter out march video
],
"capture_group_names": ["title_type", "title_date"],
"exclude": [
"should not cap",
".*27.*", # should filter out Feb 27th video
],
},
},
},
"nfo_tags": {
"tags": {
"title_cap_1": "{title_type}",
"title_cap_1_sanitized": "{title_type_sanitized}",
"title_cap_2": "{title_date}",
"desc_cap": "{description_website}",
"upload_date_both_caps": "{upload_captured_year} and {upload_captured_month}",
"override_with_capture_variable": "{contains_regex_default}",
"override_with_capture_variable_sanitized": "{contains_regex_sanitized_default}",
}
},
"overrides": {
"contains_regex_default": "contains {title_type}",
"contains_regex_sanitized_default": "contains {title_type_sanitized}",
},
},
)
@pytest.fixture
def regex_subscription_dict_match_and_exclude_override_variable(
regex_subscription_dict_base, output_directory
):
return mergedeep.merge(
regex_subscription_dict_base,
{
"regex": {
# tests that skip_if_match_fails defaults to True
"from": {
"override_title": {
"exclude": [
"should not cap",
".*Feb.*", # should filter out march video
],
},
"override_description": {
"match": [".*http:\\/\\/(.+).com.*"],
"capture_group_names": ["override_description_website"],
},
},
},
"overrides": {"override_title": "{title}", "override_description": "{description}"},
},
)
@pytest.fixture
def playlist_subscription(music_video_config, regex_subscription_dict):
playlist_preset = Preset.from_dict(
return Subscription.from_dict(
config=music_video_config,
preset_name="regex_capture_playlist_test",
preset_dict=regex_subscription_dict,
)
return Subscription.from_preset(
preset=playlist_preset,
@pytest.fixture
def playlist_subscription_no_match_fails(
music_video_config: ConfigFile, regex_subscription_dict: Dict[str, Any]
):
regex_subscription_dict["regex"]["skip_if_match_fails"] = False
return Subscription.from_dict(
config=music_video_config,
preset_name="regex_capture_playlist_test",
preset_dict=regex_subscription_dict,
)
@pytest.fixture
def playlist_subscription_no_match_fails(
music_video_config, regex_subscription_dict_no_match_fails
):
playlist_preset = Preset.from_dict(
def playlist_subscription_exclude(
music_video_config: ConfigFile, regex_subscription_dict_exclude: Dict[str, Any]
) -> Subscription:
return Subscription.from_dict(
config=music_video_config,
preset_name="regex_capture_playlist_test",
preset_dict=regex_subscription_dict_no_match_fails,
preset_name="regex_exclude_playlist_test",
preset_dict=regex_subscription_dict_exclude,
)
return Subscription.from_preset(
preset=playlist_preset,
@pytest.fixture
def playlist_subscription_overrides(
music_video_config: ConfigFile,
regex_subscription_dict_match_and_exclude_override_variable: Dict[str, Any],
) -> Subscription:
return Subscription.from_dict(
config=music_video_config,
preset_name="regex_using_overrides_test",
preset_dict=regex_subscription_dict_match_and_exclude_override_variable,
)
@pytest.fixture
def playlist_subscription_match_and_exclude(
music_video_config: ConfigFile, regex_subscription_dict_match_and_exclude: Dict[str, Any]
) -> Subscription:
return Subscription.from_dict(
config=music_video_config,
preset_name="regex_match_and_exclude_playlist_test",
preset_dict=regex_subscription_dict_match_and_exclude,
)
@ -113,6 +239,35 @@ class TestRegex:
transaction_log_summary_file_name="plugins/test_regex.txt",
)
def test_regex_excludes_success(self, playlist_subscription_exclude, output_directory):
# Should only contain the march video
transaction_log = playlist_subscription_exclude.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="plugins/test_regex_exclude.txt",
)
def test_regex_match_and_excludes_success(
self, playlist_subscription_match_and_exclude, output_directory
):
# Should only contain the Feb 1st video
transaction_log = playlist_subscription_match_and_exclude.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="plugins/test_regex_match_and_exclude.txt",
)
def test_regex_using_overrides_success(self, playlist_subscription_overrides, output_directory):
# Should only contain the march video
transaction_log = playlist_subscription_overrides.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="plugins/test_regex_overrides.txt",
)
def test_regex_fails_no_match(self, playlist_subscription_no_match_fails, output_directory):
with pytest.raises(
RegexNoMatchException,
@ -122,6 +277,34 @@ class TestRegex:
):
_ = playlist_subscription_no_match_fails.download(dry_run=True)
def test_regex_fails_capture_group_with_only_excludes(
self, regex_subscription_dict_exclude, music_video_config
):
regex_subscription_dict_exclude["regex"]["from"]["title"]["capture_group_names"] = ["uid"]
with pytest.raises(
ValidationException,
match=re.escape(
"capture group parameters requires at least one `match` to be specified"
),
):
_ = Subscription.from_dict(
config=music_video_config,
preset_name="test_regex_fails_capture_group_is_source_variable",
preset_dict=regex_subscription_dict_exclude,
)
def test_regex_fails_no_match_or_exclude(self, regex_subscription_dict, music_video_config):
del regex_subscription_dict["regex"]["from"]["title"]["match"]
with pytest.raises(
ValidationException,
match=re.escape("must specify either `match` or `exclude`"),
):
_ = Subscription.from_dict(
config=music_video_config,
preset_name="test_regex_fails_capture_group_is_source_variable",
preset_dict=regex_subscription_dict,
)
def test_regex_fails_capture_group_is_source_variable(
self, regex_subscription_dict, music_video_config
):
@ -164,7 +347,9 @@ class TestRegex:
)
with pytest.raises(
ValidationException,
match=re.escape("cannot regex capture 'dne' because it is not a source variable"),
match=re.escape(
"cannot regex capture 'dne' because it is not a source or override variable"
),
):
_ = Subscription.from_dict(
config=music_video_config,

View file

@ -32,7 +32,9 @@ def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict
{
"regex": {
"from": {
"chapter_title": {
# Ensure regex can handle override variables that come from the
# post-metadata stage
"override_chapter_title": {
"match": r"\d+\. (.+)",
"capture_group_names": "captured_track_title",
"capture_group_defaults": "{chapter_title}",
@ -54,6 +56,7 @@ def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict
}
},
"overrides": {
"override_chapter_title": "{chapter_title}",
"track_title": "{captured_track_title}",
"track_album": "{captured_track_album}",
"track_artist": "{captured_track_artist}",
@ -133,7 +136,7 @@ class TestSplitByChapters:
mergedeep.merge(
yt_album_as_chapters_with_regex_preset_dict,
{
"download": {"url": "https://youtube.com/watch?v=HKTNxEqsN3Q"},
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
"split_by_chapters": {"when_no_chapters": when_no_chapters},
},
)

View file

@ -10,7 +10,7 @@ from ytdl_sub.subscriptions.subscription import Subscription
def single_video_subs_embed_preset_dict(output_directory):
return {
"preset": "music_video",
"download": {"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"},
"download": "https://www.youtube.com/watch?v=2lAe1cqCOXo",
# override the output directory with our fixture-generated dir
"output_options": {"output_directory": output_directory},
"subtitles": {

View file

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

View file

@ -1,23 +1,30 @@
from unittest.mock import patch
import pytest
from conftest import preset_dict_to_dl_args
from e2e.conftest import mock_run_from_cli
from expected_download import assert_expected_downloads
from expected_transaction_log import assert_transaction_log_matches
from mergedeep import mergedeep
from ytdl_sub.downloaders.ytdlp import YTDLP
from ytdl_sub.entries.entry import Entry
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
@pytest.fixture
def single_video_preset_dict(output_directory):
def single_video_preset_dict_old_video_tags_format(output_directory):
return {
"preset": "music_video",
"download": {"url": "https://youtube.com/watch?v=HKTNxEqsN3Q"},
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
# override the output directory with our fixture-generated dir
"output_options": {
"output_directory": output_directory,
"maintain_download_archive": False,
},
# embed thumb into the video
"embed_thumbnail": True,
# download the worst format so it is fast
"ytdl_options": {
"format": "worst[ext=mp4]",
@ -32,6 +39,30 @@ def single_video_preset_dict(output_directory):
}
@pytest.fixture
def single_video_preset_dict(output_directory):
return {
"preset": "music_video",
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
# override the output directory with our fixture-generated dir
"output_options": {
"output_directory": output_directory,
"maintain_download_archive": False,
},
# embed thumb into the video
"embed_thumbnail": True,
# download the worst format so it is fast
"ytdl_options": {
"format": "worst[ext=mp4]",
},
# also test video tags
"video_tags": {
"title": "{title}",
},
"overrides": {"artist": "JMC"},
}
@pytest.fixture
def single_tv_show_video_nulled_values_preset_dict(output_directory):
return {
@ -49,6 +80,13 @@ def single_tv_show_video_nulled_values_preset_dict(output_directory):
"format": "worst[ext=mp4]",
"max_downloads": 2,
},
# test override variables added by ytdl-sub
"nfo_tags": {
"tags": {
"subscription_name": "{subscription_name}",
"subscription_name_sanitized": "{subscription_name_sanitized}",
}
},
"overrides": {
"url": "https://www.youtube.com/@ProjectZombie603",
"tv_show_name": "Project Zombie",
@ -63,7 +101,26 @@ def single_video_preset_dict_dl_args(single_video_preset_dict):
class TestYoutubeVideo:
@pytest.mark.parametrize("dry_run", [True, False])
def test_single_video_old_video_tags_format_download(
self,
music_video_config,
single_video_preset_dict_old_video_tags_format,
output_directory,
):
single_video_subscription = Subscription.from_dict(
config=music_video_config,
preset_name="music_video_single_video_test",
preset_dict=single_video_preset_dict_old_video_tags_format,
)
transaction_log = single_video_subscription.download(dry_run=True)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="youtube/test_video.txt",
)
@pytest.mark.parametrize("dry_run", [True])
def test_single_video_download(
self,
music_video_config,
@ -89,6 +146,44 @@ class TestYoutubeVideo:
expected_download_summary_file_name="youtube/test_video.json",
)
def test_single_video_download_missing_thumbnail(
self,
music_video_config,
single_video_preset_dict,
working_directory,
output_directory,
):
single_video_subscription = Subscription.from_dict(
config=music_video_config,
preset_name="music_video_single_video_test",
preset_dict=single_video_preset_dict,
)
def delete_entry_thumb(entry: Entry) -> None:
FileHandler.delete(entry.get_download_thumbnail_path())
try_convert_download_thumbnail(entry=entry)
# Pretend the thumbnail did not download via returning nothing for its downloaded path
with patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1), patch.object(
Entry, "try_get_ytdlp_download_thumbnail_path"
) as mock_ytdlp_path, patch(
"ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail",
side_effect=delete_entry_thumb,
):
mock_ytdlp_path.return_value = None
transaction_log = single_video_subscription.download(dry_run=False)
assert_transaction_log_matches(
output_directory=output_directory,
transaction_log=transaction_log,
transaction_log_summary_file_name="youtube/test_video_missing_thumb.txt",
)
assert_expected_downloads(
output_directory=output_directory,
dry_run=False,
expected_download_summary_file_name="youtube/test_video_missing_thumb.json",
)
@pytest.mark.parametrize("dry_run", [True, False])
def test_single_video_download_from_cli_dl(
self,

View file

@ -124,7 +124,6 @@ def assert_expected_downloads(
dry_run: bool,
expected_download_summary_file_name: str,
ignore_md5_hashes_for: Optional[List[str]] = None,
regenerate_expected_download_summary: bool = REGENERATE_FIXTURES,
):
if dry_run:
output_directory_contents = list(Path(output_directory).rglob("*"))
@ -134,7 +133,11 @@ def assert_expected_downloads(
return
summary_full_path = _EXPECTED_DOWNLOADS_SUMMARY_PATH / expected_download_summary_file_name
if regenerate_expected_download_summary:
# USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
# Updates the file with the input transaction log. Should only be used to update
# tests after an expected change is made.
if REGENERATE_FIXTURES:
os.makedirs(os.path.dirname(summary_full_path), exist_ok=True)
ExpectedDownloads.from_directory(directory_path=output_directory).to_summary_file(
summary_file_path=summary_full_path

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
{
"Project Zombie/.ytdl-sub-recent-download-archive.json": "ca7275efd7f2ebe489b8ca4007dc5361",
"Project Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server Teaser Trailer-thumb.jpg": "705ca4e0d99b37e9ecdf6bfe4b90c59b",
"Project Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server Teaser Trailer.info.json": "a8bc452d1b2075d761f5f2bf3812ecb9",
"Project Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server Teaser Trailer.info.json": "f249bd1137702b3db3d2805bbf4bfff8",
"Project Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server Teaser Trailer.mp4": "dbb140af8676f34fb701d5199e8708ea",
"Project Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server Teaser Trailer.nfo": "bb868035301eed6afea636f7f7bddaf5",
"Project Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server Teaser Trailer.nfo": "ee5bee94667ed4f1d47af08384418d3c",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.info.json": "abd80bd5828a3b986f41ec0bd8d497c1",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.nfo": "6cbd3a26974aa3be8ab73578e748e013",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.info.json": "e143a26148069d0477dc45896afba93e",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
"Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
"Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
"Project Zombie/tvshow.nfo": "3adb1769f4ca5a0c4dd59a9f662a4780"
"Project Zombie/tvshow.nfo": "bc3d17c23371da3af6db1108faea5a84"
}

View file

@ -1,10 +1,10 @@
{
"Project Zombie/.ytdl-sub-recent-download-archive.json": "46170edaf7cc51e5f59ef5d4c0476200",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.info.json": "abd80bd5828a3b986f41ec0bd8d497c1",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.nfo": "6cbd3a26974aa3be8ab73578e748e013",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.info.json": "e143a26148069d0477dc45896afba93e",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
"Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
"Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
"Project Zombie/tvshow.nfo": "3adb1769f4ca5a0c4dd59a9f662a4780"
"Project Zombie/tvshow.nfo": "bc3d17c23371da3af6db1108faea5a84"
}

View file

@ -1,7 +1,7 @@
{
".ytdl-sub-file_convert_test-download-archive.json": "65aa49c7345f9fc201e42ddc4a96b19b",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3-thumb.jpg": "662fcaadf6e80d63591bac19a5fdffb0",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "cc013e7b638b49e51970e855bcf821c8",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "5201f78cc65d5bb26aeea114e11d6264",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mp4": "390177bcc076e309d1534b8bb5afdcc7",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a"
}

View file

@ -1,7 +1,7 @@
{
".ytdl-sub-file_convert_test-download-archive.json": "74813dccf4e9732e49f5dc3c2d66f3be",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3-thumb.jpg": "662fcaadf6e80d63591bac19a5fdffb0",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "848617cf0fa5f9e51b929de72d594d78",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.info.json": "20cf671c3a613d4aae8f5bfbf14feba0",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.mkv": "b5f248b560f89f3f2a83fcdcd197d486",
"Beyond The Guitar/Beyond The Guitar - When you hear Hugh Jackman is returning as Wolverine in Deadpool 3.nfo": "cacf09ab38f9b3085da9c5af516cf22a"
}

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",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "29d09da874133ce5c5f7459c2fa6cd85",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "d5432e6a4f7af9810b0e7c8eebe4898a",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "cf03da6688a73373f9295dc595156e11",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "b7b943b6f3395c05433b8532364d63d6",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "0d9719268900d4abcd8c0f51ad3af92c",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "bbd9ec616a0f7d3c5d13c04bb118681e",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "2e59ecdc0f8050bcf190a7898d7ae968",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "e58966f3326b876f0ab97459b681f76f",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "d6939215b73457fce4e026f8c9b57ecc",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "82d0e7d8070fd15d71c2e11c0285d426",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "3edb11e8cd5f270dd42ee62d9a9aa4ff",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "8827ac62201abeb94f44a46d91628aa8",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "f17e02119bf073c4ed98da2a960b85d3",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "63c0bc97cbdaf35203011378b1860418",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "eb1a8533390243a5c4c49d8b6871c0ce",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "a1b54772207228bd4922ad93d249c9f5",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "1d217264368d5f078e67691ea1b3df97",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions Rumination (Interlude) (Feat. Racheal Ofori).mp3": "4d1a1728dbd69a30bbc2ffa354851169",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "c204cf16f9f44945634c6171a5192e99",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "795e22297f8db00697563774328ffb3a",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "3904a5cd81b5ac43850b7858fb8dd378",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "a89549db5b16ff76d04fd0bbbfba3424",
"Proved Records/[2017] Alfa Mist - Nocturne [Full Album]/folder.jpg": "bd3685acc53072e591bae2505ecb0648"
}

View file

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

View file

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

View file

@ -1,7 +1,7 @@
{
".ytdl-sub-multiple_songs_test-download-archive.json": "54237df5e00d1598dfd39f341ee03d75",
"Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "5657c5b92f8980b20d8bbee0fdc7e5d8",
"Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "a2a3a34e02e26a6c0265530d4499473b",
"Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "b2d6388b4ddf8e3fbca042cb123672c3",
"Project Zombie/[2011] Jesse's Minecraft Server/01 - Jesse's Minecraft Server [Trailer - Mar.21].ogg": "602166199db42b48e49483f82acc33ce",
"Project Zombie/[2011] Jesse's Minecraft Server/02 - Jesse's Minecraft Server [Trailer - Feb.27].ogg": "eae6fff43c176fe6b9001a5e1d66517c",
"Project Zombie/[2011] Jesse's Minecraft Server/03 - Jesse's Minecraft Server [Trailer - Feb.1].ogg": "67b590e4323c67229b05faf45fe557da",
"Project Zombie/[2011] Jesse's Minecraft Server/folder.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530"
}

View file

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

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",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case-thumb.jpg": "b5353a824a4800cc26f884e3025ed969",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.info.json": "970c6f6a3b34851fa6fcbe7e9b7b21d0",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "03696a5cf4491c2a3649c7022801a14a",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.info.json": "324f582f8a69d3844019e650b382773f",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4": "20a9ddbececcc2c75d6df06e88007b5c",
"JMC/JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo": "b9bd35e4f260c728774d8dd31f83637c"
}

View file

@ -1,7 +1,7 @@
{
".ytdl-sub-subtitles_embedded_test-download-archive.json": "1aa35c1d2663ac721f33f8374c00af1a",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind-thumb.jpg": "50ee47c80f679029f5d3503bb91b045a",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.info.json": "24ee80a0b78191fbc091b29901ca7067",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.info.json": "7940facc211cb3e3956af3f2ad6f19fd",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.mp4": "1f1f91f85b162c20596a2413e704b809",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48"
}

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.de.srt": "b343c3bb9257b7ee7ba38f570a115b37",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.en.srt": "fe8c6ee92cae6e059fd80fd61691adbe",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.info.json": "b295080aa389e023026d4127e8ab0a06",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.info.json": "a3f7906782db2be4aa43bdd1354e80bb",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.mp4": "1f1f91f85b162c20596a2413e704b809",
"JMC/JMC - YouTube Rewind 2019 For the Record #YouTubeRewind.nfo": "c64964fab07574080e5da3242e3bfd48"
}

View file

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

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/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "f1315ac907d99c567839ac982b396f51",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "44a703e77c8285e81fec57ed9000bb0c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "559c970f2fde6db18d42850b65f038b4",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "3fe91099a510c9dc820a0a7adba98868",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "6e712b8c5b40f3622732f087127d75f3",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "e780d5e9e678d4aae5b131a5f3603695",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "61d42a2f8aa085eae5758206ff853538",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "4a4d542da434d5b3ff8d304f0e077469",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "4b04325397e3b9ed0ee51f319ce07353",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "3caee440547dd4ed46ec3cdca46978f2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "729ca4dba26539e6d8c04443c9aa67ab",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "0b180b38ac673e7e320e9e615974fe41",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "075f2c2ce8c995195decf755fc980c89",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "5b26edd07c0534d53884d6a42908f8db",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "e3b5da94c5a86db003633570af47d1a9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "629fb263cff2e93b7ec67769ebbc75e0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.mp4": "e8b77ffd826f8f7233b875e311adaf34",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "17489dad8935540598d3c5f873eb11f8",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "c1f5925e1eab8bd21e077df560879d94",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "c572cf9767af2221f4e88b40ab11e7dd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "0a3998c019cc33845be703ca1bb448f9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.mp4": "4343e692b53f0abce80559b59ef2fa0c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "5be336b1d5e9db676bc864081dd0a20c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "9fdbaa70187252b0d303d128123c4e83",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "4fe8ba1b78f29c9e578d84d3ef6e8caa",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "e891c1960d5c29b5e1e73c98ecf2e7f6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.mp4": "dea5d69ae35e47aa78a51f464088a6ad",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "296ac9d9371ba73e4361ace900de3056",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "5e5815bbc8b471df94d8536b117409f4",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "943a66e92c610b032471cf684fa2cdf0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "fe18c65b99ca8942e65d777c9a498577",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.mp4": "b2f03dcefe44b8afc65b2500c873aec2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "acd99e3536227ce1fbfdb0323a180418",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "a86c62cd14e6a42dd79639a6155165fe",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "fc9484dbc42b6fd6149fdcacc235e206",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "aeeb1be477400f34485799112a8121c2",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "044162512a1982b573a0d6f19646801a",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "bebd3dafb168c976a8c817e83a550ce2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "d6e1d8a486125f87d04959607e2dc515",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "7543d21dcf2bd220a9ce4cd15f1e1578",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "5632b6ac9992ee07bf6052e9c87a02c5",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "921a343cf11be7cc492678459dbdd926",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "aa1269526f1e384b25d630c7db17860e",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "5757f06de01c68c5104ec7a7b0c6a69b",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e000001 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "b8f3acff3cb18657dfa8a37b037c78f1",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.info.json": "3a613f0724932930ab746bcf9f8bdf42",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.mp4": "5f221fdf07f200a297427b5df953d96f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "1925f7654bf0243736c7b32bc013c0d9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000001 - Mock Entry 20-3.nfo": "dbe61f2c8ae41041773f713ba5376726",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "e4efb3ea481b6936d188470ace4d6bae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.info.json": "a702f2b8e66c348ceb3ec1fd65956024",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.mp4": "240eb2e4df1abb10290f957d75f2522c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "7a39d8c24ee8df26bc72ac4b2753715b",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000002 - Mock Entry 20-2.nfo": "0f071078c9fa2569bbcb998664d40681",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "683d44d82d8854b1bed529196dc091c9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.info.json": "455e6a6bce2b8630d1a53e11c5554362",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.mp4": "0c58e78e7727c893226b9fcbe39b1791",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "a9e5d5d21b6ca5574ffecee859413923",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000003 - Mock Entry 20-1.nfo": "837a61dca11bbe1874ea07cb8ef8a7c9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "f2ab803ea763a11fb271d7a36b2eb340",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.info.json": "04c2a406e17fb509e849c3d5ccac8b5e",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.mp4": "e8b77ffd826f8f7233b875e311adaf34",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "17489dad8935540598d3c5f873eb11f8",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000005 - Mock Entry 20-7.nfo": "c1f5925e1eab8bd21e077df560879d94",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "6e010415351d9d94f64624607d7ad831",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.info.json": "b5960c859c450da1c40fe68951499a8f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.mp4": "4343e692b53f0abce80559b59ef2fa0c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "5be336b1d5e9db676bc864081dd0a20c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000006 - Mock Entry 20-6.nfo": "9fdbaa70187252b0d303d128123c4e83",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "7214c68d8bd6f3c714dee034a5e04fa8",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.info.json": "1892550ced3033c5b779324e39ef77ac",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.mp4": "dea5d69ae35e47aa78a51f464088a6ad",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "296ac9d9371ba73e4361ace900de3056",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000007 - Mock Entry 20-5.nfo": "5e5815bbc8b471df94d8536b117409f4",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "d04117d103932f217a50e1a28db42962",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.info.json": "03254329cabbac6a887801d1a3a4a9fc",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.mp4": "b2f03dcefe44b8afc65b2500c873aec2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "acd99e3536227ce1fbfdb0323a180418",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e000008 - Mock Entry 20-4.nfo": "a86c62cd14e6a42dd79639a6155165fe",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "e33b506e3e441ad05af03826b8ccb758",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.info.json": "09b731b1e404368cf7ce3fe8197e1de9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.mp4": "8b75d7f6f6f84cccf1867a91d15044a6",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "7e79fad2f197644c02bfd35c29b46029",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e000004 - Mock Entry 21-1.nfo": "8ee3845c514a411425b7e9198666b61c",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "f1315ac907d99c567839ac982b396f51",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "44a703e77c8285e81fec57ed9000bb0c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "559c970f2fde6db18d42850b65f038b4",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "3fe91099a510c9dc820a0a7adba98868",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "6e712b8c5b40f3622732f087127d75f3",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "e780d5e9e678d4aae5b131a5f3603695",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "61d42a2f8aa085eae5758206ff853538",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "4a4d542da434d5b3ff8d304f0e077469",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e060601 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "e3b5da94c5a86db003633570af47d1a9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "629fb263cff2e93b7ec67769ebbc75e0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.mp4": "a44c49a4adb03fc619ff10a37025c09d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "9ee9c7df9a719d90bc6218f0d06af136",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "c4311723a97b1b299342aca9c079ffbc",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "c572cf9767af2221f4e88b40ab11e7dd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "0a3998c019cc33845be703ca1bb448f9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.mp4": "ca2a1e878e543c73e5d95c983580ea66",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "130944ece25592f7dc7d3b395ec1cee5",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "2d3b121869944eeef9919765b6d10ec3",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "4fe8ba1b78f29c9e578d84d3ef6e8caa",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "e891c1960d5c29b5e1e73c98ecf2e7f6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.mp4": "1ed7951cb4414e79f78f48176f9fc2c2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "0e10bead605b29b3939de12bcfc68039",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "3b507ba7e4482c16ccd5b1a832c205eb",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "943a66e92c610b032471cf684fa2cdf0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "fe18c65b99ca8942e65d777c9a498577",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.mp4": "fdc41ef1aceab35f2f229962ec4bb66a",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "885a2381f1dab6b02cb21176fdf958ce",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "27e19fdc5941b870320b68f189e2c2ec",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "4b04325397e3b9ed0ee51f319ce07353",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "3caee440547dd4ed46ec3cdca46978f2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "729ca4dba26539e6d8c04443c9aa67ab",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "0b180b38ac673e7e320e9e615974fe41",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "075f2c2ce8c995195decf755fc980c89",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "5b26edd07c0534d53884d6a42908f8db",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "fc9484dbc42b6fd6149fdcacc235e206",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "aeeb1be477400f34485799112a8121c2",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "044162512a1982b573a0d6f19646801a",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "bebd3dafb168c976a8c817e83a550ce2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "d6e1d8a486125f87d04959607e2dc515",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "7543d21dcf2bd220a9ce4cd15f1e1578",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "5632b6ac9992ee07bf6052e9c87a02c5",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "921a343cf11be7cc492678459dbdd926",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "aa1269526f1e384b25d630c7db17860e",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "5757f06de01c68c5104ec7a7b0c6a69b",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e060601 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "f2ab803ea763a11fb271d7a36b2eb340",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.info.json": "04c2a406e17fb509e849c3d5ccac8b5e",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.mp4": "a44c49a4adb03fc619ff10a37025c09d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "9ee9c7df9a719d90bc6218f0d06af136",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e060601 - Mock Entry 20-7.nfo": "c4311723a97b1b299342aca9c079ffbc",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "6e010415351d9d94f64624607d7ad831",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.info.json": "b5960c859c450da1c40fe68951499a8f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.mp4": "ca2a1e878e543c73e5d95c983580ea66",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "130944ece25592f7dc7d3b395ec1cee5",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070601 - Mock Entry 20-6.nfo": "2d3b121869944eeef9919765b6d10ec3",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "7214c68d8bd6f3c714dee034a5e04fa8",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.info.json": "1892550ced3033c5b779324e39ef77ac",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.mp4": "1ed7951cb4414e79f78f48176f9fc2c2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "0e10bead605b29b3939de12bcfc68039",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e070602 - Mock Entry 20-5.nfo": "3b507ba7e4482c16ccd5b1a832c205eb",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "d04117d103932f217a50e1a28db42962",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.info.json": "03254329cabbac6a887801d1a3a4a9fc",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.mp4": "fdc41ef1aceab35f2f229962ec4bb66a",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "885a2381f1dab6b02cb21176fdf958ce",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080601 - Mock Entry 20-4.nfo": "27e19fdc5941b870320b68f189e2c2ec",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "b8f3acff3cb18657dfa8a37b037c78f1",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.info.json": "3a613f0724932930ab746bcf9f8bdf42",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.mp4": "72205b53c6e2d477372e3d5bb3d35fff",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "c2e446ade2fe21cffe3e9e4b79a1ed1f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080701 - Mock Entry 20-3.nfo": "7c0f2a9d38bf617377e474fcb9bf2bc2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "e4efb3ea481b6936d188470ace4d6bae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.info.json": "a702f2b8e66c348ceb3ec1fd65956024",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.mp4": "bc7ffede56ed138f592d76a3a02681ae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "8d64a2d4c30483f6e032df52f7911dad",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080801 - Mock Entry 20-2.nfo": "1f4dec756555ee345cd9d6489b63e070",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "683d44d82d8854b1bed529196dc091c9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.info.json": "455e6a6bce2b8630d1a53e11c5554362",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.mp4": "4825194cd6a037b79997cfb2d08ed444",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "0b9391e992b88c4cef32a6892609ef4c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e080802 - Mock Entry 20-1.nfo": "f6f704ae3ae0c4055590a147eb425609",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "e33b506e3e441ad05af03826b8ccb758",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.info.json": "09b731b1e404368cf7ce3fe8197e1de9",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.mp4": "efd9d3b1f941386c031e5899aa3042ed",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c64a1ce3eade3978ea2aeb0ed72eb5f5",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e080801 - Mock Entry 21-1.nfo": "c6e0d368bfe9d70134e5a2ff2d3dccc6",
"Best Prebuilt TV Show by Date/fanart.jpg": "15c9a67b554f415a662fdf7a3340cd61",
"Best Prebuilt TV Show by Date/poster.jpg": "0b0fdbe56bab3e3fdda18730588d742a",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e14698 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "6e712b8c5b40f3622732f087127d75f3",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "e780d5e9e678d4aae5b131a5f3603695",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.mp4": "5eafeb7a1c616dfe02c28fc902ce00fb",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "57a07df96fb016c59a292b5cd1d22e10",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "88fee5353ad7eecd96865b34654bbde6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "559c970f2fde6db18d42850b65f038b4",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "3fe91099a510c9dc820a0a7adba98868",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.mp4": "e4a7144efa7039a03ae69460361ae518",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "473a638408eff688582ec8572ba955b2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "d781b80ef24916acbf3bf181ab32ddea",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "f1315ac907d99c567839ac982b396f51",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "44a703e77c8285e81fec57ed9000bb0c",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.mp4": "7566491129c54183e337e83455ab5bda",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "b1070f080d4448a08cbead85540f370d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "252b3639b4d1686f8c79476943f89439",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "61d42a2f8aa085eae5758206ff853538",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "4a4d542da434d5b3ff8d304f0e077469",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.mp4": "e94af7eed1fe7d2512d042979ca878dd",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "7f90134d9346efe476cc5a1e531f8ecd",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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/Season 2020/s2020.e14698 - Mock Entry 20-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "075f2c2ce8c995195decf755fc980c89",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.info.json": "5b26edd07c0534d53884d6a42908f8db",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.mp4": "5eafeb7a1c616dfe02c28fc902ce00fb",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "57a07df96fb016c59a292b5cd1d22e10",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14698 - Mock Entry 20-1.nfo": "88fee5353ad7eecd96865b34654bbde6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "729ca4dba26539e6d8c04443c9aa67ab",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.info.json": "0b180b38ac673e7e320e9e615974fe41",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.mp4": "e4a7144efa7039a03ae69460361ae518",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "473a638408eff688582ec8572ba955b2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14699 - Mock Entry 20-2.nfo": "d781b80ef24916acbf3bf181ab32ddea",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "4b04325397e3b9ed0ee51f319ce07353",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.info.json": "3caee440547dd4ed46ec3cdca46978f2",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.mp4": "7566491129c54183e337e83455ab5bda",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "b1070f080d4448a08cbead85540f370d",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14799 - Mock Entry 20-3.nfo": "252b3639b4d1686f8c79476943f89439",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.info.json": "943a66e92c610b032471cf684fa2cdf0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.info.json": "fe18c65b99ca8942e65d777c9a498577",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.mp4": "0850f53dd2335ba57632128b9bb9d112",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.nfo": "ed439adf2aa5eb4ba741934791ca99e5",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e14899 - Mock Entry 20-4.nfo": "4edc04aaea3799ef24bafe6afe4017e5",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.info.json": "4fe8ba1b78f29c9e578d84d3ef6e8caa",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.info.json": "e891c1960d5c29b5e1e73c98ecf2e7f6",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.mp4": "0dcd1a017948164d217f0bde59551655",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.nfo": "a59a0fa2cef91dea9c1416f3c1abd3e0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17998 - Mock Entry 20-5.nfo": "d7d3005d7a8b3feb7465e697cfb4cad4",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.info.json": "c572cf9767af2221f4e88b40ab11e7dd",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.info.json": "0a3998c019cc33845be703ca1bb448f9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.mp4": "6c4b98bd5e32fbffef033f268d8587ae",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.nfo": "d33cdac7ddd02bb601add35e4dce0173",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e17999 - Mock Entry 20-6.nfo": "1832889c5a8010c28914ef5c9845e14f",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.info.json": "e3b5da94c5a86db003633570af47d1a9",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.info.json": "629fb263cff2e93b7ec67769ebbc75e0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.mp4": "386aa9718db383c17aafc9a00d61d0f0",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.nfo": "1c65b21f8bd5a52dab41ae2d297925e3",
"Best Prebuilt TV Show by Date/Season 2020/s2020.e20999 - Mock Entry 20-7.nfo": "75a73feabcd828d88768c453ab13718e",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1-thumb.jpg": "e80c508c4818454300133fe1dc1a9cd7",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "fc9484dbc42b6fd6149fdcacc235e206",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.info.json": "aeeb1be477400f34485799112a8121c2",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.mp4": "e94af7eed1fe7d2512d042979ca878dd",
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "7f90134d9346efe476cc5a1e531f8ecd",
"Best Prebuilt TV Show by Date/tvshow.nfo": "59c4a3a046d43a9350f2e95e2f401039"
"Best Prebuilt TV Show by Date/Season 2021/s2021.e14699 - Mock Entry 21-1.nfo": "e7374b766d9ec0599ecf209a0f78c0ba",
"Best Prebuilt TV Show by Date/tvshow.nfo": "2439ebc18e46a67064956cb940c992e9"
}

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