[BUGFIX] Do not hard-fail if the thumbnail fails to download/convert (#675)
* [BUGFIX] Do not fail with thumbnail issues * helper * refactor * dry-run * regen fixture * download arhcive txt -> json in docs, fixtures * return if no download thumb path * better test * more test coverage
This commit is contained in:
parent
a9e6a80111
commit
5f9b8afc4b
19 changed files with 162 additions and 86 deletions
|
|
@ -245,7 +245,7 @@ class OutputOptions(StrictDictValidator):
|
||||||
# optional
|
# optional
|
||||||
thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
|
thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
|
||||||
info_json_name: "{title_sanitized}.{info_json_ext}"
|
info_json_name: "{title_sanitized}.{info_json_ext}"
|
||||||
download_archive_name: ".ytdl-sub-{subscription_name}-download-archive.txt"
|
download_archive_name: ".ytdl-sub-{subscription_name}-download-archive.json"
|
||||||
maintain_download_archive: True
|
maintain_download_archive: True
|
||||||
keep_files_before: now
|
keep_files_before: now
|
||||||
keep_files_after: 19000101
|
keep_files_after: 19000101
|
||||||
|
|
@ -354,7 +354,7 @@ class OutputOptions(StrictDictValidator):
|
||||||
def download_archive_name(self) -> Optional[OverridesStringFormatterValidator]:
|
def download_archive_name(self) -> Optional[OverridesStringFormatterValidator]:
|
||||||
"""
|
"""
|
||||||
Optional. The file name to store a subscriptions download archive placed relative to
|
Optional. The file name to store a subscriptions download archive placed relative to
|
||||||
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.txt``
|
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
|
||||||
"""
|
"""
|
||||||
return self._download_archive_name
|
return self._download_archive_name
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ from ytdl_sub.entries.variables.kwargs import YTDL_SUB_MATCH_FILTER_REJECT
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.utils.thumbnail import ThumbnailTypes
|
from ytdl_sub.utils.thumbnail import ThumbnailTypes
|
||||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
|
||||||
from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail
|
from ytdl_sub.utils.thumbnail import download_and_convert_url_thumbnail
|
||||||
|
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
|
||||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
download_logger = Logger.get(name="downloader")
|
download_logger = Logger.get(name="downloader")
|
||||||
|
|
@ -82,11 +82,9 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
|
||||||
|
|
||||||
# If latest entry, always update the thumbnail on each entry
|
# If latest entry, always update the thumbnail on each entry
|
||||||
if thumbnail_id == ThumbnailTypes.LATEST_ENTRY:
|
if thumbnail_id == ThumbnailTypes.LATEST_ENTRY:
|
||||||
# Make sure the entry's thumbnail is converted to jpg
|
|
||||||
convert_download_thumbnail(entry, error_if_not_found=False)
|
|
||||||
|
|
||||||
# always save in dry-run even if it doesn't exist...
|
# always save in dry-run even if it doesn't exist...
|
||||||
if self.is_dry_run or os.path.isfile(entry.get_download_thumbnail_path()):
|
if self.is_dry_run or entry.is_thumbnail_downloaded():
|
||||||
self.save_file(
|
self.save_file(
|
||||||
file_name=entry.get_download_thumbnail_name(),
|
file_name=entry.get_download_thumbnail_name(),
|
||||||
output_file_name=thumbnail_name,
|
output_file_name=thumbnail_name,
|
||||||
|
|
@ -138,8 +136,14 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
|
||||||
|
|
||||||
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||||
"""
|
"""
|
||||||
Use the entry to download thumbnails (or move if LATEST_ENTRY)
|
Use the entry to download thumbnails (or move if LATEST_ENTRY).
|
||||||
|
In addition, convert the entry thumbnail to jpg
|
||||||
"""
|
"""
|
||||||
|
# We always convert entry thumbnails to jpgs, and is performed here to be done
|
||||||
|
# as early as possible in the plugin pipeline (downstream plugins depend on it being jpg)
|
||||||
|
if not self.is_dry_run:
|
||||||
|
try_convert_download_thumbnail(entry=entry)
|
||||||
|
|
||||||
if entry.kwargs_get(COLLECTION_URL) in self._collection_url_mapping:
|
if entry.kwargs_get(COLLECTION_URL) in self._collection_url_mapping:
|
||||||
self._download_url_thumbnails(
|
self._download_url_thumbnails(
|
||||||
collection_url=self._collection_url_mapping[entry.kwargs(COLLECTION_URL)],
|
collection_url=self._collection_url_mapping[entry.kwargs(COLLECTION_URL)],
|
||||||
|
|
@ -346,7 +350,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
|
||||||
is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
|
is_downloaded_fn=None if self.is_dry_run else entry.is_downloaded,
|
||||||
is_thumbnail_downloaded_fn=None
|
is_thumbnail_downloaded_fn=None
|
||||||
if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
|
if (self.is_dry_run or not self.is_entry_thumbnails_enabled)
|
||||||
else entry.is_thumbnail_downloaded,
|
else entry.is_thumbnail_downloaded_via_ytdlp,
|
||||||
url=entry.webpage_url,
|
url=entry.webpage_url,
|
||||||
)
|
)
|
||||||
return Entry(download_entry_dict, working_directory=self.working_directory)
|
return Entry(download_entry_dict, working_directory=self.working_directory)
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,12 @@ class YTDLP:
|
||||||
If the entry fails to download
|
If the entry fails to download
|
||||||
"""
|
"""
|
||||||
num_tries = 0
|
num_tries = 0
|
||||||
entry_files_exist = False
|
|
||||||
copied_ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides)
|
copied_ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides)
|
||||||
|
|
||||||
while not entry_files_exist and num_tries < cls._EXTRACT_ENTRY_NUM_RETRIES:
|
is_downloaded = False
|
||||||
|
entry_dict: Optional[Dict] = None
|
||||||
|
|
||||||
|
while num_tries < cls._EXTRACT_ENTRY_NUM_RETRIES:
|
||||||
entry_dict = cls.extract_info(
|
entry_dict = cls.extract_info(
|
||||||
ytdl_options_overrides=copied_ytdl_options_overrides, **kwargs
|
ytdl_options_overrides=copied_ytdl_options_overrides, **kwargs
|
||||||
)
|
)
|
||||||
|
|
@ -115,7 +117,7 @@ class YTDLP:
|
||||||
time.sleep(cls._EXTRACT_ENTRY_RETRY_WAIT_SEC)
|
time.sleep(cls._EXTRACT_ENTRY_RETRY_WAIT_SEC)
|
||||||
num_tries += 1
|
num_tries += 1
|
||||||
|
|
||||||
# Remove the download archive so it can retry without thinking its already downloaded,
|
# Remove the download archive to retry without thinking its already downloaded,
|
||||||
# even though it is not
|
# even though it is not
|
||||||
if "download_archive" in copied_ytdl_options_overrides:
|
if "download_archive" in copied_ytdl_options_overrides:
|
||||||
del copied_ytdl_options_overrides["download_archive"]
|
del copied_ytdl_options_overrides["download_archive"]
|
||||||
|
|
@ -127,6 +129,10 @@ class YTDLP:
|
||||||
cls._EXTRACT_ENTRY_NUM_RETRIES,
|
cls._EXTRACT_ENTRY_NUM_RETRIES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Still return if the media file downloaded (thumbnail could be missing)
|
||||||
|
if is_downloaded and entry_dict is not None:
|
||||||
|
return entry_dict
|
||||||
|
|
||||||
error_dict = {"ytdl_options": ytdl_options_overrides, "kwargs": kwargs}
|
error_dict = {"ytdl_options": ytdl_options_overrides, "kwargs": kwargs}
|
||||||
raise FileNotDownloadedException(
|
raise FileNotDownloadedException(
|
||||||
f"yt-dlp failed to download an entry with these arguments: {error_dict}"
|
f"yt-dlp failed to download an entry with these arguments: {error_dict}"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class Entry(EntryVariables, BaseEntry):
|
||||||
"""Returns the entry's thumbnail's file path to where it was downloaded"""
|
"""Returns the entry's thumbnail's file path to where it was downloaded"""
|
||||||
return str(Path(self.working_directory()) / self.get_download_thumbnail_name())
|
return str(Path(self.working_directory()) / self.get_download_thumbnail_name())
|
||||||
|
|
||||||
def get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
|
def try_get_ytdlp_download_thumbnail_path(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
|
The source `thumbnail` value and the actual downloaded thumbnail extension sometimes do
|
||||||
not match. Return the actual downloaded thumbnail path.
|
not match. Return the actual downloaded thumbnail path.
|
||||||
|
|
@ -83,14 +83,23 @@ class Entry(EntryVariables, BaseEntry):
|
||||||
with open(self.get_download_info_json_path(), "w", encoding="utf-8") as file:
|
with open(self.get_download_info_json_path(), "w", encoding="utf-8") as file:
|
||||||
file.write(kwargs_json)
|
file.write(kwargs_json)
|
||||||
|
|
||||||
|
@final
|
||||||
|
def is_thumbnail_downloaded_via_ytdlp(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
True if ANY thumbnail file exist locally. False otherwise.
|
||||||
|
"""
|
||||||
|
return self.try_get_ytdlp_download_thumbnail_path() is not None
|
||||||
|
|
||||||
@final
|
@final
|
||||||
def is_thumbnail_downloaded(self) -> bool:
|
def is_thumbnail_downloaded(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
True if the thumbnail file exist locally. False otherwise.
|
True if the thumbnail file exists and is its proper format. False otherwise.
|
||||||
"""
|
"""
|
||||||
return self.get_ytdlp_download_thumbnail_path() is not None
|
return os.path.isfile(self.get_download_thumbnail_path())
|
||||||
|
|
||||||
@final
|
@final
|
||||||
def is_downloaded(self) -> bool:
|
def is_downloaded(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
|
||||||
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
|
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
|
||||||
from ytdl_sub.validators.validators import BoolValidator
|
from ytdl_sub.validators.validators import BoolValidator
|
||||||
|
|
||||||
|
|
@ -91,8 +90,11 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not self.is_dry_run:
|
if not self.is_dry_run:
|
||||||
# convert the entry thumbnail so it is embedded as jpg
|
if not entry.is_thumbnail_downloaded():
|
||||||
convert_download_thumbnail(entry=entry)
|
logger.warning(
|
||||||
|
"Cannot embed thumbnail for '%s' because it is not available", entry.title
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
if entry.ext in AUDIO_CODEC_EXTS:
|
if entry.ext in AUDIO_CODEC_EXTS:
|
||||||
self._embed_audio_file(entry)
|
self._embed_audio_file(entry)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from ytdl_sub.config.preset_options import OptionsDictValidator
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
|
||||||
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
|
from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
|
from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator
|
||||||
|
|
@ -162,11 +161,7 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
|
||||||
)
|
)
|
||||||
setattr(audio_file, tag_name, tag_value[0])
|
setattr(audio_file, tag_name, tag_value[0])
|
||||||
|
|
||||||
if self.plugin_options.embed_thumbnail:
|
if self.plugin_options.embed_thumbnail and entry.is_thumbnail_downloaded():
|
||||||
|
|
||||||
# convert the entry thumbnail so it is embedded as jpg
|
|
||||||
convert_download_thumbnail(entry=entry)
|
|
||||||
|
|
||||||
with open(entry.get_download_thumbnail_path(), "rb") as thumb:
|
with open(entry.get_download_thumbnail_path(), "rb") as thumb:
|
||||||
mediafile_img = mediafile.Image(
|
mediafile_img = mediafile.Image(
|
||||||
data=thumb.read(), desc="cover", type=mediafile.ImageType.front
|
data=thumb.read(), desc="cover", type=mediafile.ImageType.front
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import copy
|
import copy
|
||||||
import os.path
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
@ -21,7 +20,6 @@ from ytdl_sub.utils.exceptions import ValidationException
|
||||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
|
||||||
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -184,11 +182,6 @@ class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]):
|
||||||
f"Tried to split '{entry.title}' by chapters but it has no chapters"
|
f"Tried to split '{entry.title}' by chapters but it has no chapters"
|
||||||
)
|
)
|
||||||
|
|
||||||
# convert the entry thumbnail early so we do not have to guess the thumbnail extension
|
|
||||||
# when copying it. Do not error if it's not found, in case thumbnail_name is not set
|
|
||||||
if not self.is_dry_run:
|
|
||||||
convert_download_thumbnail(entry=entry, error_if_not_found=False)
|
|
||||||
|
|
||||||
for idx, title in enumerate(chapters.titles):
|
for idx, title in enumerate(chapters.titles):
|
||||||
new_uid = _split_video_uid(source_uid=entry.uid, idx=idx)
|
new_uid = _split_video_uid(source_uid=entry.uid, idx=idx)
|
||||||
|
|
||||||
|
|
@ -209,7 +202,7 @@ class SplitByChaptersPlugin(SplitPlugin[SplitByChaptersOptions]):
|
||||||
|
|
||||||
# Copy the original vid thumbnail to the working directory with the new uid. This so
|
# Copy the original vid thumbnail to the working directory with the new uid. This so
|
||||||
# downstream logic thinks this split video has its own thumbnail
|
# downstream logic thinks this split video has its own thumbnail
|
||||||
if os.path.isfile(entry.get_download_thumbnail_path()):
|
if entry.is_thumbnail_downloaded():
|
||||||
FileHandler.copy(
|
FileHandler.copy(
|
||||||
src_file_path=entry.get_download_thumbnail_path(),
|
src_file_path=entry.get_download_thumbnail_path(),
|
||||||
dst_file_path=Path(self.working_directory)
|
dst_file_path=Path(self.working_directory)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
|
|
@ -21,7 +22,9 @@ from ytdl_sub.utils.exceptions import ValidationException
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
from ytdl_sub.utils.logger import Logger
|
||||||
|
|
||||||
|
logger: logging.Logger = Logger.get()
|
||||||
|
|
||||||
|
|
||||||
def _get_split_plugin(plugins: List[Plugin]) -> Optional[SplitPlugin]:
|
def _get_split_plugin(plugins: List[Plugin]) -> Optional[SplitPlugin]:
|
||||||
|
|
@ -69,16 +72,12 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
entry=entry,
|
entry=entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: see if entry even has a thumbnail
|
# Always pretend to include the thumbnail in a dry-run
|
||||||
if self.output_options.thumbnail_name:
|
if self.output_options.thumbnail_name and (dry_run or entry.is_thumbnail_downloaded()):
|
||||||
output_thumbnail_name = self.overrides.apply_formatter(
|
output_thumbnail_name = self.overrides.apply_formatter(
|
||||||
formatter=self.output_options.thumbnail_name, entry=entry
|
formatter=self.output_options.thumbnail_name, entry=entry
|
||||||
)
|
)
|
||||||
|
|
||||||
# We always convert entry thumbnails to jpgs, and is performed here
|
|
||||||
if not dry_run:
|
|
||||||
convert_download_thumbnail(entry=entry)
|
|
||||||
|
|
||||||
# Copy the thumbnails since they could be used later for other things
|
# Copy the thumbnails since they could be used later for other things
|
||||||
self._enhanced_download_archive.save_file_to_output_directory(
|
self._enhanced_download_archive.save_file_to_output_directory(
|
||||||
file_name=entry.get_download_thumbnail_name(),
|
file_name=entry.get_download_thumbnail_name(),
|
||||||
|
|
@ -86,6 +85,10 @@ class SubscriptionDownload(BaseSubscription, ABC):
|
||||||
entry=entry,
|
entry=entry,
|
||||||
copy_file=True,
|
copy_file=True,
|
||||||
)
|
)
|
||||||
|
elif not entry.is_thumbnail_downloaded():
|
||||||
|
logger.warning(
|
||||||
|
"Cannot save thumbnail for '%s' because it is not available", entry.title
|
||||||
|
)
|
||||||
|
|
||||||
if self.output_options.info_json_name:
|
if self.output_options.info_json_name:
|
||||||
output_info_json_name = self.overrides.apply_formatter(
|
output_info_json_name = self.overrides.apply_formatter(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from subprocess import CalledProcessError
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.ffmpeg import FFMPEG
|
from ytdl_sub.utils.ffmpeg import FFMPEG
|
||||||
from ytdl_sub.utils.file_handler import FileHandler
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.utils.retry import retry
|
from ytdl_sub.utils.retry import retry
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -13,39 +16,39 @@ class ThumbnailTypes:
|
||||||
LATEST_ENTRY = "latest_entry"
|
LATEST_ENTRY = "latest_entry"
|
||||||
|
|
||||||
|
|
||||||
def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None:
|
logger: logging.Logger = Logger.get("thumbnail")
|
||||||
|
|
||||||
|
|
||||||
|
def try_convert_download_thumbnail(entry: Entry) -> None:
|
||||||
"""
|
"""
|
||||||
Converts an entry's downloaded thumbnail into jpg format
|
Converts an entry's downloaded thumbnail into jpg format.
|
||||||
|
Log with a warning if the thumbnail is not found or fails to convert
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
entry
|
entry
|
||||||
Entry with the thumbnail
|
Entry with the thumbnail
|
||||||
error_if_not_found
|
|
||||||
If the thumbnail is not found, error if True.
|
|
||||||
|
|
||||||
Raises
|
|
||||||
------
|
|
||||||
ValueError
|
|
||||||
Entry thumbnail file not found
|
|
||||||
"""
|
"""
|
||||||
download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path()
|
download_thumbnail_path: Optional[str] = entry.try_get_ytdlp_download_thumbnail_path()
|
||||||
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
|
download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path()
|
||||||
|
|
||||||
# If it was already converted, do not convert again
|
# If it was already converted, do not convert again
|
||||||
if os.path.isfile(download_thumbnail_path_as_jpg):
|
if entry.is_thumbnail_downloaded():
|
||||||
return
|
return
|
||||||
|
|
||||||
if not download_thumbnail_path:
|
if not download_thumbnail_path:
|
||||||
if error_if_not_found:
|
logger.warning("Thumbnail for '%s' was not downloaded", entry.title)
|
||||||
raise ValueError("Thumbnail not found")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not download_thumbnail_path == download_thumbnail_path_as_jpg:
|
if not download_thumbnail_path == download_thumbnail_path_as_jpg:
|
||||||
FFMPEG.run(
|
try:
|
||||||
["-y", "-bitexact", "-i", 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)
|
)
|
||||||
|
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,))
|
@retry(times=3, exceptions=(Exception,))
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,6 @@ class TestRegex:
|
||||||
output_directory=output_directory,
|
output_directory=output_directory,
|
||||||
transaction_log=transaction_log,
|
transaction_log=transaction_log,
|
||||||
transaction_log_summary_file_name="plugins/test_regex_match_and_exclude.txt",
|
transaction_log_summary_file_name="plugins/test_regex_match_and_exclude.txt",
|
||||||
regenerate_transaction_log=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_regex_using_overrides_success(self, playlist_subscription_overrides, output_directory):
|
def test_regex_using_overrides_success(self, playlist_subscription_overrides, output_directory):
|
||||||
|
|
@ -267,7 +266,6 @@ class TestRegex:
|
||||||
output_directory=output_directory,
|
output_directory=output_directory,
|
||||||
transaction_log=transaction_log,
|
transaction_log=transaction_log,
|
||||||
transaction_log_summary_file_name="plugins/test_regex_overrides.txt",
|
transaction_log_summary_file_name="plugins/test_regex_overrides.txt",
|
||||||
regenerate_transaction_log=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_regex_fails_no_match(self, playlist_subscription_no_match_fails, output_directory):
|
def test_regex_fails_no_match(self, playlist_subscription_no_match_fails, output_directory):
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class TestChannel:
|
||||||
expected md5 file hashes.
|
expected md5 file hashes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@pytest.mark.parametrize("dry_run", [True, False])
|
@pytest.mark.parametrize("dry_run", [False])
|
||||||
def test_full_channel_download(
|
def test_full_channel_download(
|
||||||
self,
|
self,
|
||||||
channel_as_tv_show_config,
|
channel_as_tv_show_config,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from conftest import preset_dict_to_dl_args
|
from conftest import preset_dict_to_dl_args
|
||||||
from e2e.conftest import mock_run_from_cli
|
from e2e.conftest import mock_run_from_cli
|
||||||
from expected_download import assert_expected_downloads
|
from expected_download import assert_expected_downloads
|
||||||
from expected_transaction_log import assert_transaction_log_matches
|
from expected_transaction_log import assert_transaction_log_matches
|
||||||
|
|
||||||
|
from ytdl_sub.downloaders.ytdlp import YTDLP
|
||||||
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.subscriptions.subscription import Subscription
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
|
from ytdl_sub.utils.file_handler import FileHandler
|
||||||
|
from ytdl_sub.utils.thumbnail import try_convert_download_thumbnail
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -114,7 +120,7 @@ class TestYoutubeVideo:
|
||||||
transaction_log_summary_file_name="youtube/test_video.txt",
|
transaction_log_summary_file_name="youtube/test_video.txt",
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("dry_run", [True, False])
|
@pytest.mark.parametrize("dry_run", [True])
|
||||||
def test_single_video_download(
|
def test_single_video_download(
|
||||||
self,
|
self,
|
||||||
music_video_config,
|
music_video_config,
|
||||||
|
|
@ -140,6 +146,44 @@ class TestYoutubeVideo:
|
||||||
expected_download_summary_file_name="youtube/test_video.json",
|
expected_download_summary_file_name="youtube/test_video.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_single_video_download_missing_thumbnail(
|
||||||
|
self,
|
||||||
|
music_video_config,
|
||||||
|
single_video_preset_dict,
|
||||||
|
working_directory,
|
||||||
|
output_directory,
|
||||||
|
):
|
||||||
|
single_video_subscription = Subscription.from_dict(
|
||||||
|
config=music_video_config,
|
||||||
|
preset_name="music_video_single_video_test",
|
||||||
|
preset_dict=single_video_preset_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_entry_thumb(entry: Entry) -> None:
|
||||||
|
FileHandler.delete(entry.get_download_thumbnail_path())
|
||||||
|
try_convert_download_thumbnail(entry=entry)
|
||||||
|
|
||||||
|
# Pretend the thumbnail did not download via returning nothing for its downloaded path
|
||||||
|
with patch.object(YTDLP, "_EXTRACT_ENTRY_NUM_RETRIES", 1), patch.object(
|
||||||
|
Entry, "try_get_ytdlp_download_thumbnail_path"
|
||||||
|
) as mock_ytdlp_path, patch(
|
||||||
|
"ytdl_sub.downloaders.url.downloader.try_convert_download_thumbnail",
|
||||||
|
side_effect=delete_entry_thumb,
|
||||||
|
):
|
||||||
|
mock_ytdlp_path.return_value = None
|
||||||
|
transaction_log = single_video_subscription.download(dry_run=False)
|
||||||
|
|
||||||
|
assert_transaction_log_matches(
|
||||||
|
output_directory=output_directory,
|
||||||
|
transaction_log=transaction_log,
|
||||||
|
transaction_log_summary_file_name="youtube/test_video_missing_thumb.txt",
|
||||||
|
)
|
||||||
|
assert_expected_downloads(
|
||||||
|
output_directory=output_directory,
|
||||||
|
dry_run=False,
|
||||||
|
expected_download_summary_file_name="youtube/test_video_missing_thumb.json",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("dry_run", [True, False])
|
@pytest.mark.parametrize("dry_run", [True, False])
|
||||||
def test_single_video_download_from_cli_dl(
|
def test_single_video_download_from_cli_dl(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,6 @@ def assert_expected_downloads(
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
expected_download_summary_file_name: str,
|
expected_download_summary_file_name: str,
|
||||||
ignore_md5_hashes_for: Optional[List[str]] = None,
|
ignore_md5_hashes_for: Optional[List[str]] = None,
|
||||||
regenerate_expected_download_summary: bool = REGENERATE_FIXTURES,
|
|
||||||
):
|
):
|
||||||
if dry_run:
|
if dry_run:
|
||||||
output_directory_contents = list(Path(output_directory).rglob("*"))
|
output_directory_contents = list(Path(output_directory).rglob("*"))
|
||||||
|
|
@ -134,7 +133,11 @@ def assert_expected_downloads(
|
||||||
return
|
return
|
||||||
|
|
||||||
summary_full_path = _EXPECTED_DOWNLOADS_SUMMARY_PATH / expected_download_summary_file_name
|
summary_full_path = _EXPECTED_DOWNLOADS_SUMMARY_PATH / expected_download_summary_file_name
|
||||||
if regenerate_expected_download_summary:
|
|
||||||
|
# USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
|
||||||
|
# Updates the file with the input transaction log. Should only be used to update
|
||||||
|
# tests after an expected change is made.
|
||||||
|
if REGENERATE_FIXTURES:
|
||||||
os.makedirs(os.path.dirname(summary_full_path), exist_ok=True)
|
os.makedirs(os.path.dirname(summary_full_path), exist_ok=True)
|
||||||
ExpectedDownloads.from_directory(directory_path=output_directory).to_summary_file(
|
ExpectedDownloads.from_directory(directory_path=output_directory).to_summary_file(
|
||||||
summary_file_path=summary_full_path
|
summary_file_path=summary_full_path
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ def assert_transaction_log_matches(
|
||||||
output_directory: Path,
|
output_directory: Path,
|
||||||
transaction_log: FileHandlerTransactionLog,
|
transaction_log: FileHandlerTransactionLog,
|
||||||
transaction_log_summary_file_name: str,
|
transaction_log_summary_file_name: str,
|
||||||
regenerate_transaction_log: bool = REGENERATE_FIXTURES,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Parameters
|
Parameters
|
||||||
|
|
@ -26,16 +25,14 @@ def assert_transaction_log_matches(
|
||||||
transaction_log_summary_file_name
|
transaction_log_summary_file_name
|
||||||
Name if the transaction log summary to compare.
|
Name if the transaction log summary to compare.
|
||||||
Lives in tests/e2e/resources/transaction_log_summaries
|
Lives in tests/e2e/resources/transaction_log_summaries
|
||||||
regenerate_transaction_log
|
|
||||||
USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
|
|
||||||
Updates the file with the input transaction log. Should only be used to update
|
|
||||||
tests after an expected change is made.
|
|
||||||
"""
|
"""
|
||||||
transaction_log_path = _TRANSACTION_LOG_SUMMARY_PATH / transaction_log_summary_file_name
|
transaction_log_path = _TRANSACTION_LOG_SUMMARY_PATH / transaction_log_summary_file_name
|
||||||
summary = transaction_log.to_output_message(output_directory=output_directory)
|
summary = transaction_log.to_output_message(output_directory=output_directory)
|
||||||
|
|
||||||
# Write the expected summary file if regenerate is True
|
# USE WITH CAUTION - MANUALLY INSPECT CHANGED FILES TO ENSURE THEY LOOK GOOD!
|
||||||
if regenerate_transaction_log:
|
# Updates the file with the input transaction log. Should only be used to update
|
||||||
|
# tests after an expected change is made.
|
||||||
|
if REGENERATE_FIXTURES:
|
||||||
os.makedirs(os.path.dirname(transaction_log_path), exist_ok=True)
|
os.makedirs(os.path.dirname(transaction_log_path), exist_ok=True)
|
||||||
with open(transaction_log_path, "w", encoding="utf-8") as summary_file:
|
with open(transaction_log_path, "w", encoding="utf-8") as summary_file:
|
||||||
summary_file.write(
|
summary_file.write(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"Project ⧸ Zombie/.ytdl-sub-recent-download-archive.json": "ca7275efd7f2ebe489b8ca4007dc5361",
|
"Project ⧸ Zombie/.ytdl-sub-recent-download-archive.json": "ca7275efd7f2ebe489b8ca4007dc5361",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer-thumb.jpg": "705ca4e0d99b37e9ecdf6bfe4b90c59b",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer-thumb.jpg": "705ca4e0d99b37e9ecdf6bfe4b90c59b",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.info.json": "70dc8e603978c4ac2438e0a76a14d91a",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.info.json": "f249bd1137702b3db3d2805bbf4bfff8",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.mp4": "dbb140af8676f34fb701d5199e8708ea",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.mp4": "dbb140af8676f34fb701d5199e8708ea",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.nfo": "ee5bee94667ed4f1d47af08384418d3c",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.nfo": "ee5bee94667ed4f1d47af08384418d3c",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "39aea44e1987ed4950ef81661b70123f",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "e143a26148069d0477dc45896afba93e",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
|
||||||
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
||||||
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"Project ⧸ Zombie/.ytdl-sub-recent-download-archive.json": "46170edaf7cc51e5f59ef5d4c0476200",
|
"Project ⧸ Zombie/.ytdl-sub-recent-download-archive.json": "46170edaf7cc51e5f59ef5d4c0476200",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "39aea44e1987ed4950ef81661b70123f",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "e143a26148069d0477dc45896afba93e",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "c2f06f9eb5b42c777808a35587e5941e",
|
||||||
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
||||||
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,53 @@
|
||||||
{
|
{
|
||||||
"Project ⧸ Zombie/.ytdl-sub-pz-download-archive.json": "aadb59c92dcf14ee6617c77423a14584",
|
"Project ⧸ Zombie/.ytdl-sub-pz-download-archive.json": "aadb59c92dcf14ee6617c77423a14584",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e",
|
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.info.json": "0e9453839a22b6a76b51790fbbb23454",
|
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.info.json": "71a73acadb2b22b03e694450e24829c3",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.mp4": "6aab51d2ebb374dc965970cbacd00bda",
|
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.mp4": "6aab51d2ebb374dc965970cbacd00bda",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.nfo": "a1970f06fbc4743fca6db0627de779f3",
|
"Project ⧸ Zombie/Season 2010/s2010.e081301 - Oblivion Mod "Falcor" p.1.nfo": "a1970f06fbc4743fca6db0627de779f3",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2-thumb.jpg": "8b32ee9c037fa669e444a0ac181525a1",
|
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2-thumb.jpg": "8b32ee9c037fa669e444a0ac181525a1",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.info.json": "adc538d5568b311d6b71bb2a3c2f8ec2",
|
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.info.json": "eed64444efcd28fd011355c99f9f0b11",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.mp4": "4433a9932f0c3935cd9d339e281f38a9",
|
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.mp4": "4433a9932f0c3935cd9d339e281f38a9",
|
||||||
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.nfo": "4ad498ce223454a4baa7d64bb4a837d6",
|
"Project ⧸ Zombie/Season 2010/s2010.e120201 - Oblivion Mod "Falcor" p.2.nfo": "4ad498ce223454a4baa7d64bb4a837d6",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d",
|
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "ebf6e5c5ba1806d69cc8eb66fa53a2f9",
|
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "2d0fd09ada04988e971680de3bbb16a3",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "36622668231135f717456ef960fc7a68",
|
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "36622668231135f717456ef960fc7a68",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "073eefa6e5c6d76edde80258ddf452ee",
|
"Project ⧸ Zombie/Season 2011/s2011.e020101 - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "073eefa6e5c6d76edde80258ddf452ee",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb",
|
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "0d687426300432ffc94460d5ac90200d",
|
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "df97680cd9c492b81586530f6eae7692",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].mp4": "692f5928b18d6d70db7b5314e08cdfd3",
|
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].mp4": "692f5928b18d6d70db7b5314e08cdfd3",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "20af231e30a035fb2bc0c946f4b4026d",
|
"Project ⧸ Zombie/Season 2011/s2011.e022701 - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "20af231e30a035fb2bc0c946f4b4026d",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
|
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "e03526cb4b91114dc0bfa4d75a479da4",
|
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "34221ff34f0449cce61004dd91e7bd7c",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "f5cb35da0e2ecedee9b74db1402be06d",
|
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "f5cb35da0e2ecedee9b74db1402be06d",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "f8bdc463c0cb2ffdc82aba2bcc27ad5d",
|
"Project ⧸ Zombie/Season 2011/s2011.e032101 - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "f8bdc463c0cb2ffdc82aba2bcc27ad5d",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net)-thumb.jpg": "c956192a379b3661595c9920972d4819",
|
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net)-thumb.jpg": "c956192a379b3661595c9920972d4819",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).info.json": "48964586b9f85ac49caf1887158c23ab",
|
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).info.json": "d8442a2e3d84f88bdb7adb8a123cc676",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).mp4": "9766181630b30e94a22e571c70a065f4",
|
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).mp4": "9766181630b30e94a22e571c70a065f4",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).nfo": "cb0184784a8eda842cfaf851f6e6af7d",
|
"Project ⧸ Zombie/Season 2011/s2011.e052901 - Project Zombie |Official Trailer| (IP: mc.projectzombie.beastnode.net).nfo": "cb0184784a8eda842cfaf851f6e6af7d",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|-thumb.jpg": "00ed383591779ffe98291de60f198fe9",
|
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|-thumb.jpg": "00ed383591779ffe98291de60f198fe9",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.info.json": "20510db3688622932727bf5dcd0a2200",
|
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.info.json": "f4c4808ca277ac254ce59669f5da95bd",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.mp4": "2d6a06b4b8c4dac8d7c173362d9637a4",
|
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.mp4": "2d6a06b4b8c4dac8d7c173362d9637a4",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.nfo": "54ea4a48116aa98480a79495036c25e9",
|
"Project ⧸ Zombie/Season 2011/s2011.e063001 - Project Zombie |Fin|.nfo": "54ea4a48116aa98480a79495036c25e9",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC]-thumb.jpg": "1718599d5189c65f7d8cf6acfa5ea851",
|
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC]-thumb.jpg": "1718599d5189c65f7d8cf6acfa5ea851",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].info.json": "8df32d858656fe8c684cf50ddf16b4cf",
|
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].info.json": "0605441961b2692d3ccfd8b30e9ac345",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].mp4": "60c3221125afbef78990bdcead78a82d",
|
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].mp4": "60c3221125afbef78990bdcead78a82d",
|
||||||
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].nfo": "04f6aad56d85b1f65b81b6b8000f6479",
|
"Project ⧸ Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD w⧸Mods' [PC].nfo": "04f6aad56d85b1f65b81b6b8000f6479",
|
||||||
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|-thumb.jpg": "54ebe9df801b278fdd17b21afa8373a6",
|
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|-thumb.jpg": "54ebe9df801b278fdd17b21afa8373a6",
|
||||||
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|.info.json": "dee712d394eea246a3b4bba436a8d7c3",
|
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|.info.json": "3ac8c6130454dc8628d1954038ef1611",
|
||||||
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|.mp4": "2141f0f928d55e94fef7f7d3c7a4aca0",
|
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|.mp4": "2141f0f928d55e94fef7f7d3c7a4aca0",
|
||||||
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|.nfo": "e2b078891b27cfee4c791598d046be24",
|
"Project ⧸ Zombie/Season 2012/s2012.e012301 - Project Zombie |Map Trailer|.nfo": "e2b078891b27cfee4c791598d046be24",
|
||||||
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|-thumb.jpg": "e29d49433175de8a761af35c5307791f",
|
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|-thumb.jpg": "e29d49433175de8a761af35c5307791f",
|
||||||
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|.info.json": "255c5045b149c446267d6a91c0e5ebb5",
|
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|.info.json": "de10297ea3c72c111741ea06c7e07ec3",
|
||||||
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|.mp4": "e092a7ecbe10a02556487db8cb4e1158",
|
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|.mp4": "e092a7ecbe10a02556487db8cb4e1158",
|
||||||
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|.nfo": "ce72e3e210d6d46fef3e0bc208dc69cf",
|
"Project ⧸ Zombie/Season 2013/s2013.e071901 - Project Zombie Rewind |Trailer|.nfo": "ce72e3e210d6d46fef3e0bc208dc69cf",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer-thumb.jpg": "705ca4e0d99b37e9ecdf6bfe4b90c59b",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer-thumb.jpg": "705ca4e0d99b37e9ecdf6bfe4b90c59b",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.info.json": "6f6ffbb3118ea6b0485b3816b6e4bba1",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.info.json": "ffb2dbd264a02848ddea1a1cba96e459",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.mp4": "dbb140af8676f34fb701d5199e8708ea",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.mp4": "dbb140af8676f34fb701d5199e8708ea",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.nfo": "31e92fc23d9765f8d5bd0d64834a9d1f",
|
"Project ⧸ Zombie/Season 2018/s2018.e102901 - Jesse's Minecraft Server | Teaser Trailer.nfo": "31e92fc23d9765f8d5bd0d64834a9d1f",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id-thumb.jpg": "28d852ede73b879b9ebf9a061cfc7d46",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.en.srt": "3d2c4e7f65d2ca5e96da38ce7eecfc4e",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.en.srt": "3d2c4e7f65d2ca5e96da38ce7eecfc4e",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "a1ec296adf1cfbe7329fa16dde78ebc3",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.info.json": "59d77abf90db5f3ec96a2a77ee503d18",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "083e2e77e3e01218419531c08795c9c0",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.mp4": "a1b58a20411fff51ba06c1fc31cadf1f",
|
||||||
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "a9b7ac3cacbda7fed9d12b0519945292",
|
"Project ⧸ Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server | IP mc.jesse.id.nfo": "a9b7ac3cacbda7fed9d12b0519945292",
|
||||||
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
"Project ⧸ Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
|
||||||
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
"Project ⧸ Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"JMC/JMC - Oblivion Mod "Falcor" p.1.info.json": "4b361d0ab0220407731553dbe36f12a1",
|
||||||
|
"JMC/JMC - Oblivion Mod "Falcor" p.1.mp4": "3744c49f2e447bd7712a5aad5ed36be2",
|
||||||
|
"JMC/JMC - Oblivion Mod "Falcor" p.1.nfo": "24cc4e17d2bebc89b2759ce5471d403e"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
Files created:
|
||||||
|
----------------------------------------
|
||||||
|
{output_directory}/JMC
|
||||||
|
JMC - Oblivion Mod "Falcor" p.1.info.json
|
||||||
|
JMC - Oblivion Mod "Falcor" p.1.mp4
|
||||||
|
Video Tags:
|
||||||
|
title: Oblivion Mod "Falcor" p.1
|
||||||
|
JMC - Oblivion Mod "Falcor" p.1.nfo
|
||||||
|
NFO tags:
|
||||||
|
musicvideo:
|
||||||
|
album: Music Videos
|
||||||
|
artist: JMC
|
||||||
|
title: Oblivion Mod "Falcor" p.1
|
||||||
|
year: 2010
|
||||||
Loading…
Reference in a new issue