From 963881ca27bb9fa444d61c29421fe1635b54df5c Mon Sep 17 00:00:00 2001 From: Nathan Thomas Date: Wed, 24 Jan 2024 13:20:20 -0800 Subject: [PATCH] More docs --- pyproject.toml | 2 +- streamrip/client/deezer.py | 3 +- streamrip/client/downloadable.py | 1 - streamrip/client/qobuz.py | 187 +++++++++++++++++++++++++++++-- streamrip/client/soundcloud.py | 6 +- streamrip/config.py | 94 +++++++++++++--- streamrip/media/playlist.py | 1 + streamrip/metadata/album.py | 5 +- streamrip/rip/cli.py | 2 +- tests/silence.flac | Bin 28561 -> 29238 bytes 10 files changed, 270 insertions(+), 31 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7b9b2e8..2c13430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ build-backend = "poetry.core.masonry.api" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F", "I", "ASYNC", "N", "RUF", "ERA001"] +select = ["E4", "E7", "E9", "F", "I", "ASYNC", "N", "RUF", "ERA001", "D"] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 3f42260..f2059c1 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -22,7 +22,8 @@ logging.captureWarnings(True) class DeezerClient(Client): """Client to handle deezer API. Does not do rate limiting. - Attributes: + Attributes + ---------- global_config: Entire config object client: client from deezer py used for API requests logged_in: True if logged in diff --git a/streamrip/client/downloadable.py b/streamrip/client/downloadable.py index 4a4e2c0..63037ed 100644 --- a/streamrip/client/downloadable.py +++ b/streamrip/client/downloadable.py @@ -242,7 +242,6 @@ class TidalDownloadable(Downloadable): :param out_path: :param encryption_key: """ - # Do not change this master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=" diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index b9bf152..7e72703 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -1,3 +1,37 @@ +"""Module providing a QobuzClient class for interacting with the Qobuz API. + +The QobuzClient class extends the Client class and includes methods for +authentication, metadata retrieval, search, and downloading content from Qobuz. + +Classes: + - QobuzClient: Main class for interacting with the Qobuz API. + +Usage: + Example usage of the QobuzClient class: + + ```python + from qobuz_client import QobuzClient + + # Initialize the QobuzClient with a configuration object + qobuz_client = QobuzClient(config) + + # Log in to the Qobuz API + await qobuz_client.login() + + # Retrieve metadata for a track + metadata = await qobuz_client.get_metadata("123456", "track") + + # Search for albums by an artist + search_results = await qobuz_client.search("artist", "John Doe", limit=5) + + # Get user favorites for tracks + user_favorites = await qobuz_client.get_user_favorites("track", limit=10) + + # Download a track + downloadable = await qobuz_client.get_downloadable("789012", quality=3) + await downloadable.download("output_path") + ``` +""" import asyncio import base64 import hashlib @@ -64,6 +98,7 @@ class QobuzSpoofer: self.session = None async def get_app_id_and_secrets(self) -> tuple[str, list[str]]: + """Request the relevant pages and return app ID and secrets.""" assert self.session is not None async with self.session.get("https://play.qobuz.com/login") as req: login_page = await req.text() @@ -124,20 +159,57 @@ class QobuzSpoofer: return app_id, secrets_list async def __aenter__(self): + """Enter context manager and create async client session.""" self.session = aiohttp.ClientSession() return self async def __aexit__(self, *_): + """Close client session on context manager exit.""" if self.session is not None: await self.session.close() self.session = None class QobuzClient(Client): + """QobuzClient class for interacting with the Qobuz API. + + Attributes + ---------- + source (str): The source identifier for Qobuz. + max_quality (int): The maximum quality level supported by Qobuz. + + Methods + ------- + __init__(self, config: Config): Initialize the QobuzClient instance. + login(self): Log in to the Qobuz API. + get_metadata(self, item_id: str, media_type: str): Get metadata for a specified item. + get_label(self, label_id: str) -> dict: Get details for a label. + search(self, media_type: str, query: str, limit: int = 500) -> list[dict]: Search for items on Qobuz. + get_featured(self, query, limit: int = 500) -> list[dict]: Get featured items on Qobuz. + get_user_favorites(self, media_type: str, limit: int = 500) -> list[dict]: Get user favorites for a media type. + get_user_playlists(self, limit: int = 500) -> list[dict]: Get user playlists on Qobuz. + get_downloadable(self, item_id: str, quality: int) -> Downloadable: Get downloadable content details. + + Private Methods + _paginate(self, epoint: str, params: dict, limit: int = 500) -> list[dict]: Paginate search results. + _get_app_id_and_secrets(self) -> tuple[str, list[str]]: Get Qobuz app ID and secrets. + _get_valid_secret(self, secrets: list[str]) -> str: Get a valid secret for authentication. + _test_secret(self, secret: str) -> Optional[str]: Test the validity of a secret. + _request_file_url(self, track_id: str, quality: int, secret: str) -> tuple[int, dict]: Request file URL for downloading. + _api_request(self, epoint: str, params: dict) -> tuple[int, dict]: Make a request to the Qobuz API. + get_quality(quality: int): Map the quality level to Qobuz format. + """ + source = "qobuz" max_quality = 4 def __init__(self, config: Config): + """Initialize a new QobuzClient instance. + + Args: + ---- + config (Config): Configuration object containing session details. + """ self.logged_in = False self.config = config self.rate_limiter = self.get_rate_limiter( @@ -146,6 +218,15 @@ class QobuzClient(Client): self.secret: Optional[str] = None async def login(self): + """Log in to the Qobuz API. + + Raises + ------ + MissingCredentialsError: If email/user ID or password/token is missing. + AuthenticationError: If invalid credentials are provided. + InvalidAppIdError: If the app ID is invalid. + IneligibleError: If the user has a free account that is not eligible for downloading tracks. + """ self.session = await self.get_session() c = self.config.session.qobuz if not c.email_or_userid or not c.password_or_token: @@ -198,6 +279,17 @@ class QobuzClient(Client): self.logged_in = True async def get_metadata(self, item_id: str, media_type: str): + """Get metadata for a specified item. + + Args: + ---- + item_id (str): The ID of the item. + media_type (str): The type of media (e.g., artist, album, track). + + Raises: + ------ + NonStreamableError: If there is an error fetching metadata. + """ if media_type == "label": return await self.get_label(item_id) @@ -233,6 +325,16 @@ class QobuzClient(Client): return resp async def get_label(self, label_id: str) -> dict: + """Get details for a label. + + Args: + ---- + label_id (str): The ID of the label. + + Returns: + ------- + dict: Details of the label. + """ c = self.config.session.qobuz page_limit = 500 params = { @@ -273,6 +375,18 @@ class QobuzClient(Client): return label_resp async def search(self, media_type: str, query: str, limit: int = 500) -> list[dict]: + """Search for items on Qobuz. + + Args: + ---- + media_type (str): The type of media to search for (e.g., artist, album, track, playlist). + query (str): The search query. + limit (int): The maximum number of results to retrieve. + + Returns: + ------- + list[dict]: List of search results. + """ if media_type not in ("artist", "album", "track", "playlist"): raise Exception(f"{media_type} not available for search on qobuz") @@ -284,6 +398,21 @@ class QobuzClient(Client): return await self._paginate(epoint, params, limit=limit) async def get_featured(self, query, limit: int = 500) -> list[dict]: + """Get featured items on Qobuz. + + Args: + ---- + query: The type of featured items to retrieve. + limit (int): The maximum number of results to retrieve. + + Raises: + ------ + AssertionError: If the provided query is invalid. + + Returns: + ------- + list[dict]: List of featured items. + """ params = { "type": query, } @@ -292,6 +421,21 @@ class QobuzClient(Client): return await self._paginate(epoint, params, limit=limit) async def get_user_favorites(self, media_type: str, limit: int = 500) -> list[dict]: + """Get user favorites for a specific media type on Qobuz. + + Args: + ---- + media_type (str): The type of media (e.g., track, artist, album). + limit (int): The maximum number of results to retrieve. + + Raises: + ------ + AssertionError: If the provided media type is invalid. + + Returns: + ------- + list[dict]: List of user favorites for the specified media type. + """ assert media_type in ("track", "artist", "album") params = {"type": f"{media_type}s"} epoint = "favorite/getUserFavorites" @@ -299,10 +443,36 @@ class QobuzClient(Client): return await self._paginate(epoint, params, limit=limit) async def get_user_playlists(self, limit: int = 500) -> list[dict]: + """Get user playlists on Qobuz. + + Args: + ---- + limit (int): The maximum number of playlists to retrieve. + + Returns: + ------- + list[dict]: List of user playlists. + """ epoint = "playlist/getUserPlaylists" return await self._paginate(epoint, {}, limit=limit) async def get_downloadable(self, item_id: str, quality: int) -> Downloadable: + """Get details of a downloadable item on Qobuz. + + Args: + ---- + item_id (str): The ID of the item to download. + quality (int): The quality level of the download. + + Raises: + ------ + AssertionError: If the secret is not valid, not logged in, or quality level is out of bounds. + NonStreamableError: If the item is not streamable or there is an error. + + Returns: + ------- + Downloadable: Downloadable item details. + """ assert self.secret is not None and self.logged_in and 1 <= quality <= 4 status, resp_json = await self._request_file_url(item_id, quality, self.secret) assert status == 200 @@ -332,13 +502,15 @@ class QobuzClient(Client): ) -> list[dict]: """Paginate search results. - params: - limit: If None, all the results are yielded. Otherwise a maximum - of `limit` results are yielded. + Args: + ---- + epoint (str): The API endpoint. + params (dict): Parameters for the API request. + limit (int): The maximum number of results to retrieve. - Returns + Returns: ------- - Generator that yields (status code, response) tuples + list[dict]: List of paginated search results. """ params.update({"limit": limit}) status, page = await self._api_request(epoint, params) @@ -408,7 +580,7 @@ class QobuzClient(Client): quality: int, secret: str, ) -> tuple[int, dict]: - quality = self.get_quality(quality) + quality = self._get_quality(quality) unix_ts = time.time() r_sig = f"trackgetFileUrlformat_id{quality}intentstreamtrack_id{track_id}{unix_ts}{secret}" logger.debug("Raw request signature: %s", r_sig) @@ -425,6 +597,7 @@ class QobuzClient(Client): async def _api_request(self, epoint: str, params: dict) -> tuple[int, dict]: """Make a request to the API. + returns: status code, json parsed response """ url = f"{QOBUZ_BASE_URL}/{epoint}" @@ -434,6 +607,6 @@ class QobuzClient(Client): return response.status, await response.json() @staticmethod - def get_quality(quality: int): + def _get_quality(quality: int): quality_map = (5, 6, 7, 27) return quality_map[quality - 1] diff --git a/streamrip/client/soundcloud.py b/streamrip/client/soundcloud.py index a34954e..4ca23b4 100644 --- a/streamrip/client/soundcloud.py +++ b/streamrip/client/soundcloud.py @@ -56,12 +56,12 @@ class SoundcloudClient(Client): """Fetch metadata for an item in Soundcloud API. Args: - + ---- item_id (str): Plain soundcloud item ID (e.g 1633786176) media_type (str): track or playlist Returns: - + ------- API response. The item IDs for the tracks in the playlist are modified to include resolution status. """ @@ -141,9 +141,11 @@ class SoundcloudClient(Client): usage. Args: + ---- url (str): Url to resolve. Returns: + ------- API response for item. """ resp, status = await self._api_request("resolve", params={"url": url}) diff --git a/streamrip/config.py b/streamrip/config.py index 1ed3ce2..035f2be 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -20,6 +20,8 @@ CURRENT_CONFIG_VERSION = "2.0.3" @dataclass(slots=True) class QobuzConfig: + """Stores configuration related to Qobuz.""" + use_auth_token: bool email_or_userid: str # This is an md5 hash of the plaintext password @@ -35,6 +37,8 @@ class QobuzConfig: @dataclass(slots=True) class TidalConfig: + """Stores configuration related to Tidal.""" + # Do not change any of the fields below user_id: str country_code: str @@ -52,6 +56,8 @@ class TidalConfig: @dataclass(slots=True) class DeezerConfig: + """Stores configuration related to Deezer.""" + # An authentication cookie that allows streamrip to use your Deezer account # See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie # for instructions on how to find this @@ -70,6 +76,8 @@ class DeezerConfig: @dataclass(slots=True) class SoundcloudConfig: + """Stores configuration related to Soundcloud.""" + # This changes periodically, so it needs to be updated client_id: str app_version: str @@ -79,6 +87,8 @@ class SoundcloudConfig: @dataclass(slots=True) class YoutubeConfig: + """Stores configuration related to Youtube.""" + # The path to download the videos to video_downloads_folder: str # Only 0 is available for now @@ -89,6 +99,8 @@ class YoutubeConfig: @dataclass(slots=True) class DatabaseConfig: + """Stores configuration related to databases.""" + downloads_enabled: bool downloads_path: str failed_downloads_enabled: bool @@ -97,6 +109,8 @@ class DatabaseConfig: @dataclass(slots=True) class ConversionConfig: + """Stores configuration related to audio coversion.""" + enabled: bool # FLAC, ALAC, OPUS, MP3, VORBIS, or AAC codec: str @@ -112,6 +126,8 @@ class ConversionConfig: @dataclass(slots=True) class QobuzDiscographyFilterConfig: + """Stores configuration related to qobuz discography filters.""" + # Remove Collectors Editions, live recordings, etc. extras: bool # Picks the highest quality out of albums with identical titles. @@ -128,6 +144,8 @@ class QobuzDiscographyFilterConfig: @dataclass(slots=True) class ArtworkConfig: + """Stores configuration related to Album artwork.""" + # Write the image to the audio file embed: bool # The size of the artwork to embed. Options: thumbnail, small, large, original. @@ -146,6 +164,8 @@ class ArtworkConfig: @dataclass(slots=True) class MetadataConfig: + """Stores configuration related to Metadata.""" + # Sets the value of the 'ALBUM' field in the metadata to the playlist's name. # This is useful if your music library software organizes tracks based on album name. set_playlist_to_album: bool @@ -159,6 +179,8 @@ class MetadataConfig: @dataclass(slots=True) class FilepathsConfig: + """Stores configuration related to Filepaths.""" + # Create folders for single tracks within the downloads directory using the folder_format # template add_singles_to_folder: bool @@ -177,6 +199,8 @@ class FilepathsConfig: @dataclass(slots=True) class DownloadsConfig: + """Stores configuration related to downloads.""" + # Folder where tracks are downloaded to folder: str # Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal' etc. @@ -194,6 +218,8 @@ class DownloadsConfig: @dataclass(slots=True) class LastFmConfig: + """Stores configuration related to last.fm.""" + # The source on which to search for the tracks. source: str # If no results were found with the primary source, the item is searched for @@ -203,6 +229,8 @@ class LastFmConfig: @dataclass(slots=True) class CliConfig: + """Stores configuration related to the command line interface.""" + # Print "Downloading {Album name}" etc. to screen text_output: bool # Show resolve, download progress bars @@ -213,6 +241,8 @@ class CliConfig: @dataclass(slots=True) class MiscConfig: + """Stores miscellaneous configuration.""" + version: str check_for_updates: bool @@ -231,6 +261,8 @@ assert os.path.isfile(BLANK_CONFIG_PATH), "Template config not found" @dataclass(slots=True) class ConfigData: + """Stores all the configuration data.""" + toml: TOMLDocument downloads: DownloadsConfig @@ -256,6 +288,7 @@ class ConfigData: @classmethod def from_toml(cls, toml_str: str): + """Create a ConfigData instance from valid TOML.""" # TODO: handle the mistake where Windows people forget to escape backslash toml = parse(toml_str) if (v := toml["misc"]["version"]) != CURRENT_CONFIG_VERSION: # type: ignore @@ -300,36 +333,55 @@ class ConfigData: @classmethod def defaults(cls): + """Return a ConfigData object filled with default values.""" with open(BLANK_CONFIG_PATH) as f: return cls.from_toml(f.read()) def set_modified(self): + """Set the config data as modified for saving to disk.""" self._modified = True @property def modified(self): + """Get whether the config was modified for saving to disk.""" return self._modified def update_toml(self): - update_toml_section_from_config(self.toml["downloads"], self.downloads) - update_toml_section_from_config(self.toml["qobuz"], self.qobuz) - update_toml_section_from_config(self.toml["tidal"], self.tidal) - update_toml_section_from_config(self.toml["deezer"], self.deezer) - update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud) - update_toml_section_from_config(self.toml["youtube"], self.youtube) - update_toml_section_from_config(self.toml["lastfm"], self.lastfm) - update_toml_section_from_config(self.toml["artwork"], self.artwork) - update_toml_section_from_config(self.toml["filepaths"], self.filepaths) - update_toml_section_from_config(self.toml["metadata"], self.metadata) - update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters) - update_toml_section_from_config(self.toml["cli"], self.cli) - update_toml_section_from_config(self.toml["database"], self.database) - update_toml_section_from_config(self.toml["conversion"], self.conversion) + """Write the current state to the TOML object, which will be synced with disk.""" + _update_toml_section_from_config(self.toml["downloads"], self.downloads) + _update_toml_section_from_config(self.toml["qobuz"], self.qobuz) + _update_toml_section_from_config(self.toml["tidal"], self.tidal) + _update_toml_section_from_config(self.toml["deezer"], self.deezer) + _update_toml_section_from_config(self.toml["soundcloud"], self.soundcloud) + _update_toml_section_from_config(self.toml["youtube"], self.youtube) + _update_toml_section_from_config(self.toml["lastfm"], self.lastfm) + _update_toml_section_from_config(self.toml["artwork"], self.artwork) + _update_toml_section_from_config(self.toml["filepaths"], self.filepaths) + _update_toml_section_from_config(self.toml["metadata"], self.metadata) + _update_toml_section_from_config(self.toml["qobuz_filters"], self.qobuz_filters) + _update_toml_section_from_config(self.toml["cli"], self.cli) + _update_toml_section_from_config(self.toml["database"], self.database) + _update_toml_section_from_config(self.toml["conversion"], self.conversion) def get_source( self, source: str, ) -> QobuzConfig | DeezerConfig | SoundcloudConfig | TidalConfig: + """Return the configuration for the source. + + Args: + ---- + source (str): One of the available sources + + Returns: + ------- + A Config dataclass. + + Raises: + ------ + Exception: If the source is invalid + + """ d = { "qobuz": self.qobuz, "deezer": self.deezer, @@ -342,13 +394,21 @@ class ConfigData: return res -def update_toml_section_from_config(toml_section, config): +def _update_toml_section_from_config(toml_section, config): for field in fields(config): toml_section[field.name] = getattr(config, field.name) class Config: + """Manages the synchronization between the config data and the file stored on disk. + + It contains 2 copies of the data: one that will be synced with disk (self.file), + and another that will be read during program execution, but not synced with + disk (self.session). + """ + def __init__(self, path: str, /): + """Create Config object.""" self.path = path with open(path) as toml_file: @@ -357,6 +417,7 @@ class Config: self.session: ConfigData = copy.deepcopy(self.file) def save_file(self): + """Save the file config copy to disk.""" if not self.file.modified: return @@ -366,12 +427,15 @@ class Config: @classmethod def defaults(cls): + """Return a Config object with default values.""" return cls(BLANK_CONFIG_PATH) def __enter__(self): + """Enter context manager.""" return self def __exit__(self, *_): + """Save to disk when context manager exits.""" self.save_file() diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 8227899..de35e9a 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -261,6 +261,7 @@ class PendingLastfmPlaylist(Pending): if that fails. Args: + ---- query (str): Query to search s (Status): callback: function to call after each query completes diff --git a/streamrip/metadata/album.py b/streamrip/metadata/album.py index de941ec..f6d0b38 100644 --- a/streamrip/metadata/album.py +++ b/streamrip/metadata/album.py @@ -281,9 +281,8 @@ class AlbumMetadata: @classmethod def from_tidal(cls, resp) -> AlbumMetadata | None: - """ - - Args: + """Args: + ---- resp: API response containing album metadata. Returns: AlbumMetadata instance if the album is streamable, otherwise None. diff --git a/streamrip/rip/cli.py b/streamrip/rip/cli.py index cde7c09..0e5560b 100644 --- a/streamrip/rip/cli.py +++ b/streamrip/rip/cli.py @@ -353,7 +353,7 @@ async def search(ctx, first, output_file, num_results, source, media_type, query """Search for content using a specific source. Example: - + ------- rip search qobuz album 'rumours' """ if first and output_file: diff --git a/tests/silence.flac b/tests/silence.flac index 108c437ac4075cafa1c30b07582879c2fa8f0559..302cef910cb7fa83a3f48ea74359cd7584360228 100644 GIT binary patch delta 19 bcmbPupK;q0#tqSdn>T1KW!fAaXeI{$U3&;m delta 13 Ucmdn?gmL11#tqSdn?lUw04`(&!vFvP