diff --git a/ytdl_subscribe/subscriptions/soundcloud.py b/ytdl_subscribe/subscriptions/soundcloud.py index 343f7a9b..5c350692 100644 --- a/ytdl_subscribe/subscriptions/soundcloud.py +++ b/ytdl_subscribe/subscriptions/soundcloud.py @@ -12,14 +12,22 @@ from ytdl_subscribe.validators.config.sources.soundcloud_validators import ( ) -class SoundcloudAlbumsAndSinglesSubscription(Subscription): +class SoundcloudSubscription(Subscription): source_validator_type = SoundcloudSourceValidator - download_strategy_type = SoundcloudAlbumsAndSinglesDownloadValidator + downloader_type = SoundcloudDownloader @property def source_options(self) -> SoundcloudSourceValidator: return super().source_options + @property + def downloader(self) -> SoundcloudDownloader: + return super().downloader + + +class SoundcloudAlbumsAndSinglesSubscription(SoundcloudSubscription): + download_strategy_type = SoundcloudAlbumsAndSinglesDownloadValidator + @property def download_strategy_options(self) -> SoundcloudAlbumsAndSinglesDownloadValidator: return super().download_strategy_options @@ -27,16 +35,11 @@ class SoundcloudAlbumsAndSinglesSubscription(Subscription): def extract_info( self, ): - tracks: List[SoundcloudTrack] = [] - soundcloud_downloader = SoundcloudDownloader( - output_directory=self.config_options.working_directory.value, - ytdl_options=self.ytdl_options.dict, - ) # Get the album info first. This tells us which track ids belong # to an album. Unfortunately we cannot use download_archive or info.json for this - albums: List[SoundcloudAlbum] = soundcloud_downloader.download_albums( + albums: List[SoundcloudAlbum] = self.downloader.download_albums( artist_name=self.download_strategy_options.username.value ) @@ -46,7 +49,7 @@ class SoundcloudAlbumsAndSinglesSubscription(Subscription): ) # only add tracks that are not part of an album - single_tracks = soundcloud_downloader.download_tracks( + single_tracks = self.downloader.download_tracks( artist_name=self.download_strategy_options.username.value ) tracks += [ diff --git a/ytdl_subscribe/subscriptions/subscription.py b/ytdl_subscribe/subscriptions/subscription.py index 5e2ae86e..542fd749 100644 --- a/ytdl_subscribe/subscriptions/subscription.py +++ b/ytdl_subscribe/subscriptions/subscription.py @@ -8,6 +8,7 @@ import dicttoxml import music_tag from PIL import Image +from ytdl_subscribe.downloaders.downloader import Downloader from ytdl_subscribe.entries.entry import Entry from ytdl_subscribe.validators.config.config_validator import ConfigOptionsValidator from ytdl_subscribe.validators.config.metadata_options.metadata_options_validator import ( @@ -25,11 +26,15 @@ from ytdl_subscribe.validators.config.sources.source_validator import SourceVali SOURCE_T = TypeVar("SOURCE_T", bound=SourceValidator) DOWNLOAD_STRATEGY_T = TypeVar("DOWNLOAD_STRATEGY_T", bound=DownloadStrategyValidator) +DOWNLOADER_T = TypeVar("DOWNLOADER_T", bound=Downloader) class Subscription(object): + SOURCE_T = TypeVar("SOURCE_T", bound=SourceValidator) + source_validator_type: Type[SOURCE_T] download_strategy_type: Type[DOWNLOAD_STRATEGY_T] + downloader_type: Type[Downloader] def __init__( self, @@ -54,17 +59,14 @@ class Subscription(object): overrides: OverridesValidator """ self.name = name - self.config_options = config_options + self._config_options = config_options + self._source_options = source_options + self._download_strategy_options = source_options.download_strategy - self.__source_options = source_options - self.__download_strategy_options = source_options.download_strategy - - if not isinstance(self.__source_options, self.source_validator_type): + if not isinstance(self._source_options, self.source_validator_type): raise ValueError("Source options does not match the expected type") - if not isinstance( - self.__download_strategy_options, self.download_strategy_type - ): + if not isinstance(self._download_strategy_options, self.download_strategy_type): raise ValueError("Download strategy does not match the expected type") self.output_options = output_options @@ -74,18 +76,28 @@ class Subscription(object): @property def source_options(self) -> SOURCE_T: - return self.__source_options + return self._source_options @property def download_strategy_options(self) -> DOWNLOAD_STRATEGY_T: - return self.__download_strategy_options + return self._download_strategy_options + + @property + def working_directory(self) -> str: + """Returns the directory that the downloader saves files to""" + return str(Path(self._config_options.working_directory.value) / Path(self.name)) + + @property + def downloader(self) -> DOWNLOADER_T: + return self.downloader_type( + output_directory=self.working_directory, + ytdl_options=self.ytdl_options.dict, + ) def _post_process_tagging(self, entry: Entry): id3_options = self.metadata_options.id3 t = music_tag.load_file( - entry.file_path( - relative_directory=self.config_options.working_directory.value - ) + entry.file_path(relative_directory=self.working_directory) ) for tag, tag_formatter in id3_options.tags.dict.items(): t[tag] = entry.apply_formatter( @@ -131,7 +143,7 @@ class Subscription(object): # Move the file after all direct file modifications are complete entry_source_file_path = entry.file_path( - relative_directory=self.config_options.working_directory.value + relative_directory=self.working_directory ) output_directory = entry.apply_formatter( @@ -150,7 +162,7 @@ class Subscription(object): # Download the thumbnail if its present if self.output_options.thumbnail_name: source_thumbnail_path = entry.thumbnail_path( - relative_directory=self.config_options.working_directory.value + relative_directory=self.working_directory ) output_thumbnail_name = entry.apply_formatter( diff --git a/ytdl_subscribe/subscriptions/youtube.py b/ytdl_subscribe/subscriptions/youtube.py index 9135028c..6ac852ce 100644 --- a/ytdl_subscribe/subscriptions/youtube.py +++ b/ytdl_subscribe/subscriptions/youtube.py @@ -10,23 +10,26 @@ from ytdl_subscribe.validators.config.sources.youtube_validators import ( class YoutubeSubscription(Subscription): source_validator_type = YoutubeSourceValidator - download_strategy_type = YoutubePlaylistDownloadValidator + downloader_type = YoutubeDownloader @property def source_options(self) -> YoutubeSourceValidator: return super().source_options + @property + def downloader(self) -> YoutubeDownloader: + return super().downloader + + +class YoutubePlaylistSubscription(YoutubeSubscription): + download_strategy_type = YoutubePlaylistDownloadValidator + @property def download_strategy_options(self) -> YoutubePlaylistDownloadValidator: return super().download_strategy_options def extract_info(self): - youtube_downloader = YoutubeDownloader( - output_directory=self.config_options.working_directory.value, - ytdl_options=self.ytdl_options.dict, - ) - - entries = youtube_downloader.download_playlist( + entries = self.downloader.download_playlist( playlist_id=self.download_strategy_options.playlist_id.value ) diff --git a/ytdl_subscribe/validators/base/string_formatter_validator.py b/ytdl_subscribe/validators/base/string_formatter_validator.py index bbb513e1..c4c9854c 100644 --- a/ytdl_subscribe/validators/base/string_formatter_validator.py +++ b/ytdl_subscribe/validators/base/string_formatter_validator.py @@ -1,9 +1,8 @@ import re from keyword import iskeyword -from typing import Dict from typing import List -from ytdl_subscribe.validators.base.validators import DictValidator +from ytdl_subscribe.validators.base.validators import LiteralDictValidator from ytdl_subscribe.validators.base.validators import StringValidator @@ -69,7 +68,7 @@ class StringFormatterValidator(StringValidator): return self._value -class DictFormatterValidator(DictValidator): +class DictFormatterValidator(LiteralDictValidator): """ Validates a dictionary made up of key: string_formatters """ @@ -79,7 +78,3 @@ class DictFormatterValidator(DictValidator): for key in self._keys: _ = self._validate_key(key=key, validator=StringFormatterValidator) - - @property - def dict(self) -> Dict[str, str]: - return self._dict diff --git a/ytdl_subscribe/validators/base/validators.py b/ytdl_subscribe/validators/base/validators.py index e4ed56e3..686baf96 100644 --- a/ytdl_subscribe/validators/base/validators.py +++ b/ytdl_subscribe/validators/base/validators.py @@ -1,4 +1,5 @@ from typing import Any +from typing import Dict from typing import List from typing import Optional from typing import Type @@ -154,3 +155,15 @@ class DictValidator(Validator): return None return self._validate_key(key=key, validator=validator, default=default) + + +class LiteralDictValidator(DictValidator): + """DictValidator with exposed dict and keys method""" + + @property + def dict(self) -> Dict: + return super()._dict + + @property + def keys(self) -> List[str]: + return super()._keys diff --git a/ytdl_subscribe/validators/config/config_validator.py b/ytdl_subscribe/validators/config/config_validator.py index c2ae1bf0..6a9741cd 100644 --- a/ytdl_subscribe/validators/config/config_validator.py +++ b/ytdl_subscribe/validators/config/config_validator.py @@ -1,9 +1,11 @@ from typing import Any +from typing import Dict import yaml from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator from ytdl_subscribe.validators.base.validators import DictValidator +from ytdl_subscribe.validators.base.validators import LiteralDictValidator from ytdl_subscribe.validators.base.validators import StringValidator @@ -18,6 +20,12 @@ class ConfigOptionsValidator(StrictDictValidator): ) +class ConfigPresetsValidator(LiteralDictValidator): + """Shallow validator checking for the presets dict in the config""" + + pass + + class ConfigFileValidator(StrictDictValidator): _required_keys = {"configuration", "presets"} @@ -26,7 +34,7 @@ class ConfigFileValidator(StrictDictValidator): self.config_options = self._validate_key( "configuration", ConfigOptionsValidator ) - self.presets = self._validate_key("presets", DictValidator) + self.presets = self._validate_key("presets", ConfigPresetsValidator) @classmethod def from_dict(cls, config_dict) -> "ConfigFileValidator": diff --git a/ytdl_subscribe/validators/config/preset_validator.py b/ytdl_subscribe/validators/config/preset_validator.py index baebcaba..804b142f 100644 --- a/ytdl_subscribe/validators/config/preset_validator.py +++ b/ytdl_subscribe/validators/config/preset_validator.py @@ -13,6 +13,7 @@ from ytdl_subscribe.validators.base.string_formatter_validator import ( DictFormatterValidator, ) from ytdl_subscribe.validators.base.validators import DictValidator +from ytdl_subscribe.validators.base.validators import LiteralDictValidator from ytdl_subscribe.validators.config.metadata_options.metadata_options_validator import ( MetadataOptionsValidator, ) @@ -29,23 +30,21 @@ from ytdl_subscribe.validators.config.sources.youtube_validators import ( from ytdl_subscribe.validators.exceptions import ValidationException -class YTDLOptionsValidator(DictValidator): - @property - def dict(self) -> Dict: - return self._dict +class YTDLOptionsValidator(LiteralDictValidator): + """Ensures `ytdl_options` is a dict""" + + pass class OverridesValidator(DictFormatterValidator): - @property - def dict(self) -> Dict[str, str]: - """For overrides, create sanitized versions of each entry for convenience""" - output_dict = copy.deepcopy(self._dict) - for key in self._keys: - output_dict[f"sanitized_{key}"] = sanitize_filename.sanitize( - output_dict[key] - ) + """Ensures `overrides` is a dict""" - return output_dict + def __init__(self, name, value): + super().__init__(name, value) + for key in self._keys: + self._value[f"sanitized_{key}"] = sanitize_filename.sanitize( + self._value[key] + ) PRESET_SOURCE_VALIDATOR_MAPPING: Dict[str, Type[SourceValidator]] = { diff --git a/ytdl_subscribe/validators/config/subscription_validator.py b/ytdl_subscribe/validators/config/subscription_validator.py index 2a3b6719..279414fe 100644 --- a/ytdl_subscribe/validators/config/subscription_validator.py +++ b/ytdl_subscribe/validators/config/subscription_validator.py @@ -1,3 +1,4 @@ +import copy from typing import Any from typing import List @@ -8,15 +9,12 @@ from ytdl_subscribe.subscriptions.soundcloud import ( SoundcloudAlbumsAndSinglesSubscription, ) from ytdl_subscribe.subscriptions.subscription import Subscription -from ytdl_subscribe.subscriptions.youtube import YoutubeSubscription +from ytdl_subscribe.subscriptions.youtube import YoutubePlaylistSubscription from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator from ytdl_subscribe.validators.base.validators import StringValidator from ytdl_subscribe.validators.config.config_validator import ConfigFileValidator from ytdl_subscribe.validators.config.preset_validator import PRESET_OPTIONAL_KEYS from ytdl_subscribe.validators.config.preset_validator import PRESET_REQUIRED_KEYS -from ytdl_subscribe.validators.config.preset_validator import ( - PRESET_SOURCE_VALIDATOR_MAPPING, -) from ytdl_subscribe.validators.config.preset_validator import OverridesValidator from ytdl_subscribe.validators.config.preset_validator import PresetValidator from ytdl_subscribe.validators.config.sources.soundcloud_validators import ( @@ -51,18 +49,17 @@ class SubscriptionValidator(StrictDictValidator): validator=StringValidator, ).value - available_presets = self.config.presets._keys - if preset_name not in available_presets: + if preset_name not in self.config.presets.keys: raise self._validation_exception( f"'preset '{preset_name}' does not exist in the provided config. " - f"Available presets: {', '.join(available_presets)}" + f"Available presets: {', '.join(self.config.presets.keys)}" ) - # A little hacky, we will override the preset with the contents of this subscription, then validate it + # A little hacky, we will override the preset with the contents of this subscription, + # then validate it + preset_dict = copy.deepcopy(self.config.presets.dict[preset_name]) preset_dict = mergedeep.merge( - self.config.presets._dict[preset_name], - self._dict, - strategy=mergedeep.Strategy.REPLACE, + preset_dict, self._dict, strategy=mergedeep.Strategy.REPLACE ) del preset_dict["preset"] @@ -81,7 +78,7 @@ class SubscriptionValidator(StrictDictValidator): self.preset.subscription_source.download_strategy, YoutubePlaylistDownloadValidator, ): - subscription_class = YoutubeSubscription + subscription_class = YoutubePlaylistSubscription else: raise ValueError("subscription source class not found")