diff --git a/src/ytdl_sub/entries/entry.py b/src/ytdl_sub/entries/entry.py index 7d47d1bf..7613a928 100644 --- a/src/ytdl_sub/entries/entry.py +++ b/src/ytdl_sub/entries/entry.py @@ -11,6 +11,9 @@ class Entry(EntryVariables, BaseEntry): Entry object to represent a single media object returned from yt-dlp. """ + # The ytdl extractor type that the entry represents + entry_extractor: str + def get_download_file_name(self) -> str: """ Returns diff --git a/src/ytdl_sub/entries/soundcloud.py b/src/ytdl_sub/entries/soundcloud.py index cec40761..0c234b05 100644 --- a/src/ytdl_sub/entries/soundcloud.py +++ b/src/ytdl_sub/entries/soundcloud.py @@ -12,6 +12,8 @@ class SoundcloudTrack(SoundcloudVariables, Entry): it does not belong to an album. """ + entry_extractor = "soundcloud" + def is_premiere(self) -> bool: """ Returns whether the entry is a premier track. Check this by seeing if the track's url is @@ -87,6 +89,8 @@ class SoundcloudAlbum(Entry): Entry object to represent a Soundcloud album. """ + entry_extractor = "soundcloud:set" + def __init__(self, entry_dict: Dict, working_directory: Optional[str] = None): """ Initialize the entry using ytdl metadata diff --git a/src/ytdl_sub/entries/youtube.py b/src/ytdl_sub/entries/youtube.py index 5ec6bbdb..a6f7df87 100644 --- a/src/ytdl_sub/entries/youtube.py +++ b/src/ytdl_sub/entries/youtube.py @@ -11,6 +11,8 @@ class YoutubeVideo(YoutubeVideoVariables, Entry): Entry object to represent a Youtube video. Reserved for shared Youtube entry logic. """ + entry_extractor = "youtube" + @property def ext(self) -> str: """ @@ -45,6 +47,8 @@ class YoutubePlaylistVideo(YoutubeVideo): class YoutubeChannel(Entry): + entry_extractor = "youtube:tab" + def _get_thumbnail_url(self, thumbnail_id: str) -> Optional[str]: """ Downloads a specific thumbnail from a YTDL entry's thumbnail list diff --git a/src/ytdl_sub/subscriptions/subscription.py b/src/ytdl_sub/subscriptions/subscription.py index eea59c0f..3b3174ae 100644 --- a/src/ytdl_sub/subscriptions/subscription.py +++ b/src/ytdl_sub/subscriptions/subscription.py @@ -20,6 +20,7 @@ from ytdl_sub.downloaders.downloader import DownloaderValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import PluginOptions +from ytdl_sub.thread.log_entries_downloaded_listener import LogEntriesDownloadedListener 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 @@ -225,6 +226,33 @@ class Subscription: self._enhanced_download_archive.save_download_mappings() + @contextlib.contextmanager + def _listen_and_log_downloaded_entries(self): + """ + Context manager that starts a separate thread that listens for new .info.json files, + prints their titles as they appear + """ + info_json_listener = LogEntriesDownloadedListener( + working_directory=self.working_directory, + info_json_extractor=self.downloader_class.downloader_entry_type.entry_extractor, + ) + + info_json_listener.start() + + try: + yield + finally: + info_json_listener.complete = True + + @contextlib.contextmanager + def _subscription_download_context_managers(self) -> None: + with ( + self._prepare_working_directory(), + self._maintain_archive_file(), + self._listen_and_log_downloaded_entries(), + ): + yield + def _initialize_plugins(self) -> List[Plugin]: """ Returns @@ -268,7 +296,7 @@ class Subscription: ) plugins = self._initialize_plugins() - with self._prepare_working_directory(), self._maintain_archive_file(): + with self._subscription_download_context_managers(): downloader = self.downloader_class( download_options=self.downloader_options, enhanced_download_archive=self._enhanced_download_archive, diff --git a/src/ytdl_sub/thread/__init__.py b/src/ytdl_sub/thread/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ytdl_sub/thread/log_entries_downloaded_listener.py b/src/ytdl_sub/thread/log_entries_downloaded_listener.py new file mode 100644 index 00000000..246bf023 --- /dev/null +++ b/src/ytdl_sub/thread/log_entries_downloaded_listener.py @@ -0,0 +1,68 @@ +import json +import os.path +import threading +import time +from pathlib import Path +from typing import Optional +from typing import Set + +from ytdl_sub.utils.logger import Logger + +logger = Logger.get(name="downloader") + + +class LogEntriesDownloadedListener(threading.Thread): + def __init__(self, working_directory, info_json_extractor): + """ + To be ran in a thread while download via ytdl-sub. Listens for new .info.json files in the + working directory, checks the extractor value, and if it matches the input arg, log the + title. + + Parameters + ---------- + working_directory + subscription download working directory + info_json_extractor + print the titles of the info.json file with this extractor + """ + threading.Thread.__init__(self) + self.working_directory = working_directory + self.info_json_extractor = info_json_extractor + self.complete = False + + self._files_read: Set[str] = set() + + def _get_title_from_info_json(self, path: Path) -> Optional[str]: + with open(path, "r", encoding="utf-8") as file: + file_json = json.load(file) + + if file_json.get("extractor") == self.info_json_extractor: + return file_json.get("title") + + return None + + @classmethod + def _is_info_json(cls, path: Path) -> bool: + if path.is_file(): + _, ext = os.path.splitext(path) + return ext == ".json" + return False + + def loop(self) -> None: + """ + Read new files in the directory and print their titles + """ + for path in Path(self.working_directory).rglob("*"): + if path.name not in self._files_read and self._is_info_json(path): + title = self._get_title_from_info_json(path) + self._files_read.add(path.name) + if title: + logger.info("Downloading %s", title) + + def run(self): + """ + Loops over new files and prints their titles + """ + while not self.complete: + self.loop() + time.sleep(0.1)