diff --git a/src/ytdl_sub/downloaders/downloader.py b/src/ytdl_sub/downloaders/downloader.py index 79b5e48d..b07a9751 100644 --- a/src/ytdl_sub/downloaders/downloader.py +++ b/src/ytdl_sub/downloaders/downloader.py @@ -39,6 +39,7 @@ from ytdl_sub.utils.exceptions import FileNotDownloadedException 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 ThumbnailTypes from ytdl_sub.utils.thumbnail import convert_url_thumbnail from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver @@ -518,6 +519,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC): thumbnails_downloaded: Set[str], thumbnail_list_info: CollectionThumbnailListValidator, entry: Entry, + is_last_entry: bool, parent: EntryParent, ) -> Set[str]: """ @@ -527,10 +529,22 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC): thumbnail_name = self.overrides.apply_formatter(thumbnail_info.name, entry=entry) thumbnail_id = self.overrides.apply_formatter(thumbnail_info.uid) - # alread downloaded + # already downloaded if thumbnail_name in thumbnails_downloaded: continue + if thumbnail_id == ThumbnailTypes.LATEST_ENTRY: + # always save in dry-run even if it doesn't exist... + if is_last_entry and ( + self.is_dry_run or os.path.isfile(entry.get_download_thumbnail_path()) + ): + self.save_file( + file_name=entry.get_download_thumbnail_name(), + output_file_name=thumbnail_name, + ) + thumbnails_downloaded.add(thumbnail_name) + continue + if (thumbnail_url := parent.get_thumbnail_url(thumbnail_id=thumbnail_id)) is None: download_logger.warning("TODO: Failed to download channel's avatar image") continue @@ -556,12 +570,15 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC): """ thumbnails_downloaded: Set[str] = set() - for entry in entries: + for idx, entry in enumerate(entries): + is_last_entry = idx == len(entries) - 1 + if entry.kwargs_contains(PLAYLIST_ENTRY): thumbnails_downloaded = self._download_parent_thumbnails( thumbnails_downloaded=thumbnails_downloaded, thumbnail_list_info=collection_url.playlist_thumbnails, entry=entry, + is_last_entry=is_last_entry, parent=EntryParent( entry.kwargs(PLAYLIST_ENTRY), working_directory=self.working_directory ), @@ -572,6 +589,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC): thumbnails_downloaded=thumbnails_downloaded, thumbnail_list_info=collection_url.source_thumbnails, entry=entry, + is_last_entry=is_last_entry, parent=EntryParent( entry.kwargs(SOURCE_ENTRY), working_directory=self.working_directory ), diff --git a/src/ytdl_sub/downloaders/youtube/playlist.py b/src/ytdl_sub/downloaders/youtube/playlist.py index 86b507e3..7612bee2 100644 --- a/src/ytdl_sub/downloaders/youtube/playlist.py +++ b/src/ytdl_sub/downloaders/youtube/playlist.py @@ -1,8 +1,12 @@ from typing import Dict +from typing import List +from typing import Optional from ytdl_sub.downloaders.downloader import Downloader from ytdl_sub.downloaders.downloader import DownloaderValidator from ytdl_sub.downloaders.generic.collection_validator import CollectionValidator +from ytdl_sub.utils.thumbnail import ThumbnailTypes +from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.url_validator import YoutubePlaylistUrlValidator @@ -23,21 +27,38 @@ class YoutubePlaylistDownloaderOptions(DownloaderValidator): """ _required_keys = {"playlist_url"} + _optional_keys = {"playlist_thumbnail_name"} def __init__(self, name, value): super().__init__(name, value) self._playlist_url = self._validate_key( "playlist_url", YoutubePlaylistUrlValidator ).playlist_url + self._playlist_thumbnail_name = self._validate_key_if_present( + "playlist_thumbnail_name", OverridesStringFormatterValidator + ) @property def collection_validator(self) -> CollectionValidator: """Downloads the playlist url""" + playlist_thumbnails: List[Dict] = [] + if self.playlist_thumbnail_path: + playlist_thumbnails.append( + { + "name": self.playlist_thumbnail_path.format_string, + "uid": ThumbnailTypes.LATEST_ENTRY, + } + ) + return CollectionValidator( name=self._name, value={ "urls": [ - {"url": self.playlist_url, "variables": {"playlist_size": "{playlist_count}"}} + { + "url": self.playlist_url, + "playlist_thumbnails": playlist_thumbnails, + "variables": {"playlist_size": "{playlist_count}"}, + } ] }, ) @@ -50,6 +71,13 @@ class YoutubePlaylistDownloaderOptions(DownloaderValidator): """ return self._playlist_url + @property + def playlist_thumbnail_path(self) -> Optional[OverridesStringFormatterValidator]: + """ + Optional. Path to store the playlist's thumbnail + """ + return self._playlist_thumbnail_name + class YoutubePlaylistDownloader(Downloader[YoutubePlaylistDownloaderOptions]): downloader_options_type = YoutubePlaylistDownloaderOptions diff --git a/src/ytdl_sub/subscriptions/subscription_download.py b/src/ytdl_sub/subscriptions/subscription_download.py index bdbb15e3..6d407496 100644 --- a/src/ytdl_sub/subscriptions/subscription_download.py +++ b/src/ytdl_sub/subscriptions/subscription_download.py @@ -71,10 +71,12 @@ class SubscriptionDownload(BaseSubscription, ABC): 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(), output_file_name=output_thumbnail_name, entry=entry, + copy_file=True, ) if self.output_options.info_json_name: diff --git a/src/ytdl_sub/utils/file_handler.py b/src/ytdl_sub/utils/file_handler.py index a1fa97d5..0099d183 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -329,7 +329,11 @@ class FileHandler: os.remove(file_path) def move_file_to_output_directory( - self, file_name: str, output_file_name: str, file_metadata: Optional[FileMetadata] = None + self, + file_name: str, + output_file_name: str, + file_metadata: Optional[FileMetadata] = None, + copy_file: bool = False, ): """ Copies a file from the working directory to the output directory. @@ -344,6 +348,8 @@ class FileHandler: Desired output file name in the output_directory file_metadata Optional. Metadata to record to the transaction log for this file + copy_file + Optional. If True, copy the file. Move otherwise """ source_file_path = Path(self.working_directory) / file_name output_file_path = Path(self.output_directory) / output_file_name @@ -365,7 +371,10 @@ class FileHandler: if not self.dry_run: os.makedirs(os.path.dirname(output_file_path), exist_ok=True) - self.move(src_file_path=source_file_path, dst_file_path=output_file_path) + if copy_file: + self.copy(src_file_path=source_file_path, dst_file_path=output_file_path) + else: + self.move(src_file_path=source_file_path, dst_file_path=output_file_path) def delete_file_from_output_directory(self, file_name: str): """ diff --git a/src/ytdl_sub/utils/thumbnail.py b/src/ytdl_sub/utils/thumbnail.py index 834203c8..09cb3eaf 100644 --- a/src/ytdl_sub/utils/thumbnail.py +++ b/src/ytdl_sub/utils/thumbnail.py @@ -7,6 +7,10 @@ from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.retry import retry +class ThumbnailTypes: + LATEST_ENTRY = "latest_entry" + + def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None: """ Converts an entry's downloaded thumbnail into jpg format diff --git a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py index 97b20247..a995983f 100644 --- a/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py +++ b/src/ytdl_sub/ytdl_additions/enhanced_download_archive.py @@ -541,6 +541,7 @@ class EnhancedDownloadArchive: file_metadata: Optional[FileMetadata] = None, output_file_name: Optional[str] = None, entry: Optional[Entry] = None, + copy_file: bool = False, ): """ Saves a file from the working directory to the output directory and record it in the @@ -557,6 +558,8 @@ class EnhancedDownloadArchive: directory path). If None, use the same working_directory file_name entry Optional. Entry that this file belongs to + copy_file + Optional. If True, copy the file. Move otherwise """ if output_file_name is None: output_file_name = file_name @@ -565,7 +568,10 @@ class EnhancedDownloadArchive: self.mapping.add_entry(entry=entry, entry_file_path=output_file_name) self._file_handler.move_file_to_output_directory( - file_name=file_name, file_metadata=file_metadata, output_file_name=output_file_name + file_name=file_name, + file_metadata=file_metadata, + output_file_name=output_file_name, + copy_file=copy_file, ) def get_file_handler_transaction_log(self) -> FileHandlerTransactionLog: @@ -611,6 +617,7 @@ class DownloadArchiver: file_metadata: Optional[FileMetadata] = None, output_file_name: Optional[str] = None, entry: Optional[Entry] = None, + copy_file: bool = False, ) -> None: """ Saves a file in the working directory to the output directory. @@ -626,10 +633,13 @@ class DownloadArchiver: directory path). If None, use the same working_directory file_name entry Optional. Entry that the file belongs to + copy_file + Optional. If True, copy the file. Move otherwise """ self._enhanced_download_archive.save_file_to_output_directory( file_name=file_name, file_metadata=file_metadata, output_file_name=output_file_name, entry=entry, + copy_file=copy_file, ) diff --git a/tests/e2e/resources/expected_downloads_summaries/youtube/test_playlist.json b/tests/e2e/resources/expected_downloads_summaries/youtube/test_playlist.json index 59acd2e4..14c6eaab 100644 --- a/tests/e2e/resources/expected_downloads_summaries/youtube/test_playlist.json +++ b/tests/e2e/resources/expected_downloads_summaries/youtube/test_playlist.json @@ -1,16 +1,17 @@ { ".ytdl-sub-music_video_playlist_test-download-archive.json": "25b8e44961343116436584e341c7fe9b", "JMC - Jesse's Minecraft Server [Trailer - Feb.1]-thumb.jpg": "b232d253df621aa770b780c1301d364d", - "JMC - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "cbd6a34138c3044e37158ee1a3c89eea", + "JMC - Jesse's Minecraft Server [Trailer - Feb.1].info.json": "614f0db46765b30c21473c2b42efaea8", "JMC - Jesse's Minecraft Server [Trailer - Feb.1].mp4": "e66287b9832277b6a4d1554e29d9fdcc", "JMC - Jesse's Minecraft Server [Trailer - Feb.1].nfo": "de6f9a17a5ebf92aa02b14f7342b8d36", "JMC - Jesse's Minecraft Server [Trailer - Feb.27]-thumb.jpg": "d17c379ea8b362f5b97c6b213b0342cb", - "JMC - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "b707a8d1741db62dd8b8470e7a2f9368", + "JMC - Jesse's Minecraft Server [Trailer - Feb.27].info.json": "156ac62ef15a958fe9fbac6c6a58ed87", "JMC - Jesse's Minecraft Server [Trailer - Feb.27].mp4": "04ab5cb3cc12325d0c96a7cd04a8b91d", "JMC - Jesse's Minecraft Server [Trailer - Feb.27].nfo": "9a4a57bb91f1ac97b588fdf66bac4c9b", "JMC - Jesse's Minecraft Server [Trailer - Mar.21]-thumb.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", - "JMC - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "509eebbb687bab378e36dac08b5cf989", + "JMC - Jesse's Minecraft Server [Trailer - Mar.21].info.json": "4e2186286a679dd091308dabf28ed43c", "JMC - Jesse's Minecraft Server [Trailer - Mar.21].mp4": "025de6099a5c98e6397153c7a62d517d", "JMC - Jesse's Minecraft Server [Trailer - Mar.21].nfo": "b98f1fa3e76b4592aeeaed4f6e220809", + "poster.jpg": "e7830aa8a64b0cde65ba3f7e5fc56530", "tvshow.nfo": "e4123860532466ed5e0ebf2c9e44eb18" } \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/youtube/test_playlist.txt b/tests/e2e/resources/transaction_log_summaries/youtube/test_playlist.txt index ffe7c9f2..0c97059f 100644 --- a/tests/e2e/resources/transaction_log_summaries/youtube/test_playlist.txt +++ b/tests/e2e/resources/transaction_log_summaries/youtube/test_playlist.txt @@ -37,6 +37,7 @@ JMC - Jesse's Minecraft Server [Trailer - Mar.21].nfo playlist_index: 1 title: Jesse's Minecraft Server [Trailer - Mar.21] year: 2011 +poster.jpg tvshow.nfo NFO tags: test: diff --git a/tests/e2e/youtube/test_playlist.py b/tests/e2e/youtube/test_playlist.py index 9767bffb..604dfcb8 100644 --- a/tests/e2e/youtube/test_playlist.py +++ b/tests/e2e/youtube/test_playlist.py @@ -12,7 +12,10 @@ from ytdl_sub.subscriptions.subscription import Subscription def playlist_preset_dict(output_directory): return { "preset": "yt_music_video_playlist", - "youtube": {"playlist_url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35"}, + "youtube": { + "playlist_url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35", + "playlist_thumbnail_name": "poster.jpg", + }, # override the output directory with our fixture-generated dir "output_options": {"output_directory": output_directory}, # download the worst format so it is fast