diff --git a/.mypy.ini b/.mypy.ini index 5a53919..1dc19fa 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -22,8 +22,11 @@ ignore_missing_imports = True [mypy-tomlkit.*] ignore_missing_imports = True -[mypy-Crypto.*] +[mypy-Cryptodome.*] ignore_missing_imports = True [mypy-click.*] ignore_missing_imports = True + +[mypy-PIL.*] +ignore_missing_imports = True diff --git a/rip/__init__.py b/rip/__init__.py index e69de29..6015393 100644 --- a/rip/__init__.py +++ b/rip/__init__.py @@ -0,0 +1 @@ +"""Rip: an easy to use command line utility for downloading audio streams.""" diff --git a/rip/__main__.py b/rip/__main__.py index 4e28416..f6f770a 100644 --- a/rip/__main__.py +++ b/rip/__main__.py @@ -1,3 +1,4 @@ +"""Run the rip program.""" from .cli import main main() diff --git a/rip/constants.py b/rip/constants.py index 96a9bd6..f119132 100644 --- a/rip/constants.py +++ b/rip/constants.py @@ -1,3 +1,5 @@ +"""Various constant values that are used by RipCore.""" + import os import re from pathlib import Path diff --git a/rip/core.py b/rip/core.py index 7b1bc10..eb6b5ad 100644 --- a/rip/core.py +++ b/rip/core.py @@ -240,6 +240,10 @@ class RipCore(list): } def repair(self, max_items=None): + """Iterate through the failed_downloads database and retry them. + + :param max_items: The maximum number of items to download. + """ if max_items is None: max_items = float("inf") @@ -331,6 +335,11 @@ class RipCore(list): item.convert(**arguments["conversion"]) def scrape(self, featured_list: str): + """Download all of the items in a Qobuz featured list. + + :param featured_list: The name of the list. See `rip discover --help`. + :type featured_list: str + """ self.extend(self.search("qobuz", featured_list, "featured", limit=500)) def get_client(self, source: str) -> Client: diff --git a/rip/db.py b/rip/db.py index 45f1eea..af40076 100644 --- a/rip/db.py +++ b/rip/db.py @@ -15,6 +15,11 @@ class Database: name: str def __init__(self, path, dummy=False): + """Create a Database instance. + + :param path: Path to the database file. + :param dummy: Make the database empty. + """ assert self.structure != [] assert self.name @@ -72,7 +77,15 @@ class Database: return bool(conn.execute(command, tuple(items.values())).fetchone()[0]) - def __contains__(self, keys: dict) -> bool: + def __contains__(self, keys: Union[str, dict]) -> bool: + """Check whether a key-value pair exists in the database. + + :param keys: Either a dict with the structure {key: value_to_search_for, ...}, + or if there is only one key in the table, value_to_search_for can be + passed in by itself. + :type keys: Union[str, dict] + :rtype: bool + """ if isinstance(keys, dict): return self.contains(**keys) @@ -119,6 +132,12 @@ class Database: logger.debug(e) def remove(self, **items): + """Remove items from a table. + + Warning: NOT TESTED! + + :param items: + """ # not in use currently if self.is_dummy: return @@ -131,6 +150,7 @@ class Database: conn.execute(command, tuple(items.values())) def __iter__(self): + """Iterate through the rows of the table.""" if self.is_dummy: return () @@ -138,6 +158,7 @@ class Database: return conn.execute(f"SELECT * FROM {self.name}") def reset(self): + """Delete the database file.""" try: os.remove(self.path) except FileNotFoundError: @@ -145,6 +166,8 @@ class Database: class Downloads(Database): + """A table that stores the downloaded IDs.""" + name = "downloads" structure = { "id": ["text", "unique"], @@ -152,6 +175,8 @@ class Downloads(Database): class FailedDownloads(Database): + """A table that stores information about failed downloads.""" + name = "failed_downloads" structure = { "source": ["text"], diff --git a/rip/exceptions.py b/rip/exceptions.py index 2a9fc19..a392215 100644 --- a/rip/exceptions.py +++ b/rip/exceptions.py @@ -1,2 +1,5 @@ +"""Exceptions used by RipCore.""" + + class DeezloaderFallback(Exception): - pass + """Raise if Deezer account isn't logged in and rip is falling back to Deezloader.""" diff --git a/rip/utils.py b/rip/utils.py index 8bab1b6..dcf1a5f 100644 --- a/rip/utils.py +++ b/rip/utils.py @@ -1,3 +1,5 @@ +"""Utility functions for RipCore.""" + import re from typing import Tuple diff --git a/streamrip/clients.py b/streamrip/clients.py index a499ffb..22bf9eb 100644 --- a/streamrip/clients.py +++ b/streamrip/clients.py @@ -472,6 +472,10 @@ class DeezerClient(Client): return response def login(self, **kwargs): + """Log into Deezer. + + :param kwargs: + """ try: arl = kwargs["arl"] except KeyError: @@ -491,7 +495,6 @@ class DeezerClient(Client): :param type_: :type type_: str """ - GET_FUNCTIONS = { "track": self.client.api.get_track, "album": self.client.api.get_album, @@ -630,11 +633,13 @@ class DeezerClient(Client): class DeezloaderClient(Client): + """DeezloaderClient.""" source = "deezer" max_quality = 1 def __init__(self): + """Create a DeezloaderClient.""" self.session = gen_threadsafe_session() # no login required @@ -1037,7 +1042,7 @@ class TidalClient(Client): params["countryCode"] = self.country_code params["limit"] = 100 r = self.session.get(f"{TIDAL_BASE}/{path}", params=params) - r.raise_for_status() + # r.raise_for_status() return r.json() def _get_video_stream_url(self, video_id: str) -> str: diff --git a/streamrip/exceptions.py b/streamrip/exceptions.py index 04b9846..fbf9b90 100644 --- a/streamrip/exceptions.py +++ b/streamrip/exceptions.py @@ -1,41 +1,64 @@ +"""Streamrip specific exceptions.""" from typing import List import click class AuthenticationError(Exception): - pass + """AuthenticationError.""" class MissingCredentials(Exception): - pass + """MissingCredentials.""" class IneligibleError(Exception): - pass + """IneligibleError. + + Raised when the account is not eligible to stream a track. + """ class InvalidAppIdError(Exception): - pass + """InvalidAppIdError.""" class InvalidAppSecretError(Exception): - pass + """InvalidAppSecretError.""" class InvalidQuality(Exception): - pass + """InvalidQuality.""" class NonStreamable(Exception): + """Item is not streamable. + + A versatile error that can have many causes. + """ + def __init__(self, message=None): + """Create a NonStreamable exception. + + :param message: + """ self.message = message super().__init__(self.message) def print(self, item): + """Print a readable version of the exception. + + :param item: + """ click.echo(self.print_msg(item)) def print_msg(self, item) -> str: + """Return a generic readable message. + + :param item: + :type item: Media + :rtype: str + """ base_msg = [click.style(f"Unable to stream {item!s}.", fg="yellow")] if self.message: base_msg.extend( @@ -49,38 +72,45 @@ class NonStreamable(Exception): class InvalidContainerError(Exception): - pass + """InvalidContainerError.""" class InvalidSourceError(Exception): - pass + """InvalidSourceError.""" class ParsingError(Exception): - pass + """ParsingError.""" class TooLargeCoverArt(Exception): - pass + """TooLargeCoverArt.""" class BadEncoderOption(Exception): - pass + """BadEncoderOption.""" class ConversionError(Exception): - pass + """ConversionError.""" class NoResultsFound(Exception): - pass + """NoResultsFound.""" class ItemExists(Exception): - pass + """ItemExists.""" class PartialFailure(Exception): + """Raise if part of a tracklist fails to download.""" + def __init__(self, failed_items: List): + """Create a PartialFailure exception. + + :param failed_items: + :type failed_items: List + """ self.failed_items = failed_items super().__init__() diff --git a/streamrip/media.py b/streamrip/media.py index 4a38f9b..687b1a4 100644 --- a/streamrip/media.py +++ b/streamrip/media.py @@ -63,38 +63,60 @@ TYPE_REGEXES = { class Media(abc.ABC): + """An interface for a downloadable item.""" + @abc.abstractmethod def download(self, **kwargs): + """Download the item. + + :param kwargs: + """ pass @abc.abstractmethod def load_meta(self, **kwargs): + """Load all of the metadata for an item. + + :param kwargs: + """ pass @abc.abstractmethod def tag(self, **kwargs): + """Tag this item with metadata, if applicable. + + :param kwargs: + """ pass @abc.abstractmethod def convert(self, **kwargs): + """Convert this item between file formats. + + :param kwargs: + """ pass @abc.abstractmethod def __repr__(self): + """Return a string representation of the item.""" pass @abc.abstractmethod def __str__(self): + """Get a readable representation of the item.""" pass @property @abc.abstractmethod def type(self): + """Return the type of the item.""" pass @property @abc.abstractmethod def downloaded_ids(self): + """If the item is a collection, this is a set of downloaded IDs.""" pass @downloaded_ids.setter @@ -268,8 +290,8 @@ class Track(Media): try: dl_info = self.client.get_file_url(url_id, self.quality) except Exception as e: - # click.secho(f"Unable to download track. {e}", fg="red") - raise NonStreamable(repr(e)) + # raise NonStreamable(repr(e)) + raise NonStreamable(e) if self.client.source == "qobuz": if not self.__validate_qobuz_dl_info(dl_info): @@ -429,6 +451,10 @@ class Track(Media): @property def type(self) -> str: + """Return "track". + + :rtype: str + """ return "track" @property @@ -754,6 +780,7 @@ class Track(Media): return f"{self['artist']} - {self['title']}" def __bool__(self): + """Return True.""" return True @@ -835,6 +862,13 @@ class Video(Media): ) def convert(self, *args, **kwargs): + """Return None. + + Dummy method. + + :param args: + :param kwargs: + """ pass @property @@ -854,6 +888,10 @@ class Video(Media): @property def type(self) -> str: + """Return "video". + + :rtype: str + """ return "video" def __str__(self) -> str: @@ -871,6 +909,7 @@ class Video(Media): return f"