[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:
Jesse Bannon 2023-07-28 13:12:46 -07:00 committed by GitHub
parent a9e6a80111
commit 5f9b8afc4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 162 additions and 86 deletions

View file

@ -245,7 +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.txt"
download_archive_name: ".ytdl-sub-{subscription_name}-download-archive.json"
maintain_download_archive: True
keep_files_before: now
keep_files_after: 19000101
@ -354,7 +354,7 @@ class OutputOptions(StrictDictValidator):
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.txt``
the output directory. Defaults to ``.ytdl-sub-{subscription_name}-download-archive.json``
"""
return self._download_archive_name

View file

@ -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.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
download_logger = Logger.get(name="downloader")
@ -82,11 +82,9 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
# 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,
@ -138,8 +136,14 @@ class UrlDownloaderThumbnailPlugin(SourcePluginExtension):
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)],
@ -346,7 +350,7 @@ class MultiUrlDownloader(SourcePlugin[MultiUrlValidator]):
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)

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
)
@ -115,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"]
@ -127,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

@ -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 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.validators import BoolValidator
@ -91,8 +90,11 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]):
return None
if not self.is_dry_run:
# convert the entry thumbnail so it is embedded as jpg
convert_download_thumbnail(entry=entry)
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)

View file

@ -11,7 +11,6 @@ from ytdl_sub.config.preset_options import OptionsDictValidator
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.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
@ -162,11 +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

@ -1,5 +1,4 @@
import copy
import os.path
from pathlib import Path
from typing import Any
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.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
@ -184,11 +182,6 @@ class SplitByChaptersPlugin(SplitPlugin[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)
@ -209,7 +202,7 @@ class SplitByChaptersPlugin(SplitPlugin[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

@ -1,4 +1,5 @@
import contextlib
import logging
import os
import shutil
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 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[SplitPlugin]:
@ -69,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(),
@ -86,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(

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

@ -257,7 +257,6 @@ class TestRegex:
output_directory=output_directory,
transaction_log=transaction_log,
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):
@ -267,7 +266,6 @@ class TestRegex:
output_directory=output_directory,
transaction_log=transaction_log,
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):

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,10 +1,16 @@
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 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
@ -114,7 +120,7 @@ class TestYoutubeVideo:
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(
self,
music_video_config,
@ -140,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,12 +1,12 @@
{
"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": "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.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": "39aea44e1987ed4950ef81661b70123f",
"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.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",

View file

@ -1,8 +1,8 @@
{
"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": "39aea44e1987ed4950ef81661b70123f",
"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.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",

View file

@ -1,53 +1,53 @@
{
"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.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.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.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.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].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].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].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].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].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].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).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).nfo": "cb0184784a8eda842cfaf851f6e6af7d",
"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.nfo": "54ea4a48116aa98480a79495036c25e9",
"Project Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD wMods' [PC]-thumb.jpg": "1718599d5189c65f7d8cf6acfa5ea851",
"Project Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD wMods' [PC].info.json": "8df32d858656fe8c684cf50ddf16b4cf",
"Project Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD wMods' [PC].info.json": "0605441961b2692d3ccfd8b30e9ac345",
"Project Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD wMods' [PC].mp4": "60c3221125afbef78990bdcead78a82d",
"Project Zombie/Season 2011/s2011.e112101 - Skyrim 'Ultra HD wMods' [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.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.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.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.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.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.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.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.mp4": "083e2e77e3e01218419531c08795c9c0",
"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": "a1b58a20411fff51ba06c1fc31cadf1f",
"Project Zombie/Season 2018/s2018.e110201 - Jesse's Minecraft Server IP mc.jesse.id.nfo": "a9b7ac3cacbda7fed9d12b0519945292",
"Project Zombie/fanart.jpg": "129c6639b47299bc48062f0365e670ee",
"Project Zombie/poster.jpg": "5de28eea5a921a041452ab3ce1041f73",

View file

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

View file

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