ytdl-sub/src/ytdl_sub/downloaders/soundcloud_downloader.py
Jesse Bannon a6cf9c3a11
Add soundcloud discography example w/test (#49)
* example sc yaml

* discography test

* json sort_keys
2022-05-30 00:25:41 -07:00

200 lines
6.3 KiB
Python

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