diff --git a/src/ytdl_sub/downloaders/youtube/channel.py b/src/ytdl_sub/downloaders/youtube/channel.py index d53b082a..4a369cc8 100644 --- a/src/ytdl_sub/downloaders/youtube/channel.py +++ b/src/ytdl_sub/downloaders/youtube/channel.py @@ -228,7 +228,7 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions self, thumbnail_url: str, output_thumbnail_path: str, - ): + ) -> Optional[bool]: """ Downloads a thumbnail and stores it in the output directory @@ -238,12 +238,16 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions Url of the thumbnail output_thumbnail_path: Path to store the thumbnail after downloading + + Returns + ------- + True if the thumbnail converted. None if it is missing or failed. """ if not thumbnail_url: download_logger.warning("Could not find a thumbnail for %s", self.channel.uid) - return + return None - convert_url_thumbnail( + return convert_url_thumbnail( thumbnail_url=thumbnail_url, output_thumbnail_path=output_thumbnail_path ) @@ -255,18 +259,22 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions avatar_thumbnail_name = self.overrides.apply_formatter( self.download_options.channel_avatar_path ) - self._download_thumbnail( + if self._download_thumbnail( thumbnail_url=self.channel.avatar_thumbnail_url(), output_thumbnail_path=str(Path(self.working_directory) / avatar_thumbnail_name), - ) - self.save_file(file_name=avatar_thumbnail_name) + ): + self.save_file(file_name=avatar_thumbnail_name) + else: + download_logger.warning("Failed to download channel's avatar image") if self.download_options.channel_banner_path: banner_thumbnail_name = self.overrides.apply_formatter( self.download_options.channel_banner_path ) - self._download_thumbnail( + if self._download_thumbnail( thumbnail_url=self.channel.banner_thumbnail_url(), output_thumbnail_path=str(Path(self.working_directory) / banner_thumbnail_name), - ) - self.save_file(file_name=banner_thumbnail_name) + ): + self.save_file(file_name=banner_thumbnail_name) + else: + download_logger.warning("Failed to download channel's banner image") diff --git a/src/ytdl_sub/utils/retry.py b/src/ytdl_sub/utils/retry.py new file mode 100644 index 00000000..01712381 --- /dev/null +++ b/src/ytdl_sub/utils/retry.py @@ -0,0 +1,50 @@ +from time import sleep +from typing import Any +from typing import Optional +from typing import Tuple +from typing import Type + +from ytdl_sub.utils.logger import Logger + +logger = Logger.get() + + +def retry(times: int, exceptions: Tuple[Type[Exception], ...], wait_sec: int = 5) -> Optional[Any]: + """ + Retry decorator + + Parameters + ---------- + times + Number of attempts to retry + exceptions + Type of exceptions to retry against + wait_sec + Number of seconds to wait inbetween retries + + Returns + ------- + Any or None + If none, it implies all retries failed + """ + + def decorator(func): + def newfn(*args, **kwargs): + attempt = 0 + while attempt < times: + try: + return func(*args, **kwargs) + except exceptions: + logger.debug( + "Exception thrown when attempting to run %s, attempt %d of %d", + func.__name__, + attempt + 1, + times, + ) + attempt += 1 + sleep(wait_sec) + return None + + return newfn + + return decorator diff --git a/src/ytdl_sub/utils/thumbnail.py b/src/ytdl_sub/utils/thumbnail.py index 4f866a61..834203c8 100644 --- a/src/ytdl_sub/utils/thumbnail.py +++ b/src/ytdl_sub/utils/thumbnail.py @@ -1,8 +1,10 @@ import tempfile +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.retry import retry def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None: @@ -32,7 +34,8 @@ def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> FFMPEG.run(["-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg]) -def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str): +@retry(times=5, exceptions=(Exception,)) +def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str) -> Optional[bool]: """ Downloads and converts a thumbnail from a url into a jpg @@ -42,9 +45,15 @@ def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str): URL of the thumbnail output_thumbnail_path Thumbnail file destination after its converted to jpg + + Returns + ------- + True to indicate it converted the thumbnail from url. None if the retry failed. """ with urlopen(thumbnail_url) as file: with tempfile.NamedTemporaryFile() as thumbnail: thumbnail.write(file.read()) FFMPEG.run(["-bitexact", "-i", thumbnail.name, output_thumbnail_path, "-bitexact"]) + + return True diff --git a/tests/e2e/youtube/test_channel.py b/tests/e2e/youtube/test_channel.py index 8cbde89e..1b43a53f 100644 --- a/tests/e2e/youtube/test_channel.py +++ b/tests/e2e/youtube/test_channel.py @@ -1,8 +1,12 @@ +from unittest.mock import patch + import pytest +from conftest import assert_debug_log from e2e.expected_download import assert_expected_downloads from e2e.expected_transaction_log import assert_transaction_log_matches from ytdl_sub.subscriptions.subscription import Subscription +from ytdl_sub.utils.retry import logger as retry_logger @pytest.fixture @@ -59,3 +63,20 @@ class TestChannelAsKodiTvShow: dry_run=dry_run, expected_download_summary_file_name="youtube/test_channel_full.json", ) + + def test_channel_post_download(self, channel_as_tv_show_config, channel_preset_dict): + channel_preset_dict["ytdl_options"]["max_views"] = 1 # no downloads occur + full_channel_subscription = Subscription.from_dict( + config=channel_as_tv_show_config, preset_name="pz", preset_dict=channel_preset_dict + ) + + with assert_debug_log( # Ensure retry debug message is thrown + logger=retry_logger, + expected_message="Exception thrown when attempting to run %s, attempt %d of %d", + ), patch( # Make sleeps instant + "ytdl_sub.utils.retry.sleep" + ), patch( # Mock error when calling urlopen + "ytdl_sub.utils.thumbnail.urlopen" + ) as mock_urlopen: + mock_urlopen.side_effect = [Exception("error")] + full_channel_subscription.download(dry_run=True)