from abc import ABC from typing import Dict from typing import Generic from typing import List from typing import TypeVar from ytdl_sub.downloaders.downloader import Downloader from ytdl_sub.downloaders.downloader import DownloaderValidator from ytdl_sub.entries.soundcloud import SoundcloudAlbum from ytdl_sub.entries.soundcloud import SoundcloudTrack from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.validators import StringValidator ############################################################################### # Abstract Soundcloud downloader + options class SoundcloudDownloaderOptions(DownloaderValidator, ABC): """ Abstract source validator for all soundcloud sources. """ _optional_keys = {"skip_premiere_tracks"} def __init__(self, name: str, value: dict): super().__init__(name=name, value=value) self._skip_premiere_tracks = self._validate_key( "skip_premiere_tracks", BoolValidator, default=True ) @property def skip_premiere_tracks(self) -> bool: """ Optional. True to skip tracks that require purchasing. False otherwise. Defaults to True. """ return self._skip_premiere_tracks.value SoundcloudDownloaderOptionsT = TypeVar( "SoundcloudDownloaderOptionsT", bound=SoundcloudDownloaderOptions ) class SoundcloudDownloader( Downloader[SoundcloudDownloaderOptionsT, SoundcloudTrack], Generic[SoundcloudDownloaderOptionsT], ABC, ): """ Class that handles downloading soundcloud entries via ytdl and converting them into SoundcloudTrack / SoundcloudAlbumTrack objects """ downloader_entry_type = SoundcloudTrack @classmethod def ytdl_option_defaults(cls) -> Dict: """Returns default format to be best mp3""" return { "format": "bestaudio[ext=mp3]", } @classmethod def artist_url(cls, artist_name: str) -> str: """Returns full artist url""" return f"https://soundcloud.com/{artist_name}" @classmethod def artist_albums_url(cls, artist_name: str) -> str: """ Returns ------- Full artist album url """ return cls.artist_url(artist_name) + "/albums" @classmethod def artist_tracks_url(cls, artist_name: str) -> str: """ Returns ------- Full artist tracks url """ return cls.artist_url(artist_name) + "/tracks" ############################################################################### # Soundcloud albums and singles downloader + options class SoundcloudAlbumsAndSinglesDownloadOptions(SoundcloudDownloaderOptions): """ Downloads a soundcloud user's entire discography. Groups together album tracks and considers any track not in an album as a single. Also includes any collaboration tracks. Usage: .. code-block:: yaml presets: my_example_preset: soundcloud: # required download_strategy: "albums_and_singles" username: soundcloud_username_from_url # optional skip_premiere_tracks: True """ _required_keys = {"username"} def __init__(self, name, value): super().__init__(name, value) self._username = self._validate_key(key="username", validator=StringValidator) @property def username(self) -> str: """ Required. The Soundcloud username found in the url of their page. """ return self._username.value class SoundcloudAlbumsAndSinglesDownloader( SoundcloudDownloader[SoundcloudAlbumsAndSinglesDownloadOptions] ): downloader_options_type = SoundcloudAlbumsAndSinglesDownloadOptions def _get_albums(self, entry_dicts: List[Dict]) -> List[SoundcloudAlbum]: """ Parameters ---------- entry_dicts Entry dicts from extracting info jsons Returns ------- Dict containing album_id: album class """ albums: Dict[str, SoundcloudAlbum] = {} # First, get the albums themselves for entry_dict in entry_dicts: if entry_dict.get("extractor") == "soundcloud:set": albums[entry_dict["id"]] = SoundcloudAlbum( entry_dict=entry_dict, working_directory=self.working_directory ) # Then, get all tracks that belong to the album for entry_dict in entry_dicts: album_id = entry_dict.get("playlist_id") if entry_dict.get("extractor") == "soundcloud" and album_id in albums: albums[album_id].tracks.append( SoundcloudTrack(entry_dict=entry_dict, working_directory=self.working_directory) ) return list(albums.values()) def _get_singles( self, entry_dicts: List[Dict], albums: List[SoundcloudAlbum] ) -> List[SoundcloudTrack]: tracks: List[SoundcloudTrack] = [] # Get all tracks that are not part of an album for entry_dict in entry_dicts: if entry_dict.get("extractor") == "soundcloud" and not any( entry_dict in album for album in albums ): tracks.append( SoundcloudTrack(entry_dict=entry_dict, working_directory=self.working_directory) ) return tracks def download(self) -> List[SoundcloudTrack]: """ Soundcloud subscription to download albums and tracks as singles. """ artist_albums_url = self.artist_albums_url(artist_name=self.download_options.username) artist_tracks_url = self.artist_tracks_url(artist_name=self.download_options.username) album_entry_dicts = self.extract_info_via_info_json(url=artist_albums_url) tracks_entry_dicts = self.extract_info_via_info_json(url=artist_tracks_url) # Get all of the artist's albums albums = self._get_albums(entry_dicts=album_entry_dicts) # Then, get all singles tracks = self._get_singles(entry_dicts=tracks_entry_dicts, albums=albums) # Append all album tracks as SoundcloudAlbumTrack classes to the singles for album in albums: tracks += album.album_tracks() # Filter any premiere tracks if specified if self.download_options.skip_premiere_tracks: tracks = [track for track in tracks if not track.is_premiere()] return tracks