From 0fe30b2ea257cd87d5e7e6296edcd57fdc31c2a1 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 15 Mar 2022 22:18:01 +0100 Subject: [PATCH 01/53] some changes on the config class --- src/audible_cli/cmds/cmd_download.py | 13 +- src/audible_cli/cmds/cmd_manage.py | 10 +- src/audible_cli/cmds/cmd_quickstart.py | 42 +++--- src/audible_cli/config.py | 170 +++++++++++++------------ src/audible_cli/constants.py | 9 +- 5 files changed, 126 insertions(+), 118 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index f9d7a61..6014164 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -418,7 +418,11 @@ def queue_job( ) -async def main(config, auth, **params): +async def main(session, **params): + auth = session.auth + config = session.config + profile = session.selected_profile + output_dir = pathlib.Path(params.get("output_dir")).resolve() # which item(s) to download @@ -455,8 +459,7 @@ async def main(config, auth, **params): filename_mode = params.get("filename_mode") if filename_mode == "config": - filename_mode = config.profile_config.get("filename_mode") or \ - config.app_config.get("filename_mode") or \ + filename_mode = config.get_profile_option(profile, "filename_mode") or \ "ascii" headers = { @@ -695,10 +698,8 @@ def cli(session, **params): """download audiobook(s) from library""" loop = asyncio.get_event_loop() ignore_httpx_ssl_eror(loop) - auth = session.auth - config = session.config try: - loop.run_until_complete(main(config, auth, **params)) + loop.run_until_complete(main(session, **params)) finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() diff --git a/src/audible_cli/cmds/cmd_manage.py b/src/audible_cli/cmds/cmd_manage.py index e132a43..079506d 100644 --- a/src/audible_cli/cmds/cmd_manage.py +++ b/src/audible_cli/cmds/cmd_manage.py @@ -45,13 +45,13 @@ def config_editor(session): def list_profiles(session): """List all profiles in the config file""" head = ["P", "Profile", "auth file", "cc"] - profiles = session.config.data.get("profile") + config = session.config + profiles = config.data.get("profile") data = [] for profile in profiles: - p = profiles.get(profile) - auth_file = p.get("auth_file") - country_code = p.get("country_code") + auth_file = config.get_profile_option(profile, "auth_file") + country_code = config.get_profile_option(profile, "country_code") is_primary = profile == session.config.primary_profile data.append( ["*" if is_primary else "", profile, auth_file, country_code]) @@ -92,7 +92,7 @@ def list_profiles(session): def add_profile(ctx, session, profile, country_code, auth_file, is_primary): """Adds a profile to config file""" if not (session.config.dirname / auth_file).exists(): - logger.error("Auth file doesn't exists.") + logger.error("Auth file doesn't exists") raise click.Abort() session.config.add_profile( diff --git a/src/audible_cli/cmds/cmd_quickstart.py b/src/audible_cli/cmds/cmd_quickstart.py index e8eee9e..c955d70 100644 --- a/src/audible_cli/cmds/cmd_quickstart.py +++ b/src/audible_cli/cmds/cmd_quickstart.py @@ -1,12 +1,13 @@ import logging +import pathlib import sys -import audible import click from click import echo, secho, prompt from tabulate import tabulate -from ..config import Config, pass_session +from .. import __version__ +from ..config import ConfigFile, pass_session from ..constants import CONFIG_FILE, DEFAULT_AUTH_FILE_EXTENSION from ..utils import build_auth_file @@ -31,10 +32,10 @@ def tabulate_summary(d: dict) -> str: return tabulate(data, head, tablefmt="pretty", colalign=("left", "left")) -def ask_user(config: Config): +def ask_user(config: ConfigFile): d = {} welcome_message = ( - f"Welcome to the audible {audible.__version__} quickstart utility.") + f"\nWelcome to the audible-cli {__version__} quickstart utility.") secho(welcome_message, bold=True) secho(len(welcome_message) * "=", bold=True) @@ -50,11 +51,11 @@ config dir. If the auth file doesn't exists, it will be created. In this case, an authentication to the audible server is necessary to register a new device. """ echo() - secho(intro, bold=True) + secho(intro) path = config.dirname.absolute() secho("Selected dir to proceed with:", bold=True) - echo(path.absolute()) + echo(path) echo() echo("Please enter values for the following settings (just press Enter " @@ -137,17 +138,14 @@ an authentication to the audible server is necessary to register a new device. @click.command("quickstart") -@click.pass_context @pass_session -def cli(session, ctx): +def cli(session): """Quicksetup audible""" - session._config = Config() - config = session.config - config._config_file = session.app_dir / CONFIG_FILE - if config.file_exists(): - m = f"Config file {config.filename} already exists. Quickstart will " \ + config_file: pathlib.Path = session.app_dir / CONFIG_FILE + config = ConfigFile(config_file, file_exists=False) + if config_file.is_file(): + m = f"Config file {config_file} already exists. Quickstart will " \ f"not overwrite existing files." - logger.error(m) raise click.Abort() @@ -157,16 +155,9 @@ def cli(session, ctx): echo(tabulate_summary(d)) click.confirm("Do you want to continue?", abort=True) - config.add_profile( - name=d.get("profile_name"), - auth_file=d.get("auth_file"), - country_code=d.get("country_code"), - is_primary=True, - write_config=False) - if "use_existing_auth_file" not in d: build_auth_file( - filename=config.dirname / d.get("auth_file"), + filename=session.app_dir / d.get("auth_file"), username=d.get("audible_username"), password=d.get("audible_password"), country_code=d.get("country_code"), @@ -175,4 +166,9 @@ def cli(session, ctx): with_username=d.get("with_username") ) - config.write_config() + config.add_profile( + name=d.get("profile_name"), + auth_file=d.get("auth_file"), + country_code=d.get("country_code"), + is_primary=True, + ) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index c716f17..69ed744 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -3,6 +3,7 @@ import os import pathlib from typing import Any, Dict, Optional, Union +import audible import click import toml from audible import Authenticator @@ -22,51 +23,73 @@ from .exceptions import AudibleCliException, ProfileAlreadyExists logger = logging.getLogger("audible_cli.config") -class Config: - """Holds the config file data and environment.""" +class ConfigFile: + """The config file data""" - def __init__(self) -> None: - self._config_file: Optional[pathlib.Path] = None - self._config_data: Dict[str, Union[str, Dict]] = DEFAULT_CONFIG_DATA - self._current_profile: Optional[str] = None - self._is_read: bool = False + def __init__( + self, + filename: Union[str, pathlib.Path], + file_exists: bool = True + ) -> None: + filename = pathlib.Path(filename).resolve() + config_data = DEFAULT_CONFIG_DATA.copy() + file_data = {} + + if file_exists: + if not filename.is_file(): + raise AudibleCliException( + f"Config file {click.format_filename(filename)} " + f"does not exists" + ) + file_data = toml.load(filename) + + config_data.update(file_data) + + self._config_file = filename + self._config_data = config_data @property - def filename(self) -> Optional[pathlib.Path]: + def filename(self) -> pathlib.Path: return self._config_file - def file_exists(self) -> bool: - return self.filename.exists() - @property def dirname(self) -> pathlib.Path: return self.filename.parent - def dir_exists(self) -> bool: - return self.dirname.exists() - - @property - def is_read(self) -> bool: - return self._is_read - @property def data(self) -> Dict[str, Union[str, Dict]]: return self._config_data @property def app_config(self) -> Dict[str, str]: - return self.data.get("APP", {}) - - @property - def profile_config(self) -> Dict[str, str]: - return self.data["profile"][self._current_profile] - - @property - def primary_profile(self) -> Optional[str]: - return self.app_config.get("primary_profile") + return self.data["APP"] def has_profile(self, name: str) -> bool: - return name in self.data.get("profile", {}) + return name in self.data["profile"] + + def get_profile(self, name: str) -> Dict[str, str]: + if not self.has_profile(name): + raise AudibleCliException(f"Profile {name} does not exists") + return self.data["profile"][name] + + @property + def primary_profile(self) -> str: + if "primary_profile" not in self.app_config: + raise AudibleCliException("No primary profile in config set") + return self.app_config["primary_profile"] + + def get_profile_option( + self, + profile: str, + option: str, + default: Optional[str] = None + ) -> str: + profile = self.get_profile(profile) + if option in profile: + return profile[option] + if option in self.app_config: + return self.app_config[option] + return default def add_profile( self, @@ -74,12 +97,11 @@ class Config: auth_file: Union[str, pathlib.Path], country_code: str, is_primary: bool = False, - abort_on_existing_profile: bool = True, write_config: bool = True, **additional_options ) -> None: - if self.has_profile(name) and abort_on_existing_profile: + if self.has_profile(name): raise ProfileAlreadyExists(name) profile_data = { @@ -95,23 +117,10 @@ class Config: if write_config: self.write_config() - def delete_profile(self, name: str) -> None: + def delete_profile(self, name: str, write_config: bool = True) -> None: del self.data["profile"][name] - - def read_config( - self, - filename: Optional[Union[str, pathlib.Path]] = None - ) -> None: - f = pathlib.Path(filename or self.filename).resolve() - - try: - self.data.update(toml.load(f)) - except FileNotFoundError: - message = f"Config file {click.format_filename(f)} not found" - raise AudibleCliException(message) - - self._config_file = f - self._is_read = True + if write_config: + self.write_config() def write_config( self, @@ -129,7 +138,7 @@ class Session: """Holds the settings for the current session.""" def __init__(self) -> None: self._auth: Optional[Authenticator] = None - self._config: Optional[Config] = None + self._config: Optional[CONFIG_FILE] = None self._params: Dict[str, Any] = {} self._app_dir = get_app_dir() self._plugin_dir = get_plugin_dir() @@ -153,49 +162,38 @@ class Session: def config(self): if self._config is None: conf_file = self.app_dir / CONFIG_FILE - self._config = Config() logger.debug( f"Load config from file: " f"{click.format_filename(conf_file, shorten=True)}" ) - self._config.read_config(conf_file) - - name = self.params.get("profile") or self.config.primary_profile - logger.debug(f"Selected profile: {name}") - - if name is None: - message = ( - "No profile provided and primary profile not set " - "properly in config." - ) - try: - ctx = click.get_current_context() - ctx.fail(message) - except RuntimeError: - raise KeyError(message) - - if not self.config.has_profile(name): - message = "Provided profile not found in config." - try: - ctx = click.get_current_context() - ctx.fail(message) - except RuntimeError: - raise UserWarning(message) - - self.config._current_profile = name + self._config = ConfigFile(conf_file) return self._config - def _set_auth(self): - profile = self.config.profile_config - auth_file = self.config.dirname / profile["auth_file"] - country_code = profile["country_code"] - password = self.params.get("password") + @property + def selected_profile(self): + profile = self.params.get("profile") or self.config.primary_profile + if profile is None: + message = ( + "No profile provided and primary profile not set " + "properly in config." + ) + raise AudibleCliException(message) + return profile + + def get_auth_for_profile( + self, + profile: str, + password: Optional[str] = None + ) -> audible.Authenticator: + auth_file = self.config.get_profile_option(profile, "auth_file") + country_code = self.config.get_profile_option(profile, "country_code") + password = password or self.params.get("password") while True: try: - self._auth = Authenticator.from_file( - filename=auth_file, + auth = Authenticator.from_file( + filename=self.config.dirname / auth_file, password=password, locale=country_code) break @@ -210,10 +208,20 @@ class Session: if len(password) == 0: raise click.Abort() + return auth + @property def auth(self): if self._auth is None: - self._set_auth() + profile = self.selected_profile + + logger.debug(f"Selected profile: {profile}") + + if not self.config.has_profile(profile): + message = "Provided profile not found in config." + raise AudibleCliException(message) + + self._auth = self.get_auth_for_profile(profile) return self._auth diff --git a/src/audible_cli/constants.py b/src/audible_cli/constants.py index aec325c..b9954a6 100644 --- a/src/audible_cli/constants.py +++ b/src/audible_cli/constants.py @@ -1,3 +1,6 @@ +from typing import Dict + + APP_NAME: str = "Audible" CONFIG_FILE: str = "config.toml" CONFIG_DIR_ENV: str = "AUDIBLE_CONFIG_DIR" @@ -6,10 +9,10 @@ PLUGIN_DIR_ENV: str = "AUDIBLE_PLUGIN_DIR" PLUGIN_ENTRY_POINT: str = "audible.cli_plugins" DEFAULT_AUTH_FILE_EXTENSION: str = "json" DEFAULT_AUTH_FILE_ENCRYPTION: str = "json" -DEFAULT_CONFIG_DATA = { +DEFAULT_CONFIG_DATA: Dict[str, str] = { "title": "Audible Config File", "APP": {}, "profile": {} } -CODEC_HIGH_QUALITY = "AAX_44_128" -CODEC_NORMAL_QUALITY = "AAX_44_64" +CODEC_HIGH_QUALITY: str = "AAX_44_128" +CODEC_NORMAL_QUALITY: str = "AAX_44_64" From 2c277f074804eb9d7df1178eb3239aead56ddb59 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 16 Mar 2022 13:58:28 +0100 Subject: [PATCH 02/53] add docstrings to config.ConfigFile class --- src/audible_cli/config.py | 70 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 69ed744..1a9c081 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -24,7 +24,21 @@ logger = logging.getLogger("audible_cli.config") class ConfigFile: - """The config file data""" + """Presents an audible-cli configuration file + + Instantiate a :class:`~audible_cli.config.ConfigFile` will load the file + content by default. To create a new config file, the ``file_exists`` + argument must be set to ``False``. + + Audible-cli configuration files are written in the toml markup language. + It has a main section named `APP` and sections for each profile named + `profile.`. + + Args: + filename: The file path to the config file + file_exists: If ``True``, the file must exists and the file content + is loaded. + """ def __init__( self, @@ -50,24 +64,38 @@ class ConfigFile: @property def filename(self) -> pathlib.Path: + """Returns the path to the config file""" return self._config_file @property def dirname(self) -> pathlib.Path: + """Returns the path to the config file directory""" return self.filename.parent @property def data(self) -> Dict[str, Union[str, Dict]]: + """Returns the configuration data""" return self._config_data @property def app_config(self) -> Dict[str, str]: + """Returns the configuration data for the APP section""" return self.data["APP"] def has_profile(self, name: str) -> bool: + """Check if a profile with these name are in the configuration data + + Args: + name: The name of the profile + """ return name in self.data["profile"] def get_profile(self, name: str) -> Dict[str, str]: + """Returns the configuration data for these profile name + + Args: + name: The name of the profile + """ if not self.has_profile(name): raise AudibleCliException(f"Profile {name} does not exists") return self.data["profile"][name] @@ -84,6 +112,17 @@ class ConfigFile: option: str, default: Optional[str] = None ) -> str: + """Returns the value for an option for the given profile. + + Looks first, if an option is in the ``profile`` section. If not, it + searchs for the option in the ``APP`` section. If not found, it + returns the ``default``. + + Args: + profile: The name of the profile + option: The name of the option to search for + default: The default value to return, if the option is not found + """ profile = self.get_profile(profile) if option in profile: return profile[option] @@ -100,6 +139,17 @@ class ConfigFile: write_config: bool = True, **additional_options ) -> None: + """Adds a new profile to the config + + Args: + name: The name of the profile + auth_file: The name of the auth_file + country_code: The country code of the marketplace to use with + this profile + is_primary: If ``True``, this profile is set as primary in the + ``APP`` section + write_config: If ``True``, save the config to file + """ if self.has_profile(name): raise ProfileAlreadyExists(name) @@ -118,6 +168,18 @@ class ConfigFile: self.write_config() def delete_profile(self, name: str, write_config: bool = True) -> None: + """Deletes a profile from config + + Args: + name: The name of the profile + write_config: If ``True``, save the config to file + + Note: + Does not delete the auth file. + """ + if not self.has_profile(name): + raise AudibleCliException(f"Profile {name} does not exists") + del self.data["profile"][name] if write_config: self.write_config() @@ -126,6 +188,12 @@ class ConfigFile: self, filename: Optional[Union[str, pathlib.Path]] = None ) -> None: + """Write the config data to file + + Args: + filename: If not ``None`` the config is written to these file path + instead of ``self.filename`` + """ f = pathlib.Path(filename or self.filename).resolve() if not f.parent.is_dir(): From e85d60055dfb7558db4b3c6ac4af806a4ca5e2c1 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 16 Mar 2022 15:45:41 +0100 Subject: [PATCH 03/53] rework config.py --- src/audible_cli/config.py | 69 +++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 1a9c081..956657f 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -56,6 +56,10 @@ class ConfigFile: f"does not exists" ) file_data = toml.load(filename) + logger.debug( + f"Config loaded from " + f"{click.format_filename(filename, shorten=True)}" + ) config_data.update(file_data) @@ -164,6 +168,8 @@ class ConfigFile: if is_primary: self.data["APP"]["primary_profile"] = name + logger.info(f"Profile {name} added to config") + if write_config: self.write_config() @@ -181,6 +187,9 @@ class ConfigFile: raise AudibleCliException(f"Profile {name} does not exists") del self.data["profile"][name] + + logger.info(f"Profile {name} removed from config") + if write_config: self.write_config() @@ -201,45 +210,60 @@ class ConfigFile: toml.dump(self.data, f.open("w")) + click_f = click.format_filename(f, shorten=True) + logger.info(f"Config written to {click_f}") + class Session: - """Holds the settings for the current session.""" + """Holds the settings for the current session""" def __init__(self) -> None: - self._auth: Optional[Authenticator] = None + self._auths: Dict[str, Authenticator] = {} self._config: Optional[CONFIG_FILE] = None self._params: Dict[str, Any] = {} - self._app_dir = get_app_dir() - self._plugin_dir = get_plugin_dir() + self._app_dir: pathlib.Path = get_app_dir() + self._plugin_dir: pathlib.Path = get_plugin_dir() + logger.debug(f"Audible-cli version: {__version__}") logger.debug(f"App dir: {click.format_filename(self.app_dir)}") logger.debug(f"Plugin dir: {click.format_filename(self.plugin_dir)}") @property def params(self): + """Returns the parameter of the session + + Parameter are usually added using the ``add_param_to_session`` + callback on a click option. This way an option from a parent command + can be accessed from his subcommands. + """ return self._params @property def app_dir(self): + """Returns the path of the app dir""" return self._app_dir @property def plugin_dir(self): + """Returns the path of the plugin dir""" return self._plugin_dir @property def config(self): + """Returns the ConfigFile for this session""" if self._config is None: conf_file = self.app_dir / CONFIG_FILE - logger.debug( - f"Load config from file: " - f"{click.format_filename(conf_file, shorten=True)}" - ) self._config = ConfigFile(conf_file) return self._config @property def selected_profile(self): + """Returns the selected config profile name for this session + + The `profile` to use must be set using the ``add_param_to_session`` + callback of a click option. Otherwise the primary profile from the + config is used. + """ profile = self.params.get("profile") or self.config.primary_profile if profile is None: message = ( @@ -254,6 +278,16 @@ class Session: profile: str, password: Optional[str] = None ) -> audible.Authenticator: + """Returns an Authenticator for a profile + + If an Authenticator for this profile is already loaded, it will + return the Authenticator without reloading it. This way an session can + hold multiple Authenticators for different profiles. Commands can use + this to make API requests for more than one profile. + """ + if profile in self._auths: + return self._auths[profile] + auth_file = self.config.get_profile_option(profile, "auth_file") country_code = self.config.get_profile_option(profile, "country_code") password = password or self.params.get("password") @@ -270,19 +304,23 @@ class Session: "Auth file is encrypted but no/wrong password is provided" ) password = click.prompt( - "Please enter the password (or enter to exit)", + "Please enter the auth-file password (or enter to exit)", hide_input=True, default="") if len(password) == 0: raise click.Abort() + click_f = click.format_filename(auth_file, shorten=True) + logger.debug(f"Auth file {click_f} loaded.") + + self._auths[profile] = auth return auth @property def auth(self): - if self._auth is None: - profile = self.selected_profile - + """Returns the Authenticator for the selected profile""" + profile = self.selected_profile + if profile not in self._auths: logger.debug(f"Selected profile: {profile}") if not self.config.has_profile(profile): @@ -290,7 +328,7 @@ class Session: raise AudibleCliException(message) self._auth = self.get_auth_for_profile(profile) - return self._auth + return self._auths[profile] pass_session = click.make_pass_decorator(Session, ensure=True) @@ -309,7 +347,10 @@ def get_plugin_dir() -> pathlib.Path: def add_param_to_session(ctx: click.Context, param, value): - """Add a parameter to :class:`Session` `param` attribute""" + """Add a parameter to :class:`Session` `param` attribute + + This is usually used as a callback for a click option + """ session = ctx.ensure_object(Session) session.params[param.name] = value return value From 6a6e0e10f27be8984c60ddf9d4032c2fce80fca1 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 16 Mar 2022 18:46:16 +0100 Subject: [PATCH 04/53] Move base filename --- src/audible_cli/cmds/cmd_download.py | 21 ++------------------- src/audible_cli/models.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 6014164..6900f76 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -6,7 +6,6 @@ import pathlib import ssl import logging import sys -import unicodedata import aiofiles import audible @@ -161,22 +160,6 @@ class DownloadCounter: counter = DownloadCounter() -def create_base_filename(item, mode): - if "ascii" in mode: - base_filename = item.full_title_slugify - - elif "unicode" in mode: - base_filename = unicodedata.normalize("NFKD", item.full_title) - - else: - base_filename = item.asin - - if "asin" in mode: - base_filename = item.asin + "_" + base_filename - - return base_filename - - async def download_cover( client, output_dir, base_filename, item, res, overwrite_existing ): @@ -357,7 +340,7 @@ def queue_job( quality, overwrite_existing ): - base_filename = create_base_filename(item=item, mode=filename_mode) + base_filename = item.create_base_filename(filename_mode) if get_cover: queue.put_nowait( @@ -540,7 +523,7 @@ async def main(session, **params): if i.asin not in jobs: items.append(i) - podcast_dir = create_base_filename(item, filename_mode) + podcast_dir = item.create_base_filename(filename_mode) odir = output_dir / podcast_dir if not odir.is_dir(): odir.mkdir(parents=True) diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index 7c10dcc..ba76981 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -72,6 +72,27 @@ class BaseItem: return slug_title + def create_base_filename(self, mode: str): + supported_modes = ("ascii", "asin_ascii", "unicode", "asin_unicode") + if mode not in supported_modes: + raise AudibleCliException( + f"Unsupported mode {mode} for name creation" + ) + + if "ascii" in mode: + base_filename = self.full_title_slugify + + elif "unicode" in mode: + base_filename = unicodedata.normalize("NFKD", self.full_title) + + else: + base_filename = self.asin + + if "asin" in mode: + base_filename = self.asin + "_" + base_filename + + return base_filename + def substring_in_title_accuracy(self, substring): match = LongestSubString(substring, self.full_title) return round(match.percentage, 2) From 5398e55fd2e26e1cecf18323b6224506a1745af9 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Thu, 17 Mar 2022 07:31:13 +0100 Subject: [PATCH 05/53] add more docstrings to config.py --- src/audible_cli/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 956657f..8b52fb9 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -284,6 +284,10 @@ class Session: return the Authenticator without reloading it. This way an session can hold multiple Authenticators for different profiles. Commands can use this to make API requests for more than one profile. + + Args: + profile: The name of the profile + password: The password of the auth file """ if profile in self._auths: return self._auths[profile] From 0668c48e31dcdba5031ebcc925d7fbf0b5f3dd26 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Thu, 17 Mar 2022 09:51:28 +0100 Subject: [PATCH 06/53] download command now uses the same client for API requests and downloads --- src/audible_cli/cmds/cmd_download.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 6900f76..b9b1704 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -448,10 +448,11 @@ async def main(session, **params): headers = { "User-Agent": "Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0" } - client = httpx.AsyncClient(auth=auth, timeout=timeout, headers=headers) api_client = audible.AsyncClient(auth, timeout=timeout) + api_client.session.headers.update(headers) + client = api_client.session - async with client, api_client: + async with api_client: # fetch the user library library = await Library.from_api_full_sync( api_client, From ec0e6d5165840407edc13ff13b9325ca77788abe Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 18 Mar 2022 08:28:13 +0100 Subject: [PATCH 07/53] add cmd_goodreads-transform.py to example plugin cmds --- plugin_cmds/cmd_goodreads-transform.py | 141 +++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 plugin_cmds/cmd_goodreads-transform.py diff --git a/plugin_cmds/cmd_goodreads-transform.py b/plugin_cmds/cmd_goodreads-transform.py new file mode 100644 index 0000000..a06a0f6 --- /dev/null +++ b/plugin_cmds/cmd_goodreads-transform.py @@ -0,0 +1,141 @@ +import asyncio +import csv +import logging +import pathlib +from datetime import datetime, timezone + +import audible +import click +from audible_cli.config import pass_session +from audible_cli.models import Library +from isbntools.app import isbn_from_words + + +logger = logging.getLogger("audible_cli.cmds.cmd_goodreads-transform") + + +@click.command("goodreads-transform") +@click.option( + "--output", "-o", + type=click.Path(path_type=pathlib.Path), + default=pathlib.Path().cwd() / "library.csv", + show_default=True, + help="output file" +) +@click.option( + "--timeout", "-t", + type=click.INT, + default=10, + show_default=True, + help=( + "Increase the timeout time if you got any TimeoutErrors. " + "Set to 0 to disable timeout." + ) +) +@click.option( + "--bunch-size", + type=click.IntRange(10, 1000), + default=1000, + show_default=True, + help="How many library items should be requested per request. A lower " + "size results in more requests to get the full library. A higher " + "size can result in a TimeOutError on low internet connections." +) +@pass_session +def cli(session, **params): + """YOUR COMMAND DESCRIPTION""" + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(_goodreads_transform(session.auth, **params)) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + +async def _goodreads_transform(auth, **params): + output = params.get("output") + + logger.debug("fetching library") + library = await _get_library(auth, **params) + + logger.debug("prepare library") + library = _prepare_library_for_export(library) + + logger.debug("write data rows to file") + with output.open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(["isbn", "Date Added", "Date Read", "Title"]) + + for row in library: + writer.writerow(row) + + logger.info(f"File saved to {output}") + + +async def _get_library(auth, **params): + timeout = params.get("timeout") + if timeout == 0: + timeout = None + + bunch_size = params.get("bunch_size") + + async with audible.AsyncClient(auth, timeout=timeout) as client: + # added product_detail to response_groups to obtain isbn + library = await Library.from_api_full_sync( + client, + response_groups=( + "product_details, contributors, is_finished, product_desc" + ), + bunch_size=bunch_size + ) + return library + + +def _prepare_library_for_export(library): + prepared_library = [] + + isbn_counter = 0 + isbn_api_counter = 0 + isbn_no_result_counter = 0 + skipped_items = 0 + + for i in library: + title = i.title + authors = i.authors + if authors is not None: + authors = ", ".join([a["name"] for a in authors]) + is_finished = i.is_finished + + isbn = i.isbn + if isbn is None: + isbn_counter += 1 + isbn = isbn_from_words(f"{title} {authors}") or None + if isbn is None: + isbn_no_result_counter += 1 + else: + isbn_api_counter += 1 + + date_added = i.library_status + if date_added is not None: + date_added = date_added["date_added"] + date_added = datetime.strptime( + date_added, '%Y-%m-%dT%H:%M:%S.%fZ' + ).replace(tzinfo=timezone.utc).astimezone() + date_added = date_added.astimezone().date().isoformat() + + date_read = None + if is_finished: + date_read = date_added + + if isbn and date_read: + data_row = [isbn, date_added, date_read, title] + prepared_library.append(data_row) + else: + skipped_items += 1 + + logger.debug(f"{isbn_api_counter} isbns from API") + logger.debug(f"{isbn_counter} isbns requested with isbntools") + logger.debug(f"{isbn_no_result_counter} isbns without a result") + logger.debug(f"{skipped_items} title skipped due to no isbn for title found or title not read") + + return prepared_library From 087eafe5822a33fef8368a1d80653fc28a5f4fda Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 18 Mar 2022 08:29:50 +0100 Subject: [PATCH 08/53] numbering title found for download command I will add a selector in feature commit --- src/audible_cli/cmds/cmd_download.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index b9b1704..f47c743 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -487,14 +487,18 @@ async def main(session, **params): match = library.search_item_by_title(title) full_match = [i for i in match if i[1] == 100] - if full_match or match: + if match: echo(f"\nFound the following matches for '{title}'") - table_data = [[i[1], i[0].full_title, i[0].asin] - for i in full_match or match] - head = ["% match", "title", "asin"] + + table_data = [] + for count, i in enumerate(full_match or match, start=1): + table_data.append( + [count, i[1], i[0].full_title, i[0].asin] + ) + head = ["#", "% match", "title", "asin"] table = tabulate( table_data, head, tablefmt="pretty", - colalign=("center", "left", "center")) + colalign=("center", "center", "left", "center")) echo(table) if no_confirm or click.confirm( @@ -509,7 +513,6 @@ async def main(session, **params): ) queue = asyncio.Queue() - for job in jobs: item = library.get_item_by_asin(job) items = [item] From 1cc48ba06d7e39ee0d6494f6691e99c7481d5f9f Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 11 Apr 2022 15:24:49 +0200 Subject: [PATCH 09/53] bump Audible to v0.8.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9068615..a769ee0 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ setup( ], install_requires=[ "aiofiles", - "audible==0.7.2", + "audible==0.8.0", "click>=8", "colorama; platform_system=='Windows'", "httpx>=0.20.*,<=0.22.*", From b06426ae57e8323450dc6a58975c0d6001c01e55 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 11 Apr 2022 15:45:20 +0200 Subject: [PATCH 10/53] Bugfix httpx version requirement --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a769ee0..2bcdf2a 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ setup( "audible==0.8.0", "click>=8", "colorama; platform_system=='Windows'", - "httpx>=0.20.*,<=0.22.*", + "httpx>=0.20.0,<0.23.0", "packaging", "Pillow", "tabulate", From ddae5f670791e7ad79ad558d74f9e926de6e7358 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Thu, 14 Apr 2022 17:43:54 +0200 Subject: [PATCH 11/53] rework package Doc about changes will be written later --- plugin_cmds/cmd_get-annotations.py | 18 ++ plugin_cmds/cmd_goodreads-transform.py | 68 ++--- plugin_cmds/cmd_image-urls.py | 20 +- plugin_cmds/cmd_listening-stats.py | 46 ++- plugin_cmds/cmd_remove-encryption.py | 16 +- src/audible_cli/_logging.py | 36 --- src/audible_cli/_version.py | 2 +- src/audible_cli/cli.py | 80 +---- src/audible_cli/cmds/cmd_activation_bytes.py | 2 +- src/audible_cli/cmds/cmd_api.py | 2 +- src/audible_cli/cmds/cmd_download.py | 296 +++++++++---------- src/audible_cli/cmds/cmd_library.py | 185 ++++-------- src/audible_cli/cmds/cmd_manage.py | 2 +- src/audible_cli/cmds/cmd_quickstart.py | 3 +- src/audible_cli/cmds/cmd_wishlist.py | 164 ++++------ src/audible_cli/config.py | 44 ++- src/audible_cli/decorators.py | 164 ++++++++++ src/audible_cli/models.py | 1 + src/audible_cli/utils.py | 15 + 19 files changed, 542 insertions(+), 622 deletions(-) create mode 100644 plugin_cmds/cmd_get-annotations.py create mode 100644 src/audible_cli/decorators.py diff --git a/plugin_cmds/cmd_get-annotations.py b/plugin_cmds/cmd_get-annotations.py new file mode 100644 index 0000000..a3877b6 --- /dev/null +++ b/plugin_cmds/cmd_get-annotations.py @@ -0,0 +1,18 @@ +import click + +from audible_cli.decorators import pass_session, run_async + + +@click.command("get-annotations") +@click.argument("asin") +@pass_session +@run_async() +async def cli(session, asin): + async with session.get_client() as client: + url = f"https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/sidecar" + params = { + "type": "AUDI", + "key": asin + } + r = await client.get(url, params=params) + click.echo(r) diff --git a/plugin_cmds/cmd_goodreads-transform.py b/plugin_cmds/cmd_goodreads-transform.py index a06a0f6..107a4dd 100644 --- a/plugin_cmds/cmd_goodreads-transform.py +++ b/plugin_cmds/cmd_goodreads-transform.py @@ -1,13 +1,16 @@ -import asyncio -import csv import logging import pathlib from datetime import datetime, timezone -import audible import click -from audible_cli.config import pass_session +from audible_cli.decorators import ( + bunch_size_option, + run_async, + timeout_option, + pass_session +) from audible_cli.models import Library +from audible_cli.utils import export_to_csv from isbntools.app import isbn_from_words @@ -22,64 +25,37 @@ logger = logging.getLogger("audible_cli.cmds.cmd_goodreads-transform") show_default=True, help="output file" ) -@click.option( - "--timeout", "-t", - type=click.INT, - default=10, - show_default=True, - help=( - "Increase the timeout time if you got any TimeoutErrors. " - "Set to 0 to disable timeout." - ) -) -@click.option( - "--bunch-size", - type=click.IntRange(10, 1000), - default=1000, - show_default=True, - help="How many library items should be requested per request. A lower " - "size results in more requests to get the full library. A higher " - "size can result in a TimeOutError on low internet connections." -) +@timeout_option() +@bunch_size_option() @pass_session -def cli(session, **params): +@run_async() +async def cli(session, **params): """YOUR COMMAND DESCRIPTION""" - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(_goodreads_transform(session.auth, **params)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - - -async def _goodreads_transform(auth, **params): output = params.get("output") logger.debug("fetching library") - library = await _get_library(auth, **params) + library = await _get_library(session) logger.debug("prepare library") library = _prepare_library_for_export(library) logger.debug("write data rows to file") - with output.open("w", encoding="utf-8", newline="") as f: - writer = csv.writer(f) - writer.writerow(["isbn", "Date Added", "Date Read", "Title"]) - for row in library: - writer.writerow(row) + headers = ("isbn", "Date Added", "Date Read", "Title") + export_to_csv( + file=output, + data=library, + headers=headers, + dialect="excel" + ) logger.info(f"File saved to {output}") -async def _get_library(auth, **params): - timeout = params.get("timeout") - if timeout == 0: - timeout = None +async def _get_library(session): + bunch_size = session.params.get("bunch_size") - bunch_size = params.get("bunch_size") - - async with audible.AsyncClient(auth, timeout=timeout) as client: + async with session.get_client() as client: # added product_detail to response_groups to obtain isbn library = await Library.from_api_full_sync( client, diff --git a/plugin_cmds/cmd_image-urls.py b/plugin_cmds/cmd_image-urls.py index 40b6960..aa8e3bc 100644 --- a/plugin_cmds/cmd_image-urls.py +++ b/plugin_cmds/cmd_image-urls.py @@ -1,19 +1,16 @@ -import audible import click -from audible_cli.config import pass_session +from audible_cli.decorators import pass_session, run_async, timeout_option -@click.command("get-cover-urls") -@click.option( - "--asin", "-a", - multiple=False, - help="asin of the audiobook" -) +@click.command("image-urls") +@click.argument("asin") +@timeout_option() @pass_session -def cli(session, asin): +@run_async() +async def cli(session, asin): "Print out the image urls for different resolutions for a book" - with audible.Client(auth=session.auth) as client: - r = client.get( + async with session.get_client() as client: + r = await client.get( f"catalog/products/{asin}", response_groups="media", image_sizes=("1215, 408, 360, 882, 315, 570, 252, " @@ -22,4 +19,3 @@ def cli(session, asin): images = r["product"]["product_images"] for res, url in images.items(): click.echo(f"Resolution {res}: {url}") - diff --git a/plugin_cmds/cmd_listening-stats.py b/plugin_cmds/cmd_listening-stats.py index 7d914a4..d6e3e07 100644 --- a/plugin_cmds/cmd_listening-stats.py +++ b/plugin_cmds/cmd_listening-stats.py @@ -4,9 +4,8 @@ import logging import pathlib from datetime import datetime -import audible import click -from audible_cli.config import pass_session +from audible_cli.decorators import pass_session, run_async logger = logging.getLogger("audible_cli.cmds.cmd_listening-stats") @@ -35,24 +34,6 @@ async def _get_stats_year(client, year): return stats_year -async def _listening_stats(auth, output, signup_year): - year_range = [y for y in range(signup_year, current_year+1)] - - async with audible.AsyncClient(auth=auth) as client: - - r = await asyncio.gather( - *[_get_stats_year(client, y) for y in year_range] - ) - - aggreated_stats = {} - for i in r: - for k, v in i.items(): - aggreated_stats[k] = v - - aggreated_stats = json.dumps(aggreated_stats, indent=4) - output.write_text(aggreated_stats) - - @click.command("listening-stats") @click.option( "--output", "-o", @@ -69,14 +50,21 @@ async def _listening_stats(auth, output, signup_year): help="start year for collecting listening stats" ) @pass_session -def cli(session, output, signup_year): +@run_async() +async def cli(session, output, signup_year): """get and analyse listening statistics""" - loop = asyncio.get_event_loop() - try: - loop.run_until_complete( - _listening_stats(session.auth, output, signup_year) + year_range = [y for y in range(signup_year, current_year+1)] + + async with session.get_client() as client: + + r = await asyncio.gather( + *[_get_stats_year(client, y) for y in year_range] ) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - + + aggreated_stats = {} + for i in r: + for k, v in i.items(): + aggreated_stats[k] = v + + aggreated_stats = json.dumps(aggreated_stats, indent=4) + output.write_text(aggreated_stats) diff --git a/plugin_cmds/cmd_remove-encryption.py b/plugin_cmds/cmd_remove-encryption.py index a4f848d..1f218c5 100644 --- a/plugin_cmds/cmd_remove-encryption.py +++ b/plugin_cmds/cmd_remove-encryption.py @@ -14,7 +14,7 @@ import subprocess from shutil import which import click -from audible_cli.config import pass_session +from audible_cli.decoratos import pass_session from click import echo, secho @@ -170,7 +170,7 @@ class FFMeta: self._ffmeta_parsed["CHAPTER"] = new_chapters -def decrypt_aax(files, session): +def decrypt_aax(files, activation_bytes): for file in files: outfile = file.with_suffix(".m4b") metafile = file.with_suffix(".meta") @@ -181,10 +181,8 @@ def decrypt_aax(files, session): secho(f"file {outfile} already exists Skip.", fg="blue") continue - ab = session.auth.activation_bytes - cmd = ["ffmpeg", - "-activation_bytes", ab, + "-activation_bytes", activation_bytes, "-i", str(file), "-f", "ffmetadata", str(metafile)] @@ -196,7 +194,7 @@ def decrypt_aax(files, session): click.echo("Replaced all titles.") cmd = ["ffmpeg", - "-activation_bytes", ab, + "-activation_bytes", activation_bytes, "-i", str(file), "-i", str(metafile_new), "-map_metadata", "0", @@ -208,7 +206,7 @@ def decrypt_aax(files, session): metafile_new.unlink() -def decrypt_aaxc(files, session): +def decrypt_aaxc(files): for file in files: metafile = file.with_suffix(".meta") metafile_new = file.with_suffix(".new.meta") @@ -296,6 +294,6 @@ def cli(session, **options): else: secho(f"file suffix {file.suffix} not supported", fg="red") - decrypt_aaxc(jobs["aaxc"], session) - decrypt_aax(jobs["aax"], session) + decrypt_aaxc(jobs["aaxc"]) + decrypt_aax(jobs["aax"], session.auth.activation_bytes) diff --git a/src/audible_cli/_logging.py b/src/audible_cli/_logging.py index dcd5bcf..82cbf18 100644 --- a/src/audible_cli/_logging.py +++ b/src/audible_cli/_logging.py @@ -73,42 +73,6 @@ log_helper = AudibleCliLogHelper() # copied from https://github.com/Toilal/click-logging - -def click_verbosity_option(logger=None, *names, **kwargs): - """A decorator that adds a `--verbosity, -v` option to the decorated - command. - Name can be configured through ``*names``. Keyword arguments are passed to - the underlying ``click.option`` decorator. - """ - - if not names: - names = ["--verbosity", "-v"] - - kwargs.setdefault("default", "INFO") - kwargs.setdefault("metavar", "LVL") - kwargs.setdefault("expose_value", False) - kwargs.setdefault( - "help", "Either CRITICAL, ERROR, WARNING, " - "INFO or DEBUG. [default: INFO]" - ) - kwargs.setdefault("is_eager", True) - - logger = _normalize_logger(logger) - - def decorator(f): - def _set_level(ctx, param, value): - x = getattr(logging, value.upper(), None) - if x is None: - raise click.BadParameter( - f"Must be CRITICAL, ERROR, WARNING, INFO or DEBUG, " - f"not {value}" - ) - logger.setLevel(x) - - return click.option(*names, callback=_set_level, **kwargs)(f) - return decorator - - class ColorFormatter(logging.Formatter): def __init__(self, style_kwargs): self.style_kwargs = style_kwargs diff --git a/src/audible_cli/_version.py b/src/audible_cli/_version.py index 93751c9..935e289 100644 --- a/src/audible_cli/_version.py +++ b/src/audible_cli/_version.py @@ -1,7 +1,7 @@ __title__ = "audible-cli" __description__ = "Command line interface (cli) for the audible package." __url__ = "https://github.com/mkb79/audible-cli" -__version__ = "0.1.3" +__version__ = "0.2dev" __author__ = "mkb79" __author_email__ = "mkb79@hackitall.de" __license__ = "AGPL" diff --git a/src/audible_cli/cli.py b/src/audible_cli/cli.py index 7e1a265..5aef948 100644 --- a/src/audible_cli/cli.py +++ b/src/audible_cli/cli.py @@ -3,18 +3,19 @@ import sys from pkg_resources import iter_entry_points import click -import httpx -from packaging.version import parse from .cmds import build_in_cmds, cmd_quickstart -from .config import ( - get_plugin_dir, - add_param_to_session -) +from .config import get_plugin_dir from .constants import PLUGIN_ENTRY_POINT +from .decorators import ( + password_option, + profile_option, + verbosity_option, + version_option +) from .exceptions import AudibleCliException -from ._logging import click_basic_config, click_verbosity_option -from . import __version__, plugins +from ._logging import click_basic_config +from . import plugins logger = logging.getLogger("audible_cli") @@ -23,69 +24,14 @@ click_basic_config(logger) CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) -def version_option(**kwargs): - def callback(ctx, param, value): - if not value or ctx.resilient_parsing: - return - - message = f"audible-cli, version {__version__}" - click.echo(message, color=ctx.color, nl=False) - - url = "https://api.github.com/repos/mkb79/audible-cli/releases/latest" - headers = {"Accept": "application/vnd.github.v3+json"} - logger.debug(f"Requesting Github API for latest release information") - try: - response = httpx.get(url, headers=headers, follow_redirects=True) - response.raise_for_status() - except Exception as e: - logger.error(e) - click.Abort() - - content = response.json() - - current_version = parse(__version__) - latest_version = parse(content["tag_name"]) - - html_url = content["html_url"] - if latest_version > current_version: - click.echo( - f" (update available)\nVisit {html_url} " - f"for information about the new release.", - color=ctx.color - ) - else: - click.echo(" (up-to-date)", color=ctx.color) - - ctx.exit() - - kwargs.setdefault("is_flag", True) - kwargs.setdefault("expose_value", False) - kwargs.setdefault("is_eager", True) - kwargs.setdefault("help", "Show the version and exit.") - kwargs["callback"] = callback - return click.option("--version", **kwargs) - - @plugins.from_folder(get_plugin_dir()) @plugins.from_entry_point(iter_entry_points(PLUGIN_ENTRY_POINT)) @build_in_cmds() @click.group(context_settings=CONTEXT_SETTINGS) -@click.option( - "--profile", - "-P", - callback=add_param_to_session, - expose_value=False, - help="The profile to use instead primary profile (case sensitive!)." -) -@click.option( - "--password", - "-p", - callback=add_param_to_session, - expose_value=False, - help="The password for the profile auth file." -) +@profile_option() +@password_option() @version_option() -@click_verbosity_option(logger) +@verbosity_option(logger) def cli(): """Entrypoint for all other subcommands and groups.""" @@ -93,7 +39,7 @@ def cli(): @click.command(context_settings=CONTEXT_SETTINGS) @click.pass_context @version_option() -@click_verbosity_option(logger) +@verbosity_option(logger) def quickstart(ctx): """Entrypoint for the quickstart command""" try: diff --git a/src/audible_cli/cmds/cmd_activation_bytes.py b/src/audible_cli/cmds/cmd_activation_bytes.py index 24558c1..c03fd15 100644 --- a/src/audible_cli/cmds/cmd_activation_bytes.py +++ b/src/audible_cli/cmds/cmd_activation_bytes.py @@ -6,7 +6,7 @@ from audible.activation_bytes import ( fetch_activation_sign_auth ) -from ..config import pass_session +from ..decorators import pass_session logger = logging.getLogger("audible_cli.cmds.cmd_activation_bytes") diff --git a/src/audible_cli/cmds/cmd_api.py b/src/audible_cli/cmds/cmd_api.py index 054527d..ec4e138 100644 --- a/src/audible_cli/cmds/cmd_api.py +++ b/src/audible_cli/cmds/cmd_api.py @@ -6,7 +6,7 @@ import sys import click from audible import Client -from ..config import pass_session +from ..decorators import pass_session logger = logging.getLogger("audible_cli.cmds.cmd_api") diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index b07d272..61eb240 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -8,13 +8,17 @@ import logging import sys import aiofiles -import audible import click import httpx from click import echo from tabulate import tabulate -from ..config import pass_session +from ..decorators import ( + bunch_size_option, + run_async, + timeout_option, + pass_session +) from ..exceptions import DirectoryDoesNotExists, NotFoundError from ..models import Library from ..utils import Downloader @@ -408,11 +412,132 @@ def queue_job( ) -async def main(session, **params): - auth = session.auth - config = session.config - profile = session.selected_profile +def display_counter(): + if counter.has_downloads(): + echo("The download ended with the following result:") + for k, v in counter.as_dict().items(): + if v == 0: + continue + if k == "voucher_saved": + k = "voucher" + elif k == "voucher": + diff = v - counter.voucher_saved + if diff > 0: + echo(f"Unsaved voucher: {diff}") + continue + echo(f"New {k} files: {v}") + else: + echo("No new files downloaded.") + + +@click.command("download") +@click.option( + "--output-dir", "-o", + type=click.Path(exists=True, dir_okay=True), + default=pathlib.Path().cwd(), + help="output dir, uses current working dir as default" +) +@click.option( + "--all", + is_flag=True, + help="download all library items, overrides --asin and --title options" +) +@click.option( + "--asin", "-a", + multiple=True, + help="asin of the audiobook" +) +@click.option( + "--title", "-t", + multiple=True, + help="tile of the audiobook (partial search)" +) +@click.option( + "--aax", + is_flag=True, + help="Download book in aax format" +) +@click.option( + "--aaxc", + is_flag=True, + help="Download book in aaxc format incl. voucher file" +) +@click.option( + "--quality", "-q", + default="best", + show_default=True, + type=click.Choice(["best", "high", "normal"]), + help="download quality" +) +@click.option( + "--pdf", + is_flag=True, + help="downloads the pdf in addition to the audiobook" +) +@click.option( + "--cover", + is_flag=True, + help="downloads the cover in addition to the audiobook" +) +@click.option( + "--cover-size", + type=click.Choice(["252", "315", "360", "408", "500", "558", "570", "882", + "900", "1215"]), + default="500", + help="the cover pixel size" +) +@click.option( + "--chapter", + is_flag=True, + help="saves chapter metadata as JSON file" +) +@click.option( + "--no-confirm", "-y", + is_flag=True, + help="start without confirm" +) +@click.option( + "--overwrite", + is_flag=True, + help="rename existing files" +) +@click.option( + "--ignore-errors", + is_flag=True, + help="ignore errors and continue with the rest" +) +@click.option( + "--jobs", "-j", + type=int, + default=3, + show_default=True, + help="number of simultaneous downloads" +) +@click.option( + "--filename-mode", "-f", + type=click.Choice( + ["config", "ascii", "asin_ascii", "unicode", "asin_unicode"] + ), + default="config", + help="Filename mode to use. [default: config]" +) +@timeout_option() +@click.option( + "--resolve-podcasts", + is_flag=True, + help="Resolve podcasts to download a single episode via asin or title" +) +@click.option( + "--ignore-podcasts", + is_flag=True, + help="Ignore a podcast if it have episodes" +) +@bunch_size_option() +@pass_session +@run_async(display_counter) +async def cli(session, **params): + """download audiobook(s) from library""" output_dir = pathlib.Path(params.get("output_dir")).resolve() # which item(s) to download @@ -442,21 +567,17 @@ async def main(session, **params): no_confirm = params.get("no_confirm") resolve_podcats = params.get("resolve_podcasts") ignore_podcasts = params.get("ignore_podcasts") - bunch_size = params.get("bunch_size") - timeout = params.get("timeout") - if timeout == 0: - timeout = None + bunch_size = session.params.get("bunch_size") filename_mode = params.get("filename_mode") if filename_mode == "config": - filename_mode = config.get_profile_option(profile, "filename_mode") or \ - "ascii" + filename_mode = session.config.get_profile_option( + session.selected_profile, "filename_mode") or "ascii" headers = { "User-Agent": "Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0" } - api_client = audible.AsyncClient(auth, timeout=timeout) - api_client.session.headers.update(headers) + api_client = session.get_client(headers=headers) client = api_client.session async with api_client: @@ -567,150 +688,3 @@ async def main(session, **params): # the consumer is still awaiting an item, cancel it for consumer in consumers: consumer.cancel() - - -@click.command("download") -@click.option( - "--output-dir", "-o", - type=click.Path(exists=True, dir_okay=True), - default=pathlib.Path().cwd(), - help="output dir, uses current working dir as default" -) -@click.option( - "--all", - is_flag=True, - help="download all library items, overrides --asin and --title options" -) -@click.option( - "--asin", "-a", - multiple=True, - help="asin of the audiobook" -) -@click.option( - "--title", "-t", - multiple=True, - help="tile of the audiobook (partial search)" -) -@click.option( - "--aax", - is_flag=True, - help="Download book in aax format" -) -@click.option( - "--aaxc", - is_flag=True, - help="Download book in aaxc format incl. voucher file" -) -@click.option( - "--quality", "-q", - default="best", - show_default=True, - type=click.Choice(["best", "high", "normal"]), - help="download quality" -) -@click.option( - "--pdf", - is_flag=True, - help="downloads the pdf in addition to the audiobook" -) -@click.option( - "--cover", - is_flag=True, - help="downloads the cover in addition to the audiobook" -) -@click.option( - "--cover-size", - type=click.Choice(["252", "315", "360", "408", "500", "558", "570", "882", - "900", "1215"]), - default="500", - help="the cover pixel size" -) -@click.option( - "--chapter", - is_flag=True, - help="saves chapter metadata as JSON file" -) -@click.option( - "--no-confirm", "-y", - is_flag=True, - help="start without confirm" -) -@click.option( - "--overwrite", - is_flag=True, - help="rename existing files" -) -@click.option( - "--ignore-errors", - is_flag=True, - help="ignore errors and continue with the rest" -) -@click.option( - "--jobs", "-j", - type=int, - default=3, - show_default=True, - help="number of simultaneous downloads" -) -@click.option( - "--filename-mode", "-f", - type=click.Choice( - ["config", "ascii", "asin_ascii", "unicode", "asin_unicode"] - ), - default="config", - help="Filename mode to use. [default: config]" -) -@click.option( - "--timeout", - type=click.INT, - default=10, - show_default=True, - help="Increase the timeout time if you got any TimeoutErrors. " - "Set to 0 to disable timeout." -) -@click.option( - "--resolve-podcasts", - is_flag=True, - help="Resolve podcasts to download a single episode via asin or title" -) -@click.option( - "--ignore-podcasts", - is_flag=True, - help="Ignore a podcast if it have episodes" -) -@click.option( - "--bunch-size", - type=click.IntRange(10, 1000), - default=1000, - show_default=True, - help="How many library items should be requested per request. A lower " - "size results in more requests to get the full library. A higher " - "size can result in a TimeOutError on low internet connections." -) -@pass_session -def cli(session, **params): - """download audiobook(s) from library""" - loop = asyncio.get_event_loop() - ignore_httpx_ssl_eror(loop) - try: - loop.run_until_complete(main(session, **params)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - - if counter.has_downloads(): - echo("The download ended with the following result:") - for k, v in counter.as_dict().items(): - if v == 0: - continue - - if k == "voucher_saved": - k = "voucher" - elif k == "voucher": - diff = v - counter.voucher_saved - if diff > 0: - echo(f"Unsaved voucher: {diff}") - continue - echo(f"New {k} files: {v}") - else: - echo("No new files downloaded.") diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index d11f106..d72b73d 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -1,15 +1,18 @@ -import asyncio import csv import json import pathlib -from typing import Union -import audible import click from click import echo -from ..config import pass_session +from ..decorators import ( + bunch_size_option, + run_async, + timeout_option, + pass_session +) from ..models import Library +from ..utils import export_to_csv @click.group("library") @@ -17,14 +20,10 @@ def cli(): """interact with library""" -async def _get_library(auth, **params): - timeout = params.get("timeout") - if timeout == 0: - timeout = None +async def _get_library(session): + bunch_size = session.params.get("bunch_size") - bunch_size = params.get("bunch_size") - - async with audible.AsyncClient(auth, timeout=timeout) as client: + async with session.get_client() as client: library = await Library.from_api_full_sync( client, response_groups=( @@ -41,32 +40,6 @@ async def _get_library(auth, **params): return library -async def _list_library(auth, **params): - library = await _get_library(auth, **params) - - books = [] - - for item in library: - asin = item.asin - authors = ", ".join( - sorted(a["name"] for a in item.authors) if item.authors else "" - ) - series = ", ".join( - sorted(s["title"] for s in item.series) if item.series else "" - ) - title = item.title - books.append((asin, authors, series, title)) - - for asin, authors, series, title in sorted(books): - fields = [asin] - if authors: - fields.append(authors) - if series: - fields.append(series) - fields.append(title) - echo(": ".join(fields)) - - def _prepare_library_for_export(library: Library): keys_with_raw_values = ( "asin", "title", "subtitle", "runtime_length_min", "is_finished", @@ -112,28 +85,34 @@ def _prepare_library_for_export(library: Library): return prepared_library -def _export_to_csv( - file: pathlib.Path, - data: list, - headers: Union[list, tuple], - dialect: str -): - with file.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=headers, dialect=dialect) - writer.writeheader() - - for i in data: - writer.writerow(i) - - -async def _export_library(auth, **params): +@cli.command("export") +@click.option( + "--output", "-o", + type=click.Path(path_type=pathlib.Path), + default=pathlib.Path().cwd() / r"library.{format}", + show_default=True, + help="output file" +) +@timeout_option() +@click.option( + "--format", "-f", + type=click.Choice(["tsv", "csv", "json"]), + default="tsv", + show_default=True, + help="Output format" +) +@bunch_size_option() +@pass_session +@run_async() +async def export_library(session, **params): + """export library""" output_format = params.get("format") output_filename: pathlib.Path = params.get("output") if output_filename.suffix == r".{format}": suffix = "." + output_format output_filename = output_filename.with_suffix(suffix) - library = await _get_library(auth, **params) + library = await _get_library(session) prepared_library = _prepare_library_for_export(library) @@ -149,84 +128,40 @@ async def _export_library(auth, **params): dialect = "excel" else: dialect = "excel-tab" - _export_to_csv(output_filename, prepared_library, headers, dialect) + export_to_csv(output_filename, prepared_library, headers, dialect) if output_format == "json": data = json.dumps(prepared_library, indent=4) output_filename.write_text(data) -@cli.command("export") -@click.option( - "--output", "-o", - type=click.Path(path_type=pathlib.Path), - default=pathlib.Path().cwd() / r"library.{format}", - show_default=True, - help="output file" -) -@click.option( - "--timeout", "-t", - type=click.INT, - default=10, - show_default=True, - help=( - "Increase the timeout time if you got any TimeoutErrors. " - "Set to 0 to disable timeout." - ) -) -@click.option( - "--format", "-f", - type=click.Choice(["tsv", "csv", "json"]), - default="tsv", - show_default=True, - help="Output format" -) -@click.option( - "--bunch-size", - type=click.IntRange(10, 1000), - default=1000, - show_default=True, - help="How many library items should be requested per request. A lower " - "size results in more requests to get the full library. A higher " - "size can result in a TimeOutError on low internet connections." -) -@pass_session -def export_library(session, **params): - """export library""" - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(_export_library(session.auth, **params)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - - @cli.command("list") -@click.option( - "--timeout", "-t", - type=click.INT, - default=10, - show_default=True, - help=( - "Increase the timeout time if you got any TimeoutErrors. " - "Set to 0 to disable timeout." - ) -) -@click.option( - "--bunch-size", - type=click.IntRange(10, 1000), - default=1000, - show_default=True, - help="How many library items should be requested per request. A lower " - "size results in more requests to get the full library. A higher " - "size can result in a TimeOutError on low internet connections." -) +@timeout_option() +@bunch_size_option() @pass_session -def list_library(session, **params): +@run_async() +async def list_library(session): """list titles in library""" - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(_list_library(session.auth, **params)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() + library = await _get_library(session) + + books = [] + + for item in library: + asin = item.asin + authors = ", ".join( + sorted(a["name"] for a in item.authors) if item.authors else "" + ) + series = ", ".join( + sorted(s["title"] for s in item.series) if item.series else "" + ) + title = item.title + books.append((asin, authors, series, title)) + + for asin, authors, series, title in sorted(books): + fields = [asin] + if authors: + fields.append(authors) + if series: + fields.append(series) + fields.append(title) + echo(": ".join(fields)) diff --git a/src/audible_cli/cmds/cmd_manage.py b/src/audible_cli/cmds/cmd_manage.py index 079506d..7843469 100644 --- a/src/audible_cli/cmds/cmd_manage.py +++ b/src/audible_cli/cmds/cmd_manage.py @@ -6,7 +6,7 @@ from audible import Authenticator from click import echo, secho from tabulate import tabulate -from ..config import pass_session +from ..decorators import pass_session from ..utils import build_auth_file diff --git a/src/audible_cli/cmds/cmd_quickstart.py b/src/audible_cli/cmds/cmd_quickstart.py index c955d70..8376497 100644 --- a/src/audible_cli/cmds/cmd_quickstart.py +++ b/src/audible_cli/cmds/cmd_quickstart.py @@ -7,8 +7,9 @@ from click import echo, secho, prompt from tabulate import tabulate from .. import __version__ -from ..config import ConfigFile, pass_session +from ..config import ConfigFile from ..constants import CONFIG_FILE, DEFAULT_AUTH_FILE_EXTENSION +from ..decorators import pass_session from ..utils import build_auth_file diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index 87eb8bd..ff430a7 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -1,23 +1,17 @@ -import asyncio import csv import json import pathlib -from typing import Union -import audible import click from click import echo -from ..config import pass_session +from ..decorators import run_async, timeout_option, pass_session from ..models import Wishlist +from ..utils import export_to_csv -async def _get_wishlist(auth, **params): - timeout = params.get("timeout") - if timeout == 0: - timeout = None - - async with audible.AsyncClient(auth, timeout=timeout) as client: +async def _get_wishlist(session, **params): + async with session.get_client() as client: wishlist = await Wishlist.from_api( client, response_groups=( @@ -30,32 +24,6 @@ async def _get_wishlist(auth, **params): return wishlist -async def _list_wishlist(auth, **params): - wishlist = await _get_wishlist(auth, **params) - - books = [] - - for item in wishlist: - asin = item.asin - authors = ", ".join( - sorted(a["name"] for a in item.authors) if item.authors else "" - ) - series = ", ".join( - sorted(s["title"] for s in item.series) if item.series else "" - ) - title = item.title - books.append((asin, authors, series, title)) - - for asin, authors, series, title in sorted(books): - fields = [asin] - if authors: - fields.append(authors) - if series: - fields.append(series) - fields.append(title) - echo(": ".join(fields)) - - def _prepare_wishlist_for_export(wishlist: dict): keys_with_raw_values = ( "asin", "title", "subtitle", "runtime_length_min", "is_finished", @@ -101,28 +69,38 @@ def _prepare_wishlist_for_export(wishlist: dict): return prepared_wishlist -def _export_to_csv( - file: pathlib.Path, - data: list, - headers: Union[list, tuple], - dialect: str -): - with file.open("w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=headers, dialect=dialect) - writer.writeheader() - - for i in data: - writer.writerow(i) +@click.group("wishlist") +def cli(): + """interact with wishlist""" -async def _export_wishlist(auth, **params): +@cli.command("export") +@click.option( + "--output", "-o", + type=click.Path(), + default=pathlib.Path().cwd() / r"wishlist.{format}", + show_default=True, + help="output file" +) +@timeout_option() +@click.option( + "--format", "-f", + type=click.Choice(["tsv", "csv", "json"]), + default="tsv", + show_default=True, + help="Output format" +) +@pass_session +@run_async() +async def export_library(session, **params): + """export wishlist""" output_format = params.get("format") output_filename: pathlib.Path = params.get("output") if output_filename.suffix == r".{format}": suffix = "." + output_format output_filename = output_filename.with_suffix(suffix) - wishlist = await _get_wishlist(auth, **params) + wishlist = await _get_wishlist(session, **params) prepared_wishlist = _prepare_wishlist_for_export(wishlist) @@ -138,71 +116,39 @@ async def _export_wishlist(auth, **params): dialect = "excel" else: dialect = "excel-tab" - _export_to_csv(output_filename, prepared_wishlist, headers, dialect) + export_to_csv(output_filename, prepared_wishlist, headers, dialect) if output_format == "json": data = json.dumps(prepared_wishlist, indent=4) output_filename.write_text(data) -@click.group("wishlist") -def cli(): - """interact with wishlist""" - - -@cli.command("export") -@click.option( - "--output", "-o", - type=click.Path(), - default=pathlib.Path().cwd() / r"wishlist.{format}", - show_default=True, - help="output file" -) -@click.option( - "--timeout", "-t", - type=click.INT, - default=10, - show_default=True, - help=( - "Increase the timeout time if you got any TimeoutErrors. " - "Set to 0 to disable timeout." - ) -) -@click.option( - "--format", "-f", - type=click.Choice(["tsv", "csv", "json"]), - default="tsv", - show_default=True, - help="Output format" -) -@pass_session -def export_library(session, **params): - """export wishlist""" - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(_export_wishlist(session.auth, **params)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - - @cli.command("list") -@click.option( - "--timeout", "-t", - type=click.INT, - default=10, - show_default=True, - help=( - "Increase the timeout time if you got any TimeoutErrors. " - "Set to 0 to disable timeout." - ) -) +@timeout_option() @pass_session -def list_library(session, **params): +@run_async() +async def list_library(session, **params): """list titles in wishlist""" - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(_list_wishlist(session.auth, **params)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() + wishlist = await _get_wishlist(session, **params) + + books = [] + + for item in wishlist: + asin = item.asin + authors = ", ".join( + sorted(a["name"] for a in item.authors) if item.authors else "" + ) + series = ", ".join( + sorted(s["title"] for s in item.series) if item.series else "" + ) + title = item.title + books.append((asin, authors, series, title)) + + for asin, authors, series, title in sorted(books): + fields = [asin] + if authors: + fields.append(authors) + if series: + fields.append(series) + fields.append(title) + echo(": ".join(fields)) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 8b52fb9..fea9465 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Optional, Union import audible import click import toml -from audible import Authenticator +from audible import AsyncClient, Authenticator from audible.exceptions import FileEncryptionError from . import __version__ @@ -292,9 +292,12 @@ class Session: if profile in self._auths: return self._auths[profile] + if not self.config.has_profile(profile): + message = "Provided profile not found in config." + raise AudibleCliException(message) + auth_file = self.config.get_profile_option(profile, "auth_file") country_code = self.config.get_profile_option(profile, "country_code") - password = password or self.params.get("password") while True: try: @@ -315,7 +318,7 @@ class Session: raise click.Abort() click_f = click.format_filename(auth_file, shorten=True) - logger.debug(f"Auth file {click_f} loaded.") + logger.debug(f"Auth file {click_f} for profile {profile} loaded.") self._auths[profile] = auth return auth @@ -324,18 +327,23 @@ class Session: def auth(self): """Returns the Authenticator for the selected profile""" profile = self.selected_profile - if profile not in self._auths: - logger.debug(f"Selected profile: {profile}") + password = self.params.get("password") + return self.get_auth_for_profile(profile, password) - if not self.config.has_profile(profile): - message = "Provided profile not found in config." - raise AudibleCliException(message) + def get_client_for_profile( + self, + profile: str, + password: Optional[str] = None, + **kwargs + ) -> AsyncClient: + auth = self.get_auth_for_profile(profile, password) + kwargs.setdefault("timeout", self.params.get("timeout")) + return AsyncClient(auth=auth, **kwargs) - self._auth = self.get_auth_for_profile(profile) - return self._auths[profile] - - -pass_session = click.make_pass_decorator(Session, ensure=True) + def get_client(self, **kwargs) -> AsyncClient: + profile = self.selected_profile + password = self.params.get("password") + return self.get_client_for_profile(profile, password, **kwargs) def get_app_dir() -> pathlib.Path: @@ -348,13 +356,3 @@ def get_app_dir() -> pathlib.Path: def get_plugin_dir() -> pathlib.Path: plugin_dir = os.getenv(PLUGIN_DIR_ENV) or (get_app_dir() / PLUGIN_PATH) return pathlib.Path(plugin_dir).resolve() - - -def add_param_to_session(ctx: click.Context, param, value): - """Add a parameter to :class:`Session` `param` attribute - - This is usually used as a callback for a click option - """ - session = ctx.ensure_object(Session) - session.params[param.name] = value - return value diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py new file mode 100644 index 0000000..f46e347 --- /dev/null +++ b/src/audible_cli/decorators.py @@ -0,0 +1,164 @@ +import asyncio +import logging +from functools import wraps + +import click +import httpx +from packaging.version import parse + +from .config import Session +from ._logging import _normalize_logger +from . import __version__ + + +logger = logging.getLogger("audible_cli.options") + +pass_session = click.make_pass_decorator(Session, ensure=True) + + +def run_async(finally_func=None): + def coro(func): + @wraps(func) + def wrapper(*args, **kwargs): + loop = asyncio.get_event_loop() + try: + return loop.run_until_complete(func(*args, ** kwargs)) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + if finally_func is not None: + finally_func() + return wrapper + return coro + + +def add_param_to_session(ctx: click.Context, param, value): + """Add a parameter to :class:`Session` `param` attribute + + This is usually used as a callback for a click option + """ + session = ctx.ensure_object(Session) + session.params[param.name] = value + return value + + +def version_option(**kwargs): + def callback(ctx, param, value): + if not value or ctx.resilient_parsing: + return + + message = f"audible-cli, version {__version__}" + click.echo(message, color=ctx.color, nl=False) + + url = "https://api.github.com/repos/mkb79/audible-cli/releases/latest" + headers = {"Accept": "application/vnd.github.v3+json"} + logger.debug(f"Requesting Github API for latest release information") + try: + response = httpx.get(url, headers=headers, follow_redirects=True) + response.raise_for_status() + except Exception as e: + logger.error(e) + click.Abort() + + content = response.json() + + current_version = parse(__version__) + latest_version = parse(content["tag_name"]) + + html_url = content["html_url"] + if latest_version > current_version: + click.echo( + f" (update available)\nVisit {html_url} " + f"for information about the new release.", + color=ctx.color + ) + else: + click.echo(" (up-to-date)", color=ctx.color) + + ctx.exit() + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", "Show the version and exit.") + kwargs["callback"] = callback + return click.option("--version", **kwargs) + + +def profile_option(**kwargs): + kwargs.setdefault("callback", add_param_to_session) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("help", "The profile to use instead primary profile (case sensitive!).") + return click.option("--profile", "-P", **kwargs) + + +def password_option(**kwargs): + kwargs.setdefault("callback", add_param_to_session) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("help", "The password for the profile auth file.") + return click.option("--password", "-p", **kwargs) + + +def verbosity_option(logger=None, **kwargs): + """A decorator that adds a `--verbosity, -v` option to the decorated + command. + Keyword arguments are passed to + the underlying ``click.option`` decorator. + """ + def callback(ctx, param, value): + x = getattr(logging, value.upper(), None) + if x is None: + raise click.BadParameter( + f"Must be CRITICAL, ERROR, WARNING, INFO or DEBUG, " + f"not {value}" + ) + logger.setLevel(x) + + kwargs.setdefault("default", "INFO") + kwargs.setdefault("metavar", "LVL") + kwargs.setdefault("expose_value", False) + kwargs.setdefault( + "help", "Either CRITICAL, ERROR, WARNING, " + "INFO or DEBUG. [default: INFO]" + ) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("callback", callback) + + logger = _normalize_logger(logger) + + return click.option("--verbosity", "-v", **kwargs) + + +def timeout_option(**kwargs): + def callback(ctx: click.Context, param, value): + if value is 0: + value = None + session = ctx.ensure_object(Session) + session.params[param.name] = value + return value + + kwargs.setdefault("type", click.INT) + kwargs.setdefault("default", 10) + kwargs.setdefault("show_default", True) + kwargs.setdefault( + "help", ("Increase the timeout time if you got any TimeoutErrors. " + "Set to 0 to disable timeout.") + ) + kwargs.setdefault("callback", callback) + kwargs.setdefault("expose_value", False) + return click.option("--timeout", **kwargs) + + +def bunch_size_option(**kwargs): + kwargs.setdefault("type", click.IntRange(10, 1000)) + kwargs.setdefault("default", 1000) + kwargs.setdefault("show_default", True) + kwargs.setdefault( + "help", ("How many library items should be requested per request. A " + "lower size results in more requests to get the full library. " + "A higher size can result in a TimeOutError on low internet " + "connections.") + ) + kwargs.setdefault("callback", add_param_to_session) + kwargs.setdefault("expose_value", False) + return click.option("--bunch-size", **kwargs) diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index d503e56..4b49aa4 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -412,6 +412,7 @@ class Library(BaseList): if len_items < bunch_size: break request_params["page"] += 1 + print(request_params["page"]) resp._data = library return resp diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index e107ae6..43e2281 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -1,4 +1,5 @@ import asyncio +import csv import io import logging import pathlib @@ -300,3 +301,17 @@ class Downloader: await self._load() finally: self._remove_tmp_file() + + +def export_to_csv( + file: pathlib.Path, + data: list, + headers: Union[list, tuple], + dialect: str +): + with file.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=headers, dialect=dialect) + writer.writeheader() + + for i in data: + writer.writerow(i) From f0cd65af2ffef99094be16903f56d51c2eeb8b6f Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 20 Apr 2022 13:47:32 +0200 Subject: [PATCH 12/53] Add support for optional decorator parentheses --- src/audible_cli/cli.py | 14 +++--- src/audible_cli/cmds/__init__.py | 5 +- src/audible_cli/cmds/cmd_download.py | 6 +-- src/audible_cli/cmds/cmd_library.py | 12 ++--- src/audible_cli/cmds/cmd_wishlist.py | 8 ++-- src/audible_cli/decorators.py | 71 +++++++++++++++++++++------- 6 files changed, 79 insertions(+), 37 deletions(-) diff --git a/src/audible_cli/cli.py b/src/audible_cli/cli.py index 5aef948..dd935a6 100644 --- a/src/audible_cli/cli.py +++ b/src/audible_cli/cli.py @@ -26,20 +26,20 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @plugins.from_folder(get_plugin_dir()) @plugins.from_entry_point(iter_entry_points(PLUGIN_ENTRY_POINT)) -@build_in_cmds() +@build_in_cmds @click.group(context_settings=CONTEXT_SETTINGS) -@profile_option() -@password_option() -@version_option() -@verbosity_option(logger) +@profile_option +@password_option +@version_option +@verbosity_option(logger=logger) def cli(): """Entrypoint for all other subcommands and groups.""" @click.command(context_settings=CONTEXT_SETTINGS) @click.pass_context -@version_option() -@verbosity_option(logger) +@version_option +@verbosity_option(logger=logger) def quickstart(ctx): """Entrypoint for the quickstart command""" try: diff --git a/src/audible_cli/cmds/__init__.py b/src/audible_cli/cmds/__init__.py index f212520..6123aef 100644 --- a/src/audible_cli/cmds/__init__.py +++ b/src/audible_cli/cmds/__init__.py @@ -21,7 +21,7 @@ cli_cmds = [ ] -def build_in_cmds(): +def build_in_cmds(func=None): """ A decorator to register build-in CLI commands to an instance of `click.Group()`. @@ -42,4 +42,7 @@ def build_in_cmds(): return group + if callable(func): + return decorator(func) + return decorator diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 61eb240..ec6bfbf 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -522,7 +522,7 @@ def display_counter(): default="config", help="Filename mode to use. [default: config]" ) -@timeout_option() +@timeout_option @click.option( "--resolve-podcasts", is_flag=True, @@ -533,9 +533,9 @@ def display_counter(): is_flag=True, help="Ignore a podcast if it have episodes" ) -@bunch_size_option() +@bunch_size_option @pass_session -@run_async(display_counter) +@run_async(finally_func=display_counter) async def cli(session, **params): """download audiobook(s) from library""" output_dir = pathlib.Path(params.get("output_dir")).resolve() diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index d72b73d..36da6fc 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -93,7 +93,7 @@ def _prepare_library_for_export(library: Library): show_default=True, help="output file" ) -@timeout_option() +@timeout_option @click.option( "--format", "-f", type=click.Choice(["tsv", "csv", "json"]), @@ -101,9 +101,9 @@ def _prepare_library_for_export(library: Library): show_default=True, help="Output format" ) -@bunch_size_option() +@bunch_size_option @pass_session -@run_async() +@run_async async def export_library(session, **params): """export library""" output_format = params.get("format") @@ -136,10 +136,10 @@ async def export_library(session, **params): @cli.command("list") -@timeout_option() -@bunch_size_option() +@timeout_option +@bunch_size_option @pass_session -@run_async() +@run_async async def list_library(session): """list titles in library""" library = await _get_library(session) diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index ff430a7..7bd6f96 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -82,7 +82,7 @@ def cli(): show_default=True, help="output file" ) -@timeout_option() +@timeout_option @click.option( "--format", "-f", type=click.Choice(["tsv", "csv", "json"]), @@ -91,7 +91,7 @@ def cli(): help="Output format" ) @pass_session -@run_async() +@run_async async def export_library(session, **params): """export wishlist""" output_format = params.get("format") @@ -124,9 +124,9 @@ async def export_library(session, **params): @cli.command("list") -@timeout_option() +@timeout_option @pass_session -@run_async() +@run_async async def list_library(session, **params): """list titles in wishlist""" wishlist = await _get_wishlist(session, **params) diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index f46e347..8ab60e7 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -16,19 +16,23 @@ logger = logging.getLogger("audible_cli.options") pass_session = click.make_pass_decorator(Session, ensure=True) -def run_async(finally_func=None): - def coro(func): - @wraps(func) +def run_async(func=None, *, finally_func=None): + def coro(f): + @wraps(f) def wrapper(*args, **kwargs): loop = asyncio.get_event_loop() try: - return loop.run_until_complete(func(*args, ** kwargs)) + return loop.run_until_complete(f(*args, ** kwargs)) finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() if finally_func is not None: finally_func() return wrapper + + if callable(func): + return coro(func) + return coro @@ -42,7 +46,7 @@ def add_param_to_session(ctx: click.Context, param, value): return value -def version_option(**kwargs): +def version_option(func=None, **kwargs): def callback(ctx, param, value): if not value or ctx.resilient_parsing: return @@ -82,24 +86,42 @@ def version_option(**kwargs): kwargs.setdefault("is_eager", True) kwargs.setdefault("help", "Show the version and exit.") kwargs["callback"] = callback - return click.option("--version", **kwargs) + + option = click.option("--version", **kwargs) + + if callable(func): + return option(func) + + return option -def profile_option(**kwargs): +def profile_option(func=None, **kwargs): kwargs.setdefault("callback", add_param_to_session) kwargs.setdefault("expose_value", False) kwargs.setdefault("help", "The profile to use instead primary profile (case sensitive!).") - return click.option("--profile", "-P", **kwargs) + + option = click.option("--profile", "-P", **kwargs) + + if callable(func): + return option(func) + + return option -def password_option(**kwargs): +def password_option(func=None, **kwargs): kwargs.setdefault("callback", add_param_to_session) kwargs.setdefault("expose_value", False) kwargs.setdefault("help", "The password for the profile auth file.") - return click.option("--password", "-p", **kwargs) + + option = click.option("--password", "-p", **kwargs) + + if callable(func): + return option(func) + + return option -def verbosity_option(logger=None, **kwargs): +def verbosity_option(func=None, *, logger=None, **kwargs): """A decorator that adds a `--verbosity, -v` option to the decorated command. Keyword arguments are passed to @@ -126,10 +148,15 @@ def verbosity_option(logger=None, **kwargs): logger = _normalize_logger(logger) - return click.option("--verbosity", "-v", **kwargs) + option = click.option("--verbosity", "-v", **kwargs) + + if callable(func): + return option(func) + + return option -def timeout_option(**kwargs): +def timeout_option(func=None, **kwargs): def callback(ctx: click.Context, param, value): if value is 0: value = None @@ -146,10 +173,16 @@ def timeout_option(**kwargs): ) kwargs.setdefault("callback", callback) kwargs.setdefault("expose_value", False) - return click.option("--timeout", **kwargs) + + option = click.option("--timeout", **kwargs) + + if callable(func): + return option(func) + + return option -def bunch_size_option(**kwargs): +def bunch_size_option(func=None, **kwargs): kwargs.setdefault("type", click.IntRange(10, 1000)) kwargs.setdefault("default", 1000) kwargs.setdefault("show_default", True) @@ -161,4 +194,10 @@ def bunch_size_option(**kwargs): ) kwargs.setdefault("callback", add_param_to_session) kwargs.setdefault("expose_value", False) - return click.option("--bunch-size", **kwargs) + + option = click.option("--bunch-size", **kwargs) + + if callable(func): + return option(func) + + return option From 0998eb773d308e34fd3c4a7dc98ef243190c3bf8 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 20 Apr 2022 15:09:58 +0200 Subject: [PATCH 13/53] fix a bug with partially asins --- src/audible_cli/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index 4b49aa4..6d0f7f4 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -338,7 +338,7 @@ class BaseList: def get_item_by_asin(self, asin): try: - return next(i for i in self._data if asin in i.asin) + return next(i for i in self._data if asin == i.asin) except StopIteration: return None From f7562246a5839352f2210557be48ebddfd4b0fd9 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 20 Apr 2022 15:10:30 +0200 Subject: [PATCH 14/53] add utils.full_response_callback --- src/audible_cli/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index 43e2281..e925db6 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -13,6 +13,7 @@ import httpx import tqdm from PIL import Image from audible import Authenticator +from audible.client import raise_for_status from audible.login import default_login_url_callback from click import echo, secho, prompt @@ -61,6 +62,11 @@ def prompt_external_callback(url: str) -> str: return default_login_url_callback(url) +def full_response_callback(resp: httpx.Response): + raise_for_status(resp) + return resp + + def build_auth_file( filename: Union[str, pathlib.Path], username: Optional[str], From eaaf68e4d3c3e7284aa9590ee8f9493e8284df79 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 20 Apr 2022 15:11:19 +0200 Subject: [PATCH 15/53] bump audible to at least 0.8.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2bcdf2a..90ac690 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ setup( ], install_requires=[ "aiofiles", - "audible==0.8.0", + "audible>=0.8.1", "click>=8", "colorama; platform_system=='Windows'", "httpx>=0.20.0,<0.23.0", From 8e5f4a7a526195feaa308006f9d1fa10fcbae973 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 20 Apr 2022 15:14:37 +0200 Subject: [PATCH 16/53] rework cmd_wishlist.py - add `add` and `remove` subcommand to wishlist --- src/audible_cli/cmds/cmd_wishlist.py | 74 ++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index 7bd6f96..1fdd82d 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -1,5 +1,7 @@ +import asyncio import csv import json +import logging import pathlib import click @@ -10,7 +12,10 @@ from ..models import Wishlist from ..utils import export_to_csv -async def _get_wishlist(session, **params): +logger = logging.getLogger("audible_cli.cmds.cmd_wishlist") + + +async def _get_wishlist(session): async with session.get_client() as client: wishlist = await Wishlist.from_api( client, @@ -92,7 +97,7 @@ def cli(): ) @pass_session @run_async -async def export_library(session, **params): +async def export_wishlist(session, **params): """export wishlist""" output_format = params.get("format") output_filename: pathlib.Path = params.get("output") @@ -100,7 +105,7 @@ async def export_library(session, **params): suffix = "." + output_format output_filename = output_filename.with_suffix(suffix) - wishlist = await _get_wishlist(session, **params) + wishlist = await _get_wishlist(session) prepared_wishlist = _prepare_wishlist_for_export(wishlist) @@ -127,9 +132,9 @@ async def export_library(session, **params): @timeout_option @pass_session @run_async -async def list_library(session, **params): +async def list_wishlist(session, **params): """list titles in wishlist""" - wishlist = await _get_wishlist(session, **params) + wishlist = await _get_wishlist(session) books = [] @@ -152,3 +157,62 @@ async def list_library(session, **params): fields.append(series) fields.append(title) echo(": ".join(fields)) + + +@cli.command("add") +@click.option( + "--asin", "-a", + multiple=True, + help="asin of the audiobook" +) +@timeout_option +@pass_session +@run_async +async def add_wishlist(session, asin): + """add asin(s) to wishlist""" + + async def add_asin(asin): + body = {"asin": asin} + r = await client.post("wishlist", body=body) + return r + + async with session.get_client() as client: + jobs = [add_asin(a) for a in asin] + await asyncio.gather(*jobs) + + wishlist = await _get_wishlist(session) + for a in asin: + if not wishlist.has_asin(a): + logger.error(f"{a} was not added to wishlist") + else: + item = wishlist.get_item_by_asin(a) + logger.info(f"{a} ({item.full_title}) added to wishlist") + +@cli.command("remove") +@click.option( + "--asin", "-a", + multiple=True, + help="asin of the audiobook" +) +@timeout_option +@pass_session +@run_async +async def remove_wishlist(session, asin): + """remove asin(s) from wishlist""" + + async def remove_asin(rasin): + r = await client.delete(f"wishlist/{rasin}") + item = wishlist.get_item_by_asin(rasin) + logger.info(f"{rasin} ({item.full_title}) removed from wishlist") + return r + + jobs = [] + wishlist = await _get_wishlist(session) + for a in asin: + if not wishlist.has_asin(a): + logger.error(f"{a} not in wishlist") + else: + jobs.append(remove_asin(a)) + + async with session.get_client() as client: + await asyncio.gather(*jobs) From 72c45f52258f38bc947edda2924d14fa7dbff57f Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 20 Apr 2022 15:33:36 +0200 Subject: [PATCH 17/53] set default timeout to 5s when using `get_client` --- src/audible_cli/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index fea9465..8e249bb 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -337,7 +337,7 @@ class Session: **kwargs ) -> AsyncClient: auth = self.get_auth_for_profile(profile, password) - kwargs.setdefault("timeout", self.params.get("timeout")) + kwargs.setdefault("timeout", self.params.get("timeout", 5)) return AsyncClient(auth=auth, **kwargs) def get_client(self, **kwargs) -> AsyncClient: From eabd0f7a4346fb1e3774daeb53d01bddee5845fc Mon Sep 17 00:00:00 2001 From: mkb79 Date: Thu, 21 Apr 2022 12:43:52 +0200 Subject: [PATCH 18/53] add `questionary` to dependiencies --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 90ac690..fd7811f 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,8 @@ setup( "Pillow", "tabulate", "toml", - "tqdm" + "tqdm", + "questionary" ], extras_require={ 'pyi': [ From 47ba6b7dd8766b16644cb103e31561987772feaf Mon Sep 17 00:00:00 2001 From: mkb79 Date: Thu, 21 Apr 2022 12:44:15 +0200 Subject: [PATCH 19/53] bump dev version --- src/audible_cli/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/_version.py b/src/audible_cli/_version.py index 935e289..7c9c8da 100644 --- a/src/audible_cli/_version.py +++ b/src/audible_cli/_version.py @@ -1,7 +1,7 @@ __title__ = "audible-cli" __description__ = "Command line interface (cli) for the audible package." __url__ = "https://github.com/mkb79/audible-cli" -__version__ = "0.2dev" +__version__ = "0.2.dev1" __author__ = "mkb79" __author_email__ = "mkb79@hackitall.de" __license__ = "AGPL" From ee70469cacda017e9752388c676b0064edd20c39 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Thu, 21 Apr 2022 15:34:08 +0200 Subject: [PATCH 20/53] update cmd_wishlist.py --- src/audible_cli/cmds/cmd_wishlist.py | 142 +++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 17 deletions(-) diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index 1fdd82d..38c7783 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -5,15 +5,21 @@ import logging import pathlib import click +import httpx +import questionary from click import echo from ..decorators import run_async, timeout_option, pass_session -from ..models import Wishlist +from ..models import Catalog, Wishlist from ..utils import export_to_csv logger = logging.getLogger("audible_cli.cmds.cmd_wishlist") +# audible api raises a 500 status error when to many requests +# where made to wishlist endpoint in short time +limits = httpx.Limits(max_keepalive_connections=1, max_connections=1) + async def _get_wishlist(session): async with session.get_client() as client: @@ -165,28 +171,85 @@ async def list_wishlist(session, **params): multiple=True, help="asin of the audiobook" ) +@click.option( + "--title", "-t", + multiple=True, + help="tile of the audiobook (partial search)" +) @timeout_option @pass_session @run_async -async def add_wishlist(session, asin): - """add asin(s) to wishlist""" +async def add_wishlist(session, asin, title): + """add asin(s) to wishlist + + Run the command without any option for interactive mode. + """ async def add_asin(asin): body = {"asin": asin} r = await client.post("wishlist", body=body) return r - async with session.get_client() as client: + asin = list(asin) + title = list(title) + + if not asin and not title: + q = await questionary.select( + "Do you want to add an item by asin or title?", + choices=[ + questionary.Choice(title="by title", value="title"), + questionary.Choice(title="by asin", value="asin") + ] + ).unsafe_ask_async() + + if q == 'asin': + q = await questionary.text("Please enter the asin").unsafe_ask_async() + asin.append(q) + else: + q = await questionary.text("Please enter the title").unsafe_ask_async() + title.append(q) + + for t in title: + async with session.get_client() as client: + catalog = await Catalog.from_api( + client, + title=t, + num_results=50 + ) + + match = catalog.search_item_by_title(t) + full_match = [i for i in match if i[1] == 100] + + if match: + choices = [] + for i in full_match or match: + c = questionary.Choice(title=i[0].full_title, value=i[0].asin) + choices.append(c) + + answer = await questionary.checkbox( + f"Found the following matches for '{t}'. Which you want to add?", + choices=choices + ).unsafe_ask_async() + + if answer is not None: + [asin.append(i) for i in answer] + else: + logger.error( + f"Skip title {t}: Not found in library" + ) + + async with session.get_client(limits=limits) as client: jobs = [add_asin(a) for a in asin] await asyncio.gather(*jobs) wishlist = await _get_wishlist(session) for a in asin: - if not wishlist.has_asin(a): - logger.error(f"{a} was not added to wishlist") - else: + if wishlist.has_asin(a): item = wishlist.get_item_by_asin(a) logger.info(f"{a} ({item.full_title}) added to wishlist") + else: + logger.error(f"{a} was not added to wishlist") + @cli.command("remove") @click.option( @@ -194,11 +257,19 @@ async def add_wishlist(session, asin): multiple=True, help="asin of the audiobook" ) +@click.option( + "--title", "-t", + multiple=True, + help="tile of the audiobook (partial search)" +) @timeout_option @pass_session @run_async -async def remove_wishlist(session, asin): - """remove asin(s) from wishlist""" +async def remove_wishlist(session, asin, title): + """remove asin(s) from wishlist + + Run the command without any option for interactive mode. + """ async def remove_asin(rasin): r = await client.delete(f"wishlist/{rasin}") @@ -206,13 +277,50 @@ async def remove_wishlist(session, asin): logger.info(f"{rasin} ({item.full_title}) removed from wishlist") return r - jobs = [] + asin = list(asin) wishlist = await _get_wishlist(session) - for a in asin: - if not wishlist.has_asin(a): - logger.error(f"{a} not in wishlist") - else: - jobs.append(remove_asin(a)) - async with session.get_client() as client: - await asyncio.gather(*jobs) + if not asin and not title: + # interactive mode + choices = [] + for i in wishlist: + c = questionary.Choice(title=i.full_title, value=i.asin) + choices.append(c) + + asin = await questionary.checkbox( + "Select item(s) which you want to remove from whishlist", + choices=choices + ).unsafe_ask_async() + + for t in title: + match = wishlist.search_item_by_title(t) + full_match = [i for i in match if i[1] == 100] + + if match: + choices = [] + for i in full_match or match: + c = questionary.Choice(title=i[0].full_title, value=i[0].asin) + choices.append(c) + + answer = await questionary.checkbox( + f"Found the following matches for '{t}'. Which you want to remove?", + choices=choices + ).unsafe_ask_async() + + if answer is not None: + [asin.append(i) for i in answer] + else: + logger.error( + f"Skip title {t}: Not found in library" + ) + + if asin: + jobs = [] + for a in asin: + if wishlist.has_asin(a): + jobs.append(remove_asin(a)) + else: + logger.error(f"{a} not in wishlist") + + async with session.get_client(limits=limits) as client: + await asyncio.gather(*jobs) From 8a6f3edcb8a650df5eb2b71340a9d2bb36e18396 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 22 Apr 2022 14:46:23 +0200 Subject: [PATCH 21/53] rework `run_async` and add `pass_client` in decorators.py --- src/audible_cli/decorators.py | 38 ++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index 8ab60e7..985c9f2 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -16,18 +16,38 @@ logger = logging.getLogger("audible_cli.options") pass_session = click.make_pass_decorator(Session, ensure=True) -def run_async(func=None, *, finally_func=None): +def run_async(func=None): def coro(f): @wraps(f) def wrapper(*args, **kwargs): - loop = asyncio.get_event_loop() - try: - return loop.run_until_complete(f(*args, ** kwargs)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - if finally_func is not None: - finally_func() + if hasattr(asyncio, "run"): + logger.debug("Using asyncio.run ...") + return asyncio.run(f(*args, ** kwargs)) + else: + logger.debug("Using asyncio.run_until_complete ...") + loop = asyncio.get_event_loop() + try: + return loop.run_until_complete(f(*args, ** kwargs)) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + return wrapper + + if callable(func): + return coro(func) + + return coro + + +def pass_client(func=None, **client_kwargs): + def coro(f): + @wraps(f) + @pass_session + @run_async + async def wrapper(session, *args, **kwargs): + client = session.get_client(**client_kwargs) + async with client.session: + return await f(*args, client, **kwargs) return wrapper if callable(func): From b6993ecce8257b499b2d6925d7f2316cd2546933 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 22 Apr 2022 14:49:44 +0200 Subject: [PATCH 22/53] rework cmd_wishlist.py --- src/audible_cli/cmds/cmd_wishlist.py | 70 ++++++++++++---------------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index 38c7783..b65ac88 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -9,7 +9,7 @@ import httpx import questionary from click import echo -from ..decorators import run_async, timeout_option, pass_session +from ..decorators import timeout_option, pass_client from ..models import Catalog, Wishlist from ..utils import export_to_csv @@ -21,17 +21,16 @@ logger = logging.getLogger("audible_cli.cmds.cmd_wishlist") limits = httpx.Limits(max_keepalive_connections=1, max_connections=1) -async def _get_wishlist(session): - async with session.get_client() as client: - wishlist = await Wishlist.from_api( - client, - response_groups=( - "contributors, media, price, product_attrs, product_desc, " - "product_extended_attrs, product_plan_details, product_plans, " - "rating, sample, sku, series, reviews, review_attrs, ws4v, " - "customer_rights, categories, category_ladders, claim_code_url" - ) +async def _get_wishlist(client): + wishlist = await Wishlist.from_api( + client, + response_groups=( + "contributors, media, price, product_attrs, product_desc, " + "product_extended_attrs, product_plan_details, product_plans, " + "rating, sample, sku, series, reviews, review_attrs, ws4v, " + "customer_rights, categories, category_ladders, claim_code_url" ) + ) return wishlist @@ -101,9 +100,8 @@ def cli(): show_default=True, help="Output format" ) -@pass_session -@run_async -async def export_wishlist(session, **params): +@pass_client +async def export_wishlist(client, **params): """export wishlist""" output_format = params.get("format") output_filename: pathlib.Path = params.get("output") @@ -111,7 +109,7 @@ async def export_wishlist(session, **params): suffix = "." + output_format output_filename = output_filename.with_suffix(suffix) - wishlist = await _get_wishlist(session) + wishlist = await _get_wishlist(client) prepared_wishlist = _prepare_wishlist_for_export(wishlist) @@ -136,11 +134,10 @@ async def export_wishlist(session, **params): @cli.command("list") @timeout_option -@pass_session -@run_async -async def list_wishlist(session, **params): +@pass_client +async def list_wishlist(client): """list titles in wishlist""" - wishlist = await _get_wishlist(session) + wishlist = await _get_wishlist(client) books = [] @@ -177,9 +174,8 @@ async def list_wishlist(session, **params): help="tile of the audiobook (partial search)" ) @timeout_option -@pass_session -@run_async -async def add_wishlist(session, asin, title): +@pass_client(limits=limits) +async def add_wishlist(client, asin, title): """add asin(s) to wishlist Run the command without any option for interactive mode. @@ -210,12 +206,11 @@ async def add_wishlist(session, asin, title): title.append(q) for t in title: - async with session.get_client() as client: - catalog = await Catalog.from_api( - client, - title=t, - num_results=50 - ) + catalog = await Catalog.from_api( + client, + title=t, + num_results=50 + ) match = catalog.search_item_by_title(t) full_match = [i for i in match if i[1] == 100] @@ -238,11 +233,10 @@ async def add_wishlist(session, asin, title): f"Skip title {t}: Not found in library" ) - async with session.get_client(limits=limits) as client: - jobs = [add_asin(a) for a in asin] - await asyncio.gather(*jobs) + jobs = [add_asin(a) for a in asin] + await asyncio.gather(*jobs) - wishlist = await _get_wishlist(session) + wishlist = await _get_wishlist(client) for a in asin: if wishlist.has_asin(a): item = wishlist.get_item_by_asin(a) @@ -263,9 +257,8 @@ async def add_wishlist(session, asin, title): help="tile of the audiobook (partial search)" ) @timeout_option -@pass_session -@run_async -async def remove_wishlist(session, asin, title): +@pass_client(limits=limits) +async def remove_wishlist(client, asin, title): """remove asin(s) from wishlist Run the command without any option for interactive mode. @@ -278,7 +271,7 @@ async def remove_wishlist(session, asin, title): return r asin = list(asin) - wishlist = await _get_wishlist(session) + wishlist = await _get_wishlist(client) if not asin and not title: # interactive mode @@ -321,6 +314,5 @@ async def remove_wishlist(session, asin, title): jobs.append(remove_asin(a)) else: logger.error(f"{a} not in wishlist") - - async with session.get_client(limits=limits) as client: - await asyncio.gather(*jobs) + + await asyncio.gather(*jobs) From 75f832c82109e7694e994507f0b4fd60812ee097 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 22 Apr 2022 14:52:48 +0200 Subject: [PATCH 23/53] rework cmd_download.py - when using option `--title/-t` now a checkbox appears where one or more items can be selected; using `no-confirm` to ignore these checkbox and select all found matches --- src/audible_cli/cmds/cmd_download.py | 194 ++++++++++++++------------- 1 file changed, 98 insertions(+), 96 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index ec6bfbf..14b0f99 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -10,13 +10,13 @@ import sys import aiofiles import click import httpx +import questionary from click import echo -from tabulate import tabulate from ..decorators import ( bunch_size_option, - run_async, timeout_option, + pass_client, pass_session ) from ..exceptions import DirectoryDoesNotExists, NotFoundError @@ -28,6 +28,10 @@ logger = logging.getLogger("audible_cli.cmds.cmd_download") SSL_PROTOCOLS = (asyncio.sslproto.SSLProtocol,) +CLIENT_HEADERS = { + "User-Agent": "Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0" +} + def ignore_httpx_ssl_eror(loop): """Ignore aiohttp #3535 / cpython #13548 issue with SSL data after close @@ -333,7 +337,9 @@ async def consume(queue): await item except Exception as e: logger.error(e) - queue.task_done() + raise + finally: + queue.task_done() def queue_job( @@ -535,9 +541,10 @@ def display_counter(): ) @bunch_size_option @pass_session -@run_async(finally_func=display_counter) -async def cli(session, **params): +@pass_client(headers=CLIENT_HEADERS) +async def cli(session, api_client, **params): """download audiobook(s) from library""" + client = api_client.session output_dir = pathlib.Path(params.get("output_dir")).resolve() # which item(s) to download @@ -574,117 +581,112 @@ async def cli(session, **params): filename_mode = session.config.get_profile_option( session.selected_profile, "filename_mode") or "ascii" - headers = { - "User-Agent": "Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0" - } - api_client = session.get_client(headers=headers) - client = api_client.session + # fetch the user library + library = await Library.from_api_full_sync( + api_client, + image_sizes="1215, 408, 360, 882, 315, 570, 252, 558, 900, 500", + bunch_size=bunch_size + ) - async with api_client: - # fetch the user library - library = await Library.from_api_full_sync( - api_client, - image_sizes="1215, 408, 360, 882, 315, 570, 252, 558, 900, 500", - bunch_size=bunch_size - ) + if resolve_podcats: + await library.resolve_podcats() - if resolve_podcats: - await library.resolve_podcats() + # collect jobs + jobs = [] - # collect jobs - jobs = [] + if get_all: + asins = [] + titles = [] + for i in library: + jobs.append(i.asin) - if get_all: - asins = [] - titles = [] - for i in library: - jobs.append(i.asin) + for asin in asins: + if library.has_asin(asin): + jobs.append(asin) + else: + if not ignore_errors: + logger.error(f"Asin {asin} not found in library.") + click.Abort() + logger.error( + f"Skip asin {asin}: Not found in library" + ) - for asin in asins: - if library.has_asin(asin): - jobs.append(asin) + for title in titles: + match = library.search_item_by_title(title) + full_match = [i for i in match if i[1] == 100] + + if match: + if no_confirm: + [jobs.append(i[0].asin) for i in full_match or match] else: - if not ignore_errors: - logger.error(f"Asin {asin} not found in library.") - click.Abort() - logger.error( - f"Skip asin {asin}: Not found in library" - ) + choices = [] + for i in full_match or match: + a = i[0].asin + t = i[0].full_title + c = questionary.Choice(title=f"{a} # {t}", value=a) + choices.append(c) - for title in titles: - match = library.search_item_by_title(title) - full_match = [i for i in match if i[1] == 100] - - if match: - echo(f"\nFound the following matches for '{title}'") + answer = await questionary.checkbox( + f"Found the following matches for '{title}'. Which you want to download?", + choices=choices + ).unsafe_ask_async() + if answer is not None: + [jobs.append(i) for i in answer] + + else: + logger.error( + f"Skip title {title}: Not found in library" + ) - table_data = [] - for count, i in enumerate(full_match or match, start=1): - table_data.append( - [count, i[1], i[0].full_title, i[0].asin] - ) - head = ["#", "% match", "title", "asin"] - table = tabulate( - table_data, head, tablefmt="pretty", - colalign=("center", "center", "left", "center")) - echo(table) - - if no_confirm or click.confirm( - "Proceed with this audiobook(s)", - default=True - ): - jobs.extend([i[0].asin for i in full_match or match]) - - else: - logger.error( - f"Skip title {title}: Not found in library" - ) + queue = asyncio.Queue() + for job in jobs: + item = library.get_item_by_asin(job) + items = [item] + odir = pathlib.Path(output_dir) - queue = asyncio.Queue() - for job in jobs: - item = library.get_item_by_asin(job) - items = [item] - odir = pathlib.Path(output_dir) + if not ignore_podcasts and item.is_parent_podcast(): + items.remove(item) + if item._children is None: + await item.get_child_items() - if not ignore_podcasts and item.is_parent_podcast(): - items.remove(item) - if item._children is None: - await item.get_child_items() + for i in item._children: + if i.asin not in jobs: + items.append(i) - for i in item._children: - if i.asin not in jobs: - items.append(i) + podcast_dir = item.create_base_filename(filename_mode) + odir = output_dir / podcast_dir + if not odir.is_dir(): + odir.mkdir(parents=True) - podcast_dir = item.create_base_filename(filename_mode) - odir = output_dir / podcast_dir - if not odir.is_dir(): - odir.mkdir(parents=True) - - for item in items: - queue_job( - queue=queue, - get_cover=get_cover, - get_pdf=get_pdf, - get_chapters=get_chapters, - get_aax=get_aax, - get_aaxc=get_aaxc, - client=client, - output_dir=odir, - filename_mode=filename_mode, - item=item, - cover_size=cover_size, - quality=quality, - overwrite_existing=overwrite_existing - ) + for item in items: + queue_job( + queue=queue, + get_cover=get_cover, + get_pdf=get_pdf, + get_chapters=get_chapters, + get_aax=get_aax, + get_aaxc=get_aaxc, + client=client, + output_dir=odir, + filename_mode=filename_mode, + item=item, + cover_size=cover_size, + quality=quality, + overwrite_existing=overwrite_existing + ) + try: # schedule the consumer consumers = [ asyncio.ensure_future(consume(queue)) for _ in range(sim_jobs) ] - # wait until the consumer has processed all items await queue.join() + finally: # the consumer is still awaiting an item, cancel it for consumer in consumers: consumer.cancel() + + await asyncio.gather(*consumers, return_exceptions=True) + display_counter() From ec09d0582565a084a5fb24d5ba4b382af415b5b4 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 22 Apr 2022 22:33:42 +0200 Subject: [PATCH 24/53] fix SyntaxWarning in decorators.py --- src/audible_cli/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index 985c9f2..d3c173e 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -178,7 +178,7 @@ def verbosity_option(func=None, *, logger=None, **kwargs): def timeout_option(func=None, **kwargs): def callback(ctx: click.Context, param, value): - if value is 0: + if value == 0: value = None session = ctx.ensure_object(Session) session.params[param.name] = value From c54ea7416f8b09bcae195c3ee27e13c2cd4a13ff Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 25 Apr 2022 12:54:24 +0200 Subject: [PATCH 25/53] move `wrap_async` from utils.py to decorators.py --- src/audible_cli/decorators.py | 52 +++++++++++++++++++++-------------- src/audible_cli/utils.py | 13 --------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index d3c173e..a9a80c8 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -1,6 +1,6 @@ import asyncio import logging -from functools import wraps +from functools import partial, wraps import click import httpx @@ -16,27 +16,39 @@ logger = logging.getLogger("audible_cli.options") pass_session = click.make_pass_decorator(Session, ensure=True) -def run_async(func=None): - def coro(f): - @wraps(f) - def wrapper(*args, **kwargs): - if hasattr(asyncio, "run"): - logger.debug("Using asyncio.run ...") - return asyncio.run(f(*args, ** kwargs)) - else: - logger.debug("Using asyncio.run_until_complete ...") - loop = asyncio.get_event_loop() - try: - return loop.run_until_complete(f(*args, ** kwargs)) - finally: - loop.run_until_complete(loop.shutdown_asyncgens()) - loop.close() - return wrapper +def run_async(f): + @wraps(f) + def wrapper(*args, **kwargs): + if hasattr(asyncio, "run"): + logger.debug("Using asyncio.run ...") + return asyncio.run(f(*args, ** kwargs)) + else: + logger.debug("Using asyncio.run_until_complete ...") + loop = asyncio.get_event_loop() - if callable(func): - return coro(func) + if loop.is_closed(): + loop = asyncio.new_event_loop() - return coro + try: + return loop.run_until_complete(f(*args, ** kwargs)) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + return wrapper + + +def wrap_async(f): + """Wrap a syncronous function and runs them in an executor""" + + @wraps(f) + async def wrapper(*args, loop=None, executor=None, **kwargs): + if loop is None: + loop = asyncio.get_event_loop() + + partial_func = partial(f, *args, **kwargs) + return await loop.run_in_executor(executor, partial_func) + + return wrapper def pass_client(func=None, **client_kwargs): diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index e925db6..55f3994 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -1,10 +1,8 @@ -import asyncio import csv import io import logging import pathlib from difflib import SequenceMatcher -from functools import partial, wraps from typing import List, Optional, Union import aiofiles @@ -149,17 +147,6 @@ def asin_in_library(asin, library): return False -def wrap_async(func): - @wraps(func) - async def run(*args, loop=None, executor=None, **kwargs): - if loop is None: - loop = asyncio.get_event_loop() - pfunc = partial(func, *args, **kwargs) - return await loop.run_in_executor(executor, pfunc) - - return run - - class DummyProgressBar: def __enter__(self): return self From 0731e54184fd8357f430d1a757c27abbdf671689 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 25 Apr 2022 12:56:39 +0200 Subject: [PATCH 26/53] rework cmd_library.py --- src/audible_cli/cmds/cmd_library.py | 118 ++++++++++++++-------------- 1 file changed, 61 insertions(+), 57 deletions(-) diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index 36da6fc..452b09f 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -1,3 +1,4 @@ +import asyncio import csv import json import pathlib @@ -9,7 +10,8 @@ from ..decorators import ( bunch_size_option, run_async, timeout_option, - pass_session + pass_session, + wrap_async ) from ..models import Library from ..utils import export_to_csv @@ -40,15 +42,30 @@ async def _get_library(session): return library -def _prepare_library_for_export(library: Library): - keys_with_raw_values = ( - "asin", "title", "subtitle", "runtime_length_min", "is_finished", - "percent_complete", "release_date" - ) +@cli.command("export") +@click.option( + "--output", "-o", + type=click.Path(path_type=pathlib.Path), + default=pathlib.Path().cwd() / r"library.{format}", + show_default=True, + help="output file" +) +@timeout_option +@click.option( + "--format", "-f", + type=click.Choice(["tsv", "csv", "json"]), + default="tsv", + show_default=True, + help="Output format" +) +@bunch_size_option +@pass_session +@run_async +async def export_library(session, **params): + """export library""" - prepared_library = [] - - for item in library: + @wrap_async + def _prepare_item(item): data_row = {} for key in item: v = getattr(item, key) @@ -78,34 +95,8 @@ def _prepare_library_for_export(library: Library): genres.append(ladder["name"]) data_row["genres"] = ", ".join(genres) - prepared_library.append(data_row) + return data_row - prepared_library.sort(key=lambda x: x["asin"]) - - return prepared_library - - -@cli.command("export") -@click.option( - "--output", "-o", - type=click.Path(path_type=pathlib.Path), - default=pathlib.Path().cwd() / r"library.{format}", - show_default=True, - help="output file" -) -@timeout_option -@click.option( - "--format", "-f", - type=click.Choice(["tsv", "csv", "json"]), - default="tsv", - show_default=True, - help="Output format" -) -@bunch_size_option -@pass_session -@run_async -async def export_library(session, **params): - """export library""" output_format = params.get("format") output_filename: pathlib.Path = params.get("output") if output_filename.suffix == r".{format}": @@ -114,23 +105,32 @@ async def export_library(session, **params): library = await _get_library(session) - prepared_library = _prepare_library_for_export(library) - - headers = ( - "asin", "title", "subtitle", "authors", "narrators", "series_title", - "series_sequence", "genres", "runtime_length_min", "is_finished", - "percent_complete", "rating", "num_ratings", "date_added", - "release_date", "cover_url" + keys_with_raw_values = ( + "asin", "title", "subtitle", "runtime_length_min", "is_finished", + "percent_complete", "release_date" ) + prepared_library = await asyncio.gather( + *[_prepare_item(i) for i in library] + ) + prepared_library.sort(key=lambda x: x["asin"]) + if output_format in ("tsv", "csv"): if output_format == csv: dialect = "excel" else: dialect = "excel-tab" + + headers = ( + "asin", "title", "subtitle", "authors", "narrators", "series_title", + "series_sequence", "genres", "runtime_length_min", "is_finished", + "percent_complete", "rating", "num_ratings", "date_added", + "release_date", "cover_url" + ) + export_to_csv(output_filename, prepared_library, headers, dialect) - if output_format == "json": + elif output_format == "json": data = json.dumps(prepared_library, indent=4) output_filename.write_text(data) @@ -142,26 +142,30 @@ async def export_library(session, **params): @run_async async def list_library(session): """list titles in library""" - library = await _get_library(session) - books = [] + @wrap_async + def _prepare_item(item): + fields = [item.asin] - for item in library: - asin = item.asin authors = ", ".join( sorted(a["name"] for a in item.authors) if item.authors else "" ) + if authors: + fields.append(authors) + series = ", ".join( sorted(s["title"] for s in item.series) if item.series else "" ) - title = item.title - books.append((asin, authors, series, title)) - - for asin, authors, series, title in sorted(books): - fields = [asin] - if authors: - fields.append(authors) if series: fields.append(series) - fields.append(title) - echo(": ".join(fields)) + + fields.append(item.title) + return ": ".join(fields) + + library = await _get_library(session) + books = await asyncio.gather( + *[_prepare_item(i) for i in library] + ) + + for i in sorted(books): + echo(i) From 8c7a2382d23ca96cc8f652adedac0018ddd88ba4 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 25 Apr 2022 12:56:52 +0200 Subject: [PATCH 27/53] rework cmd_wishlist.py --- src/audible_cli/cmds/cmd_wishlist.py | 126 ++++++++++++++------------- 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index b65ac88..a416ef7 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -9,7 +9,7 @@ import httpx import questionary from click import echo -from ..decorators import timeout_option, pass_client +from ..decorators import timeout_option, pass_client, wrap_async from ..models import Catalog, Wishlist from ..utils import export_to_csv @@ -34,15 +34,33 @@ async def _get_wishlist(client): return wishlist -def _prepare_wishlist_for_export(wishlist: dict): - keys_with_raw_values = ( - "asin", "title", "subtitle", "runtime_length_min", "is_finished", - "percent_complete", "release_date" - ) +@click.group("wishlist") +def cli(): + """interact with wishlist""" - prepared_wishlist = [] - for item in wishlist: +@cli.command("export") +@click.option( + "--output", "-o", + type=click.Path(), + default=pathlib.Path().cwd() / r"wishlist.{format}", + show_default=True, + help="output file" +) +@timeout_option +@click.option( + "--format", "-f", + type=click.Choice(["tsv", "csv", "json"]), + default="tsv", + show_default=True, + help="Output format" +) +@pass_client +async def export_wishlist(client, **params): + """export wishlist""" + + @wrap_async + def _prepare_item(item): data_row = {} for key in item: v = getattr(item, key) @@ -71,38 +89,8 @@ def _prepare_wishlist_for_export(wishlist: dict): for ladder in genre["ladder"]: genres.append(ladder["name"]) data_row["genres"] = ", ".join(genres) + return data_row - prepared_wishlist.append(data_row) - - prepared_wishlist.sort(key=lambda x: x["asin"]) - - return prepared_wishlist - - -@click.group("wishlist") -def cli(): - """interact with wishlist""" - - -@cli.command("export") -@click.option( - "--output", "-o", - type=click.Path(), - default=pathlib.Path().cwd() / r"wishlist.{format}", - show_default=True, - help="output file" -) -@timeout_option -@click.option( - "--format", "-f", - type=click.Choice(["tsv", "csv", "json"]), - default="tsv", - show_default=True, - help="Output format" -) -@pass_client -async def export_wishlist(client, **params): - """export wishlist""" output_format = params.get("format") output_filename: pathlib.Path = params.get("output") if output_filename.suffix == r".{format}": @@ -111,23 +99,34 @@ async def export_wishlist(client, **params): wishlist = await _get_wishlist(client) - prepared_wishlist = _prepare_wishlist_for_export(wishlist) - - headers = ( - "asin", "title", "subtitle", "authors", "narrators", "series_title", - "series_sequence", "genres", "runtime_length_min", "is_finished", - "percent_complete", "rating", "num_ratings", "date_added", - "release_date", "cover_url" + keys_with_raw_values = ( + "asin", "title", "subtitle", "runtime_length_min", "is_finished", + "percent_complete", "release_date" ) + prepared_wishlist = await asyncio.gather( + *[_prepare_item(i) for i in wishlist] + ) + prepared_wishlist.sort(key=lambda x: x["asin"]) + if output_format in ("tsv", "csv"): if output_format == csv: dialect = "excel" else: dialect = "excel-tab" - export_to_csv(output_filename, prepared_wishlist, headers, dialect) - if output_format == "json": + headers = ( + "asin", "title", "subtitle", "authors", "narrators", "series_title", + "series_sequence", "genres", "runtime_length_min", "is_finished", + "percent_complete", "rating", "num_ratings", "date_added", + "release_date", "cover_url" + ) + + export_to_csv( + output_filename, prepared_wishlist, headers, dialect + ) + + elif output_format == "json": data = json.dumps(prepared_wishlist, indent=4) output_filename.write_text(data) @@ -137,29 +136,34 @@ async def export_wishlist(client, **params): @pass_client async def list_wishlist(client): """list titles in wishlist""" - wishlist = await _get_wishlist(client) - books = [] + @wrap_async + def _prepare_item(item): + fields = [item.asin] - for item in wishlist: - asin = item.asin authors = ", ".join( sorted(a["name"] for a in item.authors) if item.authors else "" ) + if authors: + fields.append(authors) + series = ", ".join( sorted(s["title"] for s in item.series) if item.series else "" ) - title = item.title - books.append((asin, authors, series, title)) - - for asin, authors, series, title in sorted(books): - fields = [asin] - if authors: - fields.append(authors) if series: fields.append(series) - fields.append(title) - echo(": ".join(fields)) + + fields.append(item.title) + return ": ".join(fields) + + wishlist = await _get_wishlist(client) + + books = await asyncio.gather( + *[_prepare_item(i) for i in wishlist] + ) + + for i in sorted(books): + echo(i) @cli.command("add") From 37c582ce7033f72e8197a89dddf97df09fa14001 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 26 Apr 2022 09:38:00 +0200 Subject: [PATCH 28/53] add `LibraryItem.get_annotations` method --- src/audible_cli/models.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index 6d0f7f4..b11af5c 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -8,7 +8,6 @@ import audible import httpx from audible.aescipher import decrypt_voucher_from_licenserequest - from .constants import CODEC_HIGH_QUALITY, CODEC_NORMAL_QUALITY from .exceptions import AudibleCliException from .utils import LongestSubString @@ -313,6 +312,17 @@ class LibraryItem(BaseItem): return metadata + async def get_annotations(self): + url = f"https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/sidecar" + params = { + "type": "AUDI", + "key": self.asin + } + + annotations = await self._client.get(url, params=params) + + return annotations + class WishlistItem(BaseItem): pass From 7cc5e6a4c450de9c2c274e3f0c4564486ccd4f8c Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 26 Apr 2022 09:40:11 +0200 Subject: [PATCH 29/53] add flag `--annotation` to download command Downloads the annotations (bookmarks, notes, clips and last heard) to json file --- src/audible_cli/cmds/cmd_download.py | 67 ++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 14b0f99..1c035f3 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -11,6 +11,7 @@ import aiofiles import click import httpx import questionary +from audible.exceptions import NotFoundError from click import echo from ..decorators import ( @@ -19,7 +20,7 @@ from ..decorators import ( pass_client, pass_session ) -from ..exceptions import DirectoryDoesNotExists, NotFoundError +from ..exceptions import DirectoryDoesNotExists from ..models import Library from ..utils import Downloader @@ -84,6 +85,7 @@ class DownloadCounter: def __init__(self): self._aax: int = 0 self._aaxc: int = 0 + self._annotation: int = 0 self._chapter: int = 0 self._cover: int = 0 self._pdf: int = 0 @@ -106,6 +108,14 @@ class DownloadCounter: self._aaxc += 1 logger.debug(f"Currently downloaded aaxc files: {self.aaxc}") + @property + def annotation(self): + return self._annotation + + def count_annotation(self): + self._annotation += 1 + logger.debug(f"Currently downloaded annotations: {self.annotation}") + @property def chapter(self): return self._chapter @@ -150,6 +160,7 @@ class DownloadCounter: return { "aax": self.aax, "aaxc": self.aaxc, + "annotation": self.annotation, "chapter": self.chapter, "cover": self.cover, "pdf": self.pdf, @@ -225,8 +236,8 @@ async def download_chapters( try: metadata = await item.get_content_metadata(quality) except NotFoundError: - logger.error( - f"Can't get chapters for {item.full_title}. Skip item." + logger.info( + f"No chapters found for {item.full_title}." ) return metadata = json.dumps(metadata, indent=4) @@ -236,6 +247,34 @@ async def download_chapters( counter.count_chapter() +async def download_annotations( + output_dir, base_filename, item, overwrite_existing +): + if not output_dir.is_dir(): + raise DirectoryDoesNotExists(output_dir) + + filename = base_filename + "-annotations.json" + file = output_dir / filename + if file.exists() and not overwrite_existing: + logger.info( + f"File {file} already exists. Skip saving annotations" + ) + return True + + try: + annotation = await item.get_annotations() + except NotFoundError: + logger.info( + f"No annotations found for {item.full_title}." + ) + return + annotation = json.dumps(annotation, indent=4) + async with aiofiles.open(file, "w") as f: + await f.write(annotation) + logger.info(f"Annotation file saved to {file}.") + counter.count_annotation() + + async def download_aax( client, output_dir, base_filename, item, quality, overwrite_existing ): @@ -346,6 +385,7 @@ def queue_job( queue, get_cover, get_pdf, + get_annotation, get_chapters, get_aax, get_aaxc, @@ -393,6 +433,16 @@ def queue_job( ) ) + if get_annotation: + queue.put_nowait( + download_annotations( + output_dir=output_dir, + base_filename=base_filename, + item=item, + overwrite_existing=overwrite_existing + ) + ) + if get_aax: queue.put_nowait( download_aax( @@ -498,6 +548,11 @@ def display_counter(): is_flag=True, help="saves chapter metadata as JSON file" ) +@click.option( + "--annotation", + is_flag=True, + help="saves the annotations (e.g. bookmarks, notes) as JSON file" +) @click.option( "--no-confirm", "-y", is_flag=True, @@ -558,10 +613,13 @@ async def cli(session, api_client, **params): # what to download get_aax = params.get("aax") get_aaxc = params.get("aaxc") + get_annotation = params.get("annotation") get_chapters = params.get("chapter") get_cover = params.get("cover") get_pdf = params.get("pdf") - if not any([get_aax, get_aaxc, get_chapters, get_cover, get_pdf]): + if not any( + [get_aax, get_aaxc, get_annotation, get_chapters, get_cover, get_pdf] + ): logger.error("Please select an option what you want download.") click.Abort() @@ -663,6 +721,7 @@ async def cli(session, api_client, **params): queue=queue, get_cover=get_cover, get_pdf=get_pdf, + get_annotation=get_annotation, get_chapters=get_chapters, get_aax=get_aax, get_aaxc=get_aaxc, From 4bd2287222f55fdffb016415fd7e7d31e696ded5 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 26 Apr 2022 14:48:28 +0200 Subject: [PATCH 30/53] update example `cmd_get-annotations.py` --- plugin_cmds/cmd_get-annotations.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/plugin_cmds/cmd_get-annotations.py b/plugin_cmds/cmd_get-annotations.py index a3877b6..c96d055 100644 --- a/plugin_cmds/cmd_get-annotations.py +++ b/plugin_cmds/cmd_get-annotations.py @@ -1,18 +1,21 @@ import click -from audible_cli.decorators import pass_session, run_async +from audible.exceptions import NotFoundError +from audible_cli.decorators import pass_client @click.command("get-annotations") @click.argument("asin") -@pass_session -@run_async() -async def cli(session, asin): - async with session.get_client() as client: - url = f"https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/sidecar" - params = { - "type": "AUDI", - "key": asin - } +@pass_client +async def cli(client, asin): + url = f"https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/sidecar" + params = { + "type": "AUDI", + "key": asin + } + try: r = await client.get(url, params=params) + except NotFoundError: + click.echo(f"No annotions found for asin {asin}") + else: click.echo(r) From 35fa35614dbde6cdf951a1bed6ac70a99d7341a5 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 29 Apr 2022 12:51:30 +0200 Subject: [PATCH 31/53] fix a bug in csv output #79 --- src/audible_cli/cmds/cmd_library.py | 3 +-- src/audible_cli/cmds/cmd_wishlist.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index 452b09f..464b392 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -1,5 +1,4 @@ import asyncio -import csv import json import pathlib @@ -116,7 +115,7 @@ async def export_library(session, **params): prepared_library.sort(key=lambda x: x["asin"]) if output_format in ("tsv", "csv"): - if output_format == csv: + if output_format == "csv": dialect = "excel" else: dialect = "excel-tab" diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index a416ef7..bd57aad 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -1,5 +1,4 @@ import asyncio -import csv import json import logging import pathlib @@ -110,7 +109,7 @@ async def export_wishlist(client, **params): prepared_wishlist.sort(key=lambda x: x["asin"]) if output_format in ("tsv", "csv"): - if output_format == csv: + if output_format == "csv": dialect = "excel" else: dialect = "excel-tab" From b26ef99332d3518483d77403a15ba53e2904a448 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 20:02:16 +0200 Subject: [PATCH 32/53] config.py: fix PEP 8: E125 --- src/audible_cli/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 8e249bb..3808bde 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -335,7 +335,7 @@ class Session: profile: str, password: Optional[str] = None, **kwargs - ) -> AsyncClient: + ) -> AsyncClient: auth = self.get_auth_for_profile(profile, password) kwargs.setdefault("timeout", self.params.get("timeout", 5)) return AsyncClient(auth=auth, **kwargs) From ea7226a8b832bb0a8bcd7f4f4987e1e025759e5c Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 20:06:27 +0200 Subject: [PATCH 33/53] config.py: fix typo --- src/audible_cli/config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 3808bde..57c6ba0 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -36,7 +36,7 @@ class ConfigFile: Args: filename: The file path to the config file - file_exists: If ``True``, the file must exists and the file content + file_exists: If ``True``, the file must exist and the file content is loaded. """ @@ -87,7 +87,7 @@ class ConfigFile: return self.data["APP"] def has_profile(self, name: str) -> bool: - """Check if a profile with these name are in the configuration data + """Check if a profile with this name are in the configuration data Args: name: The name of the profile @@ -119,7 +119,7 @@ class ConfigFile: """Returns the value for an option for the given profile. Looks first, if an option is in the ``profile`` section. If not, it - searchs for the option in the ``APP`` section. If not found, it + searches for the option in the ``APP`` section. If not found, it returns the ``default``. Args: @@ -261,7 +261,7 @@ class Session: """Returns the selected config profile name for this session The `profile` to use must be set using the ``add_param_to_session`` - callback of a click option. Otherwise the primary profile from the + callback of a click option. Otherwise, the primary profile from the config is used. """ profile = self.params.get("profile") or self.config.primary_profile @@ -281,7 +281,7 @@ class Session: """Returns an Authenticator for a profile If an Authenticator for this profile is already loaded, it will - return the Authenticator without reloading it. This way an session can + return the Authenticator without reloading it. This way a session can hold multiple Authenticators for different profiles. Commands can use this to make API requests for more than one profile. From c293afb88345c32122a26118e83c2f48c618de59 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 20:10:46 +0200 Subject: [PATCH 34/53] decorators.py: fix PEP 8: E501 --- src/audible_cli/decorators.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index a9a80c8..833e43a 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -130,7 +130,10 @@ def version_option(func=None, **kwargs): def profile_option(func=None, **kwargs): kwargs.setdefault("callback", add_param_to_session) kwargs.setdefault("expose_value", False) - kwargs.setdefault("help", "The profile to use instead primary profile (case sensitive!).") + kwargs.setdefault( + "help", + "The profile to use instead primary profile (case sensitive!)." + ) option = click.option("--profile", "-P", **kwargs) From 0fb1de2ce94211d1c82cf2fe3df297eb2d2e4b99 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 20:13:47 +0200 Subject: [PATCH 35/53] fix shadow from outer scope --- src/audible_cli/cli.py | 4 ++-- src/audible_cli/decorators.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/audible_cli/cli.py b/src/audible_cli/cli.py index dd935a6..4bb75aa 100644 --- a/src/audible_cli/cli.py +++ b/src/audible_cli/cli.py @@ -31,7 +31,7 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @profile_option @password_option @version_option -@verbosity_option(logger=logger) +@verbosity_option(cli_logger=logger) def cli(): """Entrypoint for all other subcommands and groups.""" @@ -39,7 +39,7 @@ def cli(): @click.command(context_settings=CONTEXT_SETTINGS) @click.pass_context @version_option -@verbosity_option(logger=logger) +@verbosity_option(cli_logger=logger) def quickstart(ctx): """Entrypoint for the quickstart command""" try: diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index 833e43a..21bd6b4 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -156,7 +156,7 @@ def password_option(func=None, **kwargs): return option -def verbosity_option(func=None, *, logger=None, **kwargs): +def verbosity_option(func=None, *, cli_logger=None, **kwargs): """A decorator that adds a `--verbosity, -v` option to the decorated command. Keyword arguments are passed to @@ -169,7 +169,7 @@ def verbosity_option(func=None, *, logger=None, **kwargs): f"Must be CRITICAL, ERROR, WARNING, INFO or DEBUG, " f"not {value}" ) - logger.setLevel(x) + cli_logger.setLevel(x) kwargs.setdefault("default", "INFO") kwargs.setdefault("metavar", "LVL") @@ -181,7 +181,7 @@ def verbosity_option(func=None, *, logger=None, **kwargs): kwargs.setdefault("is_eager", True) kwargs.setdefault("callback", callback) - logger = _normalize_logger(logger) + cli_logger = _normalize_logger(cli_logger) option = click.option("--verbosity", "-v", **kwargs) From fa8012ec4de62b48641099a1d4d9a85a7035df85 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 20:15:48 +0200 Subject: [PATCH 36/53] decorators.py: fix typo --- src/audible_cli/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index 21bd6b4..9e39c3a 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -38,7 +38,7 @@ def run_async(f): def wrap_async(f): - """Wrap a syncronous function and runs them in an executor""" + """Wrap a synchronous function and runs them in an executor""" @wraps(f) async def wrapper(*args, loop=None, executor=None, **kwargs): From fe4bc080e084fead6d843649aaf351357595524d Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 22:03:08 +0200 Subject: [PATCH 37/53] Update models.py A full api sync will now fetch page 1 and extract the total count headers. Now outstanding pages will be counted and requested at once. --- src/audible_cli/models.py | 82 ++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index b11af5c..1b73d51 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -2,15 +2,17 @@ import asyncio import logging import string import unicodedata +from math import ceil from typing import List, Optional, Union import audible import httpx from audible.aescipher import decrypt_voucher_from_licenserequest +from audible.client import convert_response_content from .constants import CODEC_HIGH_QUALITY, CODEC_NORMAL_QUALITY from .exceptions import AudibleCliException -from .utils import LongestSubString +from .utils import full_response_callback, LongestSubString logger = logging.getLogger("audible_cli.models") @@ -173,7 +175,7 @@ class LibraryItem(BaseItem): """ # Only items with content_delivery_type - # MultiPartBook or Periodical have child elemts + # MultiPartBook or Periodical have child elements if not self.has_children: return @@ -346,6 +348,10 @@ class BaseList: def _prepare_data(self, data: Union[dict, list]) -> Union[dict, list]: return data + @property + def data(self): + return self._data + def get_item_by_asin(self, asin): try: return next(i for i in self._data if asin == i.asin) @@ -385,6 +391,7 @@ class Library(BaseList): async def from_api( cls, api_client: audible.AsyncClient, + include_total_count_header: bool = False, **request_params ): if "response_groups" not in request_params: @@ -400,8 +407,18 @@ class Library(BaseList): "periodicals, provided_review, product_details" ) - resp = await api_client.get("library", **request_params) - return cls(resp, api_client=api_client) + resp: httpx.Response = await api_client.get( + "library", + response_callback=full_response_callback, + **request_params + ) + resp_content = convert_response_content(resp) + total_count_header = resp.headers.get("total-count") + cls_instance = cls(resp_content, api_client=api_client) + + if include_total_count_header: + return cls_instance, total_count_header + return cls_instance @classmethod async def from_api_full_sync( @@ -410,34 +427,42 @@ class Library(BaseList): bunch_size: int = 1000, **request_params ) -> "Library": - request_params["page"] = 1 + request_params.pop("page", None) request_params["num_results"] = bunch_size - library = [] - while True: - resp = await cls.from_api(api_client, params=request_params) - items = resp._data - len_items = len(items) - library.extend(items) - if len_items < bunch_size: - break - request_params["page"] += 1 - print(request_params["page"]) + library, total_count = await cls.from_api( + api_client, + page=1, + params=request_params, + include_total_count_header=True, + ) + pages = ceil(int(total_count) / bunch_size) + if pages == 1: + return library - resp._data = library - return resp + additional_pages = [] + for page in range(2, pages+1): + additional_pages.append( + cls.from_api( + api_client, + page=page, + params=request_params, + ) + ) + + additional_pages = await asyncio.gather(*additional_pages) + + for p in additional_pages: + library.data.extend(p.data) + + return library async def resolve_podcats(self): - podcasts = [] - for i in self: - if i.is_parent_podcast(): - podcasts.append(i) - podcast_items = await asyncio.gather( - *[i.get_child_items() for i in podcasts] + *[i.get_child_items() for i in self if i.is_parent_podcast()] ) for i in podcast_items: - self._data.extend(i._data) + self.data.extend(i.data) class Catalog(BaseList): @@ -497,16 +522,11 @@ class Catalog(BaseList): return cls(resp, api_client=api_client) async def resolve_podcats(self): - podcasts = [] - for i in self: - if i.is_parent_podcast(): - podcasts.append(i) - podcast_items = await asyncio.gather( - *[i.get_child_items() for i in podcasts] + *[i.get_child_items() for i in self if i.is_parent_podcast()] ) for i in podcast_items: - self._data.extend(i._data) + self.data.extend(i.data) class Wishlist(BaseList): From e62c0cbf2941760d4218f840a95d03bacc74ee1b Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 22:10:36 +0200 Subject: [PATCH 38/53] cmd_quickstart.py: fix typo --- src/audible_cli/cmds/cmd_quickstart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/cmds/cmd_quickstart.py b/src/audible_cli/cmds/cmd_quickstart.py index 8376497..a5bd8d3 100644 --- a/src/audible_cli/cmds/cmd_quickstart.py +++ b/src/audible_cli/cmds/cmd_quickstart.py @@ -141,7 +141,7 @@ an authentication to the audible server is necessary to register a new device. @click.command("quickstart") @pass_session def cli(session): - """Quicksetup audible""" + """Quick setup audible""" config_file: pathlib.Path = session.app_dir / CONFIG_FILE config = ConfigFile(config_file, file_exists=False) if config_file.is_file(): From 04fe4c2254f718075301b58963582190b4ad44c6 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 22:11:01 +0200 Subject: [PATCH 39/53] cmd_manage.py: fix typo --- src/audible_cli/cmds/cmd_manage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/cmds/cmd_manage.py b/src/audible_cli/cmds/cmd_manage.py index 7843469..03e4259 100644 --- a/src/audible_cli/cmds/cmd_manage.py +++ b/src/audible_cli/cmds/cmd_manage.py @@ -167,7 +167,7 @@ def check_if_auth_file_not_exists(session, ctx, param, value): @click.option( "--external-login", is_flag=True, - help="Authenticate using a webbrowser." + help="Authenticate using a web browser." ) @click.option( "--with-username", From 194545e2d01b9ee50aa3955212b309ffaf18f441 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 2 May 2022 22:11:17 +0200 Subject: [PATCH 40/53] Update cmd_download.py --- src/audible_cli/cmds/cmd_download.py | 51 ---------------------------- 1 file changed, 51 deletions(-) diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 1c035f3..bf0a4fc 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -3,9 +3,7 @@ import asyncio.log import asyncio.sslproto import json import pathlib -import ssl import logging -import sys import aiofiles import click @@ -27,60 +25,11 @@ from ..utils import Downloader logger = logging.getLogger("audible_cli.cmds.cmd_download") -SSL_PROTOCOLS = (asyncio.sslproto.SSLProtocol,) - CLIENT_HEADERS = { "User-Agent": "Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0" } -def ignore_httpx_ssl_eror(loop): - """Ignore aiohttp #3535 / cpython #13548 issue with SSL data after close - - There is an issue in Python 3.7 up to 3.7.3 that over-reports a - ssl.SSLError fatal error (ssl.SSLError: [SSL: KRB5_S_INIT] application data - after close notify (_ssl.c:2609)) after we are already done with the - connection. See GitHub issues aio-libs/aiohttp#3535 and - python/cpython#13548. - - Given a loop, this sets up an exception handler that ignores this specific - exception, but passes everything else on to the previous exception handler - this one replaces. - - Checks for fixed Python versions, disabling itself when running on 3.7.4+ - or 3.8. - - """ - if sys.version_info >= (3, 7, 4): - return - - orig_handler = loop.get_exception_handler() - - def ignore_ssl_error(context): - if context.get("message") in { - "SSL error in data received", - "Fatal error on transport", - }: - # validate we have the right exception, transport and protocol - exception = context.get("exception") - protocol = context.get("protocol") - if ( - isinstance(exception, ssl.SSLError) - and exception.reason == "KRB5_S_INIT" - and isinstance(protocol, SSL_PROTOCOLS) - ): - if loop.get_debug(): - asyncio.log.logger.debug( - "Ignoring httpx SSL KRB5_S_INIT error") - return - if orig_handler is not None: - orig_handler(loop, context) - else: - loop.default_exception_handler(context) - - loop.set_exception_handler(ignore_ssl_error) - - class DownloadCounter: def __init__(self): self._aax: int = 0 From 87319862a61006d0fb8516f2c13287a3415ea656 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 3 May 2022 11:00:21 +0200 Subject: [PATCH 41/53] config.py: fix typo --- src/audible_cli/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 57c6ba0..50d5492 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -107,7 +107,7 @@ class ConfigFile: @property def primary_profile(self) -> str: if "primary_profile" not in self.app_config: - raise AudibleCliException("No primary profile in config set") + raise AudibleCliException("No primary profile set in config") return self.app_config["primary_profile"] def get_profile_option( From 8ee6fc810bf278e710f72c6063ac3c303a187174 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 3 May 2022 11:02:36 +0200 Subject: [PATCH 42/53] add `--resolve-podcasts` option to library export and list command --- src/audible_cli/cmds/cmd_library.py | 60 ++++++++++++++++++----------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index 464b392..3dc4172 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -7,8 +7,8 @@ from click import echo from ..decorators import ( bunch_size_option, - run_async, timeout_option, + pass_client, pass_session, wrap_async ) @@ -21,24 +21,22 @@ def cli(): """interact with library""" -async def _get_library(session): +async def _get_library(session, client): bunch_size = session.params.get("bunch_size") - async with session.get_client() as client: - library = await Library.from_api_full_sync( - client, - response_groups=( - "contributors, media, price, product_attrs, product_desc, " - "product_extended_attrs, product_plan_details, product_plans, " - "rating, sample, sku, series, reviews, ws4v, origin, " - "relationships, review_attrs, categories, badge_types, " - "category_ladders, claim_code_url, is_downloaded, " - "is_finished, is_returnable, origin_asin, pdf_url, " - "percent_complete, provided_review" - ), - bunch_size=bunch_size - ) - return library + return await Library.from_api_full_sync( + client, + response_groups=( + "contributors, media, price, product_attrs, product_desc, " + "product_extended_attrs, product_plan_details, product_plans, " + "rating, sample, sku, series, reviews, ws4v, origin, " + "relationships, review_attrs, categories, badge_types, " + "category_ladders, claim_code_url, is_downloaded, " + "is_finished, is_returnable, origin_asin, pdf_url, " + "percent_complete, provided_review" + ), + bunch_size=bunch_size + ) @cli.command("export") @@ -58,9 +56,14 @@ async def _get_library(session): help="Output format" ) @bunch_size_option +@click.option( + "--resolve-podcasts", + is_flag=True, + help="Resolve podcasts to show all episodes" +) @pass_session -@run_async -async def export_library(session, **params): +@pass_client +async def export_library(session, client, **params): """export library""" @wrap_async @@ -102,7 +105,9 @@ async def export_library(session, **params): suffix = "." + output_format output_filename = output_filename.with_suffix(suffix) - library = await _get_library(session) + library = await _get_library(session, client) + if params.get("resolve_podcasts"): + await library.resolve_podcats() keys_with_raw_values = ( "asin", "title", "subtitle", "runtime_length_min", "is_finished", @@ -137,9 +142,14 @@ async def export_library(session, **params): @cli.command("list") @timeout_option @bunch_size_option +@click.option( + "--resolve-podcasts", + is_flag=True, + help="Resolve podcasts to show all episodes" +) @pass_session -@run_async -async def list_library(session): +@pass_client +async def list_library(session, client, resolve_podcasts=False): """list titles in library""" @wrap_async @@ -161,7 +171,11 @@ async def list_library(session): fields.append(item.title) return ": ".join(fields) - library = await _get_library(session) + library = await _get_library(session, client) + + if resolve_podcasts: + await library.resolve_podcats() + books = await asyncio.gather( *[_prepare_item(i) for i in library] ) From f6a45c299883ea777c4cbe7652443170c0b84b73 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 4 May 2022 12:34:55 +0200 Subject: [PATCH 43/53] Update files in plugin_cmds --- plugin_cmds/cmd_get-annotations.py | 2 +- plugin_cmds/cmd_goodreads-transform.py | 45 +++----- plugin_cmds/cmd_image-urls.py | 22 ++-- plugin_cmds/cmd_listening-stats.py | 33 +++--- plugin_cmds/cmd_remove-encryption.py | 153 +++++++++++++++---------- 5 files changed, 138 insertions(+), 117 deletions(-) diff --git a/plugin_cmds/cmd_get-annotations.py b/plugin_cmds/cmd_get-annotations.py index c96d055..f8d2dcc 100644 --- a/plugin_cmds/cmd_get-annotations.py +++ b/plugin_cmds/cmd_get-annotations.py @@ -16,6 +16,6 @@ async def cli(client, asin): try: r = await client.get(url, params=params) except NotFoundError: - click.echo(f"No annotions found for asin {asin}") + click.echo(f"No annotations found for asin {asin}") else: click.echo(r) diff --git a/plugin_cmds/cmd_goodreads-transform.py b/plugin_cmds/cmd_goodreads-transform.py index 107a4dd..f5c0163 100644 --- a/plugin_cmds/cmd_goodreads-transform.py +++ b/plugin_cmds/cmd_goodreads-transform.py @@ -5,8 +5,8 @@ from datetime import datetime, timezone import click from audible_cli.decorators import ( bunch_size_option, - run_async, timeout_option, + pass_client, pass_session ) from audible_cli.models import Library @@ -25,16 +25,22 @@ logger = logging.getLogger("audible_cli.cmds.cmd_goodreads-transform") show_default=True, help="output file" ) -@timeout_option() -@bunch_size_option() +@timeout_option +@bunch_size_option @pass_session -@run_async() -async def cli(session, **params): +@pass_client +async def cli(session, client, output): """YOUR COMMAND DESCRIPTION""" - output = params.get("output") logger.debug("fetching library") - library = await _get_library(session) + bunch_size = session.params.get("bunch_size") + library = await Library.from_api_full_sync( + client, + response_groups=( + "product_details, contributors, is_finished, product_desc" + ), + bunch_size=bunch_size + ) logger.debug("prepare library") library = _prepare_library_for_export(library) @@ -52,21 +58,6 @@ async def cli(session, **params): logger.info(f"File saved to {output}") -async def _get_library(session): - bunch_size = session.params.get("bunch_size") - - async with session.get_client() as client: - # added product_detail to response_groups to obtain isbn - library = await Library.from_api_full_sync( - client, - response_groups=( - "product_details, contributors, is_finished, product_desc" - ), - bunch_size=bunch_size - ) - return library - - def _prepare_library_for_export(library): prepared_library = [] @@ -109,9 +100,11 @@ def _prepare_library_for_export(library): else: skipped_items += 1 - logger.debug(f"{isbn_api_counter} isbns from API") - logger.debug(f"{isbn_counter} isbns requested with isbntools") - logger.debug(f"{isbn_no_result_counter} isbns without a result") - logger.debug(f"{skipped_items} title skipped due to no isbn for title found or title not read") + logger.debug(f"ISBNs from API: {isbn_api_counter}") + logger.debug(f"ISBNs requested with isbntools: {isbn_counter}") + logger.debug(f"No result with isbntools: {isbn_no_result_counter}") + logger.debug( + f"title skipped from file due to no ISBN or title not read: " + f"{skipped_items}") return prepared_library diff --git a/plugin_cmds/cmd_image-urls.py b/plugin_cmds/cmd_image-urls.py index aa8e3bc..a261256 100644 --- a/plugin_cmds/cmd_image-urls.py +++ b/plugin_cmds/cmd_image-urls.py @@ -1,21 +1,19 @@ import click -from audible_cli.decorators import pass_session, run_async, timeout_option +from audible_cli.decorators import pass_client, timeout_option @click.command("image-urls") @click.argument("asin") @timeout_option() -@pass_session -@run_async() -async def cli(session, asin): - "Print out the image urls for different resolutions for a book" - async with session.get_client() as client: - r = await client.get( - f"catalog/products/{asin}", - response_groups="media", - image_sizes=("1215, 408, 360, 882, 315, 570, 252, " - "558, 900, 500") - ) +@pass_client() +async def cli(client, asin): + """Print out the image urls for different resolutions for a book""" + r = await client.get( + f"catalog/products/{asin}", + response_groups="media", + image_sizes=( + "1215, 408, 360, 882, 315, 570, 252, 558, 900, 500") + ) images = r["product"]["product_images"] for res, url in images.items(): click.echo(f"Resolution {res}: {url}") diff --git a/plugin_cmds/cmd_listening-stats.py b/plugin_cmds/cmd_listening-stats.py index d6e3e07..99a12cc 100644 --- a/plugin_cmds/cmd_listening-stats.py +++ b/plugin_cmds/cmd_listening-stats.py @@ -5,7 +5,7 @@ import pathlib from datetime import datetime import click -from audible_cli.decorators import pass_session, run_async +from audible_cli.decorators import pass_client logger = logging.getLogger("audible_cli.cmds.cmd_listening-stats") @@ -14,10 +14,10 @@ current_year = datetime.now().year def ms_to_hms(milliseconds): - seconds = (int) (milliseconds / 1000) % 60 - minutes = (int) ((milliseconds / (1000*60)) % 60) - hours = (int) ((milliseconds / (1000*60*60)) % 24) - return hours, minutes, seconds + seconds = int((milliseconds / 1000) % 60) + minutes = int(((milliseconds / (1000*60)) % 60)) + hours = int(((milliseconds / (1000*60*60)) % 24)) + return {"hours": hours, "minutes": minutes, "seconds": seconds} async def _get_stats_year(client, year): @@ -28,7 +28,7 @@ async def _get_stats_year(client, year): monthly_listening_interval_start_date=f"{year}-01", store="Audible" ) - #iterate over each month + # iterate over each month for stat in stats['aggregated_monthly_listening_stats']: stats_year[stat["interval_identifier"]] = ms_to_hms(stat["aggregated_sum"]) return stats_year @@ -49,22 +49,19 @@ async def _get_stats_year(client, year): show_default=True, help="start year for collecting listening stats" ) -@pass_session -@run_async() -async def cli(session, output, signup_year): +@pass_client +async def cli(client, output, signup_year): """get and analyse listening statistics""" year_range = [y for y in range(signup_year, current_year+1)] - async with session.get_client() as client: + r = await asyncio.gather( + *[_get_stats_year(client, y) for y in year_range] + ) - r = await asyncio.gather( - *[_get_stats_year(client, y) for y in year_range] - ) - - aggreated_stats = {} + aggregated_stats = {} for i in r: for k, v in i.items(): - aggreated_stats[k] = v + aggregated_stats[k] = v - aggreated_stats = json.dumps(aggreated_stats, indent=4) - output.write_text(aggreated_stats) + aggregated_stats = json.dumps(aggregated_stats, indent=4) + output.write_text(aggregated_stats) diff --git a/plugin_cmds/cmd_remove-encryption.py b/plugin_cmds/cmd_remove-encryption.py index 1f218c5..df79a21 100644 --- a/plugin_cmds/cmd_remove-encryption.py +++ b/plugin_cmds/cmd_remove-encryption.py @@ -2,7 +2,7 @@ This is a proof-of-concept and for testing purposes only. No error handling. Need further work. Some options does not work or options are missing. -Needs at least ffmpeg 4.1 with aaxc patch. +Needs at least ffmpeg 4.4 """ @@ -14,7 +14,7 @@ import subprocess from shutil import which import click -from audible_cli.decoratos import pass_session +from audible_cli.decorators import pass_session from click import echo, secho @@ -49,6 +49,10 @@ class ApiMeta: return self._meta_parsed["content_metadata"]["chapter_info"][ "runtime_length_ms"] + def is_accurate(self): + return self._meta_parsed["content_metadata"]["chapter_info"][ + "is_accurate"] + class FFMeta: SECTION = re.compile(r"\[(?P
[^]]+)\]") @@ -107,7 +111,8 @@ class FFMeta: self._write_section(fp, section, self._ffmeta_parsed[section], d) - def _write_section(self, fp, section_name, section_items, delimiter): + @staticmethod + def _write_section(fp, section_name, section_items, delimiter): """Write a single section to the specified `fp`.""" if section_name is not None: fp.write(f"[{section_name}]\n") @@ -122,6 +127,10 @@ class FFMeta: if not isinstance(api_meta, ApiMeta): api_meta = ApiMeta(api_meta) + if not api_meta.is_accurate(): + echo("Metadata from API is not accurate. Skip.") + return + # assert api_meta.count_chapters() == self.count_chapters() echo(f"Found {self.count_chapters()} chapters to prepare.") @@ -170,43 +179,54 @@ class FFMeta: self._ffmeta_parsed["CHAPTER"] = new_chapters -def decrypt_aax(files, activation_bytes): +def decrypt_aax(files, activation_bytes, rebuild_chapters): for file in files: outfile = file.with_suffix(".m4b") metafile = file.with_suffix(".meta") metafile_new = file.with_suffix(".new.meta") - # apimeta = CHAPTERFILE + base_filename = file.stem.rsplit("-")[0] + chapters = file.with_name(base_filename + "-chapters").with_suffix(".json") + apimeta = json.loads(chapters.read_text()) if outfile.exists(): secho(f"file {outfile} already exists Skip.", fg="blue") continue - - cmd = ["ffmpeg", - "-activation_bytes", activation_bytes, - "-i", str(file), - "-f", "ffmetadata", - str(metafile)] - subprocess.check_output(cmd, universal_newlines=True) - - ffmeta_class = FFMeta(metafile) - #ffmeta_class.update_chapters_from_api_meta(apimeta) - ffmeta_class.write(metafile_new) - click.echo("Replaced all titles.") - - cmd = ["ffmpeg", - "-activation_bytes", activation_bytes, - "-i", str(file), - "-i", str(metafile_new), - "-map_metadata", "0", - "-map_chapters", "1", - "-c", "copy", - str(outfile)] - subprocess.check_output(cmd, universal_newlines=True) - metafile.unlink() - metafile_new.unlink() + + if rebuild_chapters and apimeta["content_metadata"]["chapter_info"][ + "is_accurate"]: + cmd = ["ffmpeg", + "-activation_bytes", activation_bytes, + "-i", str(file), + "-f", "ffmetadata", + str(metafile)] + subprocess.check_output(cmd, universal_newlines=True) + + ffmeta_class = FFMeta(metafile) + ffmeta_class.update_chapters_from_api_meta(apimeta) + ffmeta_class.write(metafile_new) + click.echo("Replaced all titles.") + + cmd = ["ffmpeg", + "-activation_bytes", activation_bytes, + "-i", str(file), + "-i", str(metafile_new), + "-map_metadata", "0", + "-map_chapters", "1", + "-c", "copy", + str(outfile)] + subprocess.check_output(cmd, universal_newlines=True) + metafile.unlink() + metafile_new.unlink() + else: + cmd = ["ffmpeg", + "-activation_bytes", activation_bytes, + "-i", str(file), + "-c", "copy", + str(outfile)] + subprocess.check_output(cmd, universal_newlines=True) -def decrypt_aaxc(files): +def decrypt_aaxc(files, rebuild_chapters): for file in files: metafile = file.with_suffix(".meta") metafile_new = file.with_suffix(".new.meta") @@ -221,32 +241,42 @@ def decrypt_aaxc(files): apimeta = voucher["content_license"] audible_key = apimeta["license_response"]["key"] audible_iv = apimeta["license_response"]["iv"] + + if rebuild_chapters and apimeta["content_metadata"]["chapter_info"][ + "is_accurate"]: + cmd = ["ffmpeg", + "-audible_key", audible_key, + "-audible_iv", audible_iv, + "-i", str(file), + "-f", "ffmetadata", + str(metafile)] + subprocess.check_output(cmd, universal_newlines=True) - cmd = ["ffmpeg", - "-audible_key", audible_key, - "-audible_iv", audible_iv, - "-i", str(file), - "-f", "ffmetadata", - str(metafile)] - subprocess.check_output(cmd, universal_newlines=True) + ffmeta_class = FFMeta(metafile) + ffmeta_class.update_chapters_from_api_meta(apimeta) + ffmeta_class.write(metafile_new) + click.echo("Replaced all titles.") - ffmeta_class = FFMeta(metafile) - ffmeta_class.update_chapters_from_api_meta(apimeta) - ffmeta_class.write(metafile_new) - click.echo("Replaced all titles.") - - cmd = ["ffmpeg", - "-audible_key", audible_key, - "-audible_iv", audible_iv, - "-i", str(file), - "-i", str(metafile_new), - "-map_metadata", "0", - "-map_chapters", "1", - "-c", "copy", - str(outfile)] - subprocess.check_output(cmd, universal_newlines=True) - metafile.unlink() - metafile_new.unlink() + cmd = ["ffmpeg", + "-audible_key", audible_key, + "-audible_iv", audible_iv, + "-i", str(file), + "-i", str(metafile_new), + "-map_metadata", "0", + "-map_chapters", "1", + "-c", "copy", + str(outfile)] + subprocess.check_output(cmd, universal_newlines=True) + metafile.unlink() + metafile_new.unlink() + else: + cmd = ["ffmpeg", + "-audible_key", audible_key, + "-audible_iv", audible_iv, + "-i", str(file), + "-c", "copy", + str(outfile)] + subprocess.check_output(cmd, universal_newlines=True) CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -268,21 +298,25 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) is_flag=True, help="overwrite existing files" ) +@click.option( + "--rebuild-chapters", + is_flag=True, + help="Rebuild chapters from chapter file" +) @pass_session def cli(session, **options): if not which("ffmpeg"): ctx = click.get_current_context() ctx.fail("ffmpeg not found") + rebuild_chapters = options.get("rebuild_chapters") + jobs = {"aaxc": [], "aax":[]} if options.get("all"): cwd = pathlib.Path.cwd() jobs["aaxc"].extend(list(cwd.glob('*.aaxc'))) jobs["aax"].extend(list(cwd.glob('*.aax'))) - for suffix in jobs: - for i in jobs[suffix]: - i = i.resolve() else: for file in options.get("input"): @@ -294,6 +328,5 @@ def cli(session, **options): else: secho(f"file suffix {file.suffix} not supported", fg="red") - decrypt_aaxc(jobs["aaxc"]) - decrypt_aax(jobs["aax"], session.auth.activation_bytes) - + decrypt_aaxc(jobs["aaxc"], rebuild_chapters) + decrypt_aax(jobs["aax"], session.auth.activation_bytes, rebuild_chapters) From 08609d6ee2b0371ee7a92e3c12195079afdb5126 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 4 May 2022 12:35:30 +0200 Subject: [PATCH 44/53] Update code completion guide --- utils/code_completion/README.md | 2 +- utils/code_completion/audible-complete-bash.sh | 4 ++-- utils/code_completion/audible-complete-zsh-fish.sh | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/utils/code_completion/README.md b/utils/code_completion/README.md index 53025cc..0feda2c 100644 --- a/utils/code_completion/README.md +++ b/utils/code_completion/README.md @@ -5,5 +5,5 @@ Tab completion can be provided for commands, options and choice values. Bash, Zsh and Fish are supported. Simply copy the activation script for your shell from this folder to your machine. -Read [here](https://click.palletsprojects.com/en/7.x/bashcomplete/#activation-script) +Read [here](https://click.palletsprojects.com/en/8.0.x/shell-completion/) how-to activate the script in your shell. diff --git a/utils/code_completion/audible-complete-bash.sh b/utils/code_completion/audible-complete-bash.sh index 14aae91..06725a9 100644 --- a/utils/code_completion/audible-complete-bash.sh +++ b/utils/code_completion/audible-complete-bash.sh @@ -1,2 +1,2 @@ -_AUDIBLE_COMPLETE=source_bash audible -_AUDIBLE_QUICKSTART_COMPLETE=source_bash audible-quickstart +_AUDIBLE_COMPLETE=bash_source audible +_AUDIBLE_QUICKSTART_COMPLETE=bash_source audible-quickstart diff --git a/utils/code_completion/audible-complete-zsh-fish.sh b/utils/code_completion/audible-complete-zsh-fish.sh index bc42490..edb0812 100644 --- a/utils/code_completion/audible-complete-zsh-fish.sh +++ b/utils/code_completion/audible-complete-zsh-fish.sh @@ -1,2 +1,2 @@ -_AUDIBLE_COMPLETE=source_zsh audible -_AUDIBLE_QUICKSTART_COMPLETE=source_zsh audible-quickstart +_AUDIBLE_COMPLETE=zsh_source audible +_AUDIBLE_QUICKSTART_COMPLETE=zsh_source audible-quickstart From 1dc419868dc048d9c9260f6fd55a887967da0e44 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 4 May 2022 12:35:39 +0200 Subject: [PATCH 45/53] Update utils.py --- src/audible_cli/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index 55f3994..a52e768 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -32,7 +32,7 @@ def prompt_captcha_callback(captcha_url: str) -> str: img.show() else: echo( - "Please open the following url with a webbrowser " + "Please open the following url with a web browser " "to get the captcha:" ) echo(captcha_url) @@ -60,7 +60,7 @@ def prompt_external_callback(url: str) -> str: return default_login_url_callback(url) -def full_response_callback(resp: httpx.Response): +def full_response_callback(resp: httpx.Response) -> httpx.Response: raise_for_status(resp) return resp @@ -301,7 +301,7 @@ def export_to_csv( data: list, headers: Union[list, tuple], dialect: str -): +) -> None: with file.open("w", encoding="utf-8", newline="") as f: writer = csv.DictWriter(f, fieldnames=headers, dialect=dialect) writer.writeheader() From 6bc2b6797f39e780656a0e6dee8edced0d4d4a91 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 4 May 2022 12:35:43 +0200 Subject: [PATCH 46/53] Update update_chapter_titles.py --- utils/update_chapter_titles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/update_chapter_titles.py b/utils/update_chapter_titles.py index 15c7575..ba6da27 100644 --- a/utils/update_chapter_titles.py +++ b/utils/update_chapter_titles.py @@ -1,6 +1,6 @@ """ This script replaces the chapter titles from a ffmetadata file with the one -extracted from a api metadata/voucher file +extracted from an API metadata/voucher file Example: From 1a42f1c644fccb4ed796f4d214936e4e8707d869 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 4 May 2022 12:38:01 +0200 Subject: [PATCH 47/53] Bump dev to alpha --- src/audible_cli/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/_version.py b/src/audible_cli/_version.py index 7c9c8da..eafc73c 100644 --- a/src/audible_cli/_version.py +++ b/src/audible_cli/_version.py @@ -1,7 +1,7 @@ __title__ = "audible-cli" __description__ = "Command line interface (cli) for the audible package." __url__ = "https://github.com/mkb79/audible-cli" -__version__ = "0.2.dev1" +__version__ = "0.2.a1" __author__ = "mkb79" __author_email__ = "mkb79@hackitall.de" __license__ = "AGPL" From 3839a026e8e75b9e3fa7ef9804434af0c2548abc Mon Sep 17 00:00:00 2001 From: mkb79 Date: Wed, 4 May 2022 16:10:40 +0200 Subject: [PATCH 48/53] Update CHANGELOG and README Changes in `config.py` and `models.py` will be documented next. --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- README.md | 2 ++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da54f8..0e4813c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -- +### Added + +- `questionary` package to dependencies +- `add` and `remove` subcommand to wishlist +- `full_response_callback` to `utils` +- `export_to_csv` to `utils` +- `run_async` to `decorators` +- `pass_client` to `decorators` +- `profile_option` to `decorators` +- `password_option` to `decorators` +- `timeout_option` to `decorators` +- `bunch_size_option` to `decorators` + +### Changed + +- bump `audible` to v0.8.1 +- rework plugin examples in `plugin_cmds` +- move `click_verbosity_logger` from `_logging` to `decorators` and rename it to `verbosity_option` +- move `wrap_async` from `utils` to `decorators` +- move `add_param_to_session` from `config` to `decorators` +- move `pass_session` from `config` to `decorators` +- `download` command can now get `annotations` like bookmarks and notes +- `download` command let you now select items when using `--title` option + +### Fixed + +- the `library export` and `wishlist export` command will now export to `csv` correctly +- ## [0.1.3] - 2022-03-27 diff --git a/README.md b/README.md index cf3132a..8660ac0 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ At this time, there the following buildin subcommands: - `wishlist` - `export` - `list` + - `add` + - `remove` ## Verbosity option From 1d0972b830bf91a7e37829e7db8b74f5d9733a12 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 6 May 2022 08:50:07 +0200 Subject: [PATCH 49/53] Shortened log message for downloaded files Thanks goes to snowskeleton and his [commit](https://github.com/snowskeleton/audible-cli/commit/327dc50898a db84a2775c9b3d482ab61d011ed90) --- src/audible_cli/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/audible_cli/utils.py b/src/audible_cli/utils.py index a52e768..3a5425d 100644 --- a/src/audible_cli/utils.py +++ b/src/audible_cli/utils.py @@ -250,8 +250,7 @@ class Downloader: file.rename(file.with_suffix(f"{file.suffix}.old.{i}")) tmp_file.rename(file) logger.info( - f"File {self._file} downloaded to {self._file.parent} " - f"in {elapsed}." + f"File {self._file} downloaded in {elapsed}." ) return True From d5d5f3985b441d92364a960fcc261528c14a6ac9 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Fri, 6 May 2022 13:28:29 +0200 Subject: [PATCH 50/53] check aaxc files if they are downloadable --- src/audible_cli/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index 1b73d51..fe1b231 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -274,6 +274,11 @@ class LibraryItem(BaseItem): return httpx.URL(url, params=params), codec_name async def get_aaxc_url(self, quality: str = "high"): + if not self.is_downloadable(): + raise AudibleCliException( + f"{self.full_title} is not downloadable." + ) + assert quality in ("best", "high", "normal",) body = { From 629a6ef171ed5767b721001e647d614209836642 Mon Sep 17 00:00:00 2001 From: Isaac Lyons <51010664+snowskeleton@users.noreply.github.com> Date: Sun, 8 May 2022 16:34:39 -0400 Subject: [PATCH 51/53] Download fallback (#84) * add fallback to aaxc if aax is not supported * update exceptions.py * update cmd_download.py * remove whitespaces * update cmd_download.py Co-authored-by: mkb79 --- src/audible_cli/_version.py | 2 +- src/audible_cli/cmds/cmd_api.py | 4 +-- src/audible_cli/cmds/cmd_download.py | 42 ++++++++++++++++++++++++---- src/audible_cli/cmds/cmd_wishlist.py | 8 +++--- src/audible_cli/config.py | 24 ++++++++-------- src/audible_cli/exceptions.py | 4 +++ src/audible_cli/models.py | 27 +++++++++--------- 7 files changed, 72 insertions(+), 39 deletions(-) diff --git a/src/audible_cli/_version.py b/src/audible_cli/_version.py index eafc73c..fbef9ac 100644 --- a/src/audible_cli/_version.py +++ b/src/audible_cli/_version.py @@ -1,7 +1,7 @@ __title__ = "audible-cli" __description__ = "Command line interface (cli) for the audible package." __url__ = "https://github.com/mkb79/audible-cli" -__version__ = "0.2.a1" +__version__ = "0.2.a2" __author__ = "mkb79" __author_email__ = "mkb79@hackitall.de" __license__ = "AGPL" diff --git a/src/audible_cli/cmds/cmd_api.py b/src/audible_cli/cmds/cmd_api.py index ec4e138..95cfc9c 100644 --- a/src/audible_cli/cmds/cmd_api.py +++ b/src/audible_cli/cmds/cmd_api.py @@ -63,7 +63,7 @@ logger = logging.getLogger("audible_cli.cmds.cmd_api") @pass_session def cli(session, **options): """Send requests to an Audible API endpoint - + Take a look at https://audible.readthedocs.io/en/latest/misc/external_api.html for known endpoints and parameters. @@ -96,7 +96,7 @@ def cli(session, **options): with Client(auth=auth, country_code=country_code) as client: r = client._request(method, endpoint, params=params, json=body) except Exception as e: - logger.error(e) + logger.error(e) sys.exit(1) if output_format == "json": diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index bf0a4fc..6c2f4c9 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -18,7 +18,7 @@ from ..decorators import ( pass_client, pass_session ) -from ..exceptions import DirectoryDoesNotExists +from ..exceptions import DirectoryDoesNotExists, NotDownloadableAsAAX from ..models import Library from ..utils import Downloader @@ -225,10 +225,25 @@ async def download_annotations( async def download_aax( - client, output_dir, base_filename, item, quality, overwrite_existing + client, output_dir, base_filename, item, quality, overwrite_existing, + aax_fallback ): # url, codec = await item.get_aax_url(quality) - url, codec = await item.get_aax_url_old(quality) + try: + url, codec = await item.get_aax_url_old(quality) + except NotDownloadableAsAAX: + if aax_fallback: + logger.info(f"Fallback to aaxc for {item.full_title}") + return await download_aaxc( + client=client, + output_dir=output_dir, + base_filename=base_filename, + item=item, + quality=quality, + overwrite_existing=overwrite_existing + ) + raise + filename = base_filename + f"-{codec}.aax" filepath = output_dir / filename dl = Downloader( @@ -344,7 +359,8 @@ def queue_job( item, cover_size, quality, - overwrite_existing + overwrite_existing, + aax_fallback ): base_filename = item.create_base_filename(filename_mode) @@ -400,7 +416,8 @@ def queue_job( base_filename=base_filename, item=item, quality=quality, - overwrite_existing=overwrite_existing + overwrite_existing=overwrite_existing, + aax_fallback=aax_fallback ) ) @@ -468,6 +485,11 @@ def display_counter(): is_flag=True, help="Download book in aaxc format incl. voucher file" ) +@click.option( + "--aax-fallback", + is_flag=True, + help="Download book in aax format and fallback to aaxc, if former is not supported." +) @click.option( "--quality", "-q", default="best", @@ -562,6 +584,13 @@ async def cli(session, api_client, **params): # what to download get_aax = params.get("aax") get_aaxc = params.get("aaxc") + aax_fallback = params.get("aax_fallback") + if aax_fallback: + if get_aax: + logger.info("Using --aax is redundant and can be left when using --aax-fallback") + get_aax = True + if get_aaxc: + logger.warning("Do not mix --aaxc with --aax-fallback option.") get_annotation = params.get("annotation") get_chapters = params.get("chapter") get_cover = params.get("cover") @@ -680,7 +709,8 @@ async def cli(session, api_client, **params): item=item, cover_size=cover_size, quality=quality, - overwrite_existing=overwrite_existing + overwrite_existing=overwrite_existing, + aax_fallback=aax_fallback ) try: diff --git a/src/audible_cli/cmds/cmd_wishlist.py b/src/audible_cli/cmds/cmd_wishlist.py index bd57aad..a4351d2 100644 --- a/src/audible_cli/cmds/cmd_wishlist.py +++ b/src/audible_cli/cmds/cmd_wishlist.py @@ -180,7 +180,7 @@ async def list_wishlist(client): @pass_client(limits=limits) async def add_wishlist(client, asin, title): """add asin(s) to wishlist - + Run the command without any option for interactive mode. """ @@ -223,7 +223,7 @@ async def add_wishlist(client, asin, title): for i in full_match or match: c = questionary.Choice(title=i[0].full_title, value=i[0].asin) choices.append(c) - + answer = await questionary.checkbox( f"Found the following matches for '{t}'. Which you want to add?", choices=choices @@ -263,7 +263,7 @@ async def add_wishlist(client, asin, title): @pass_client(limits=limits) async def remove_wishlist(client, asin, title): """remove asin(s) from wishlist - + Run the command without any option for interactive mode. """ @@ -297,7 +297,7 @@ async def remove_wishlist(client, asin, title): for i in full_match or match: c = questionary.Choice(title=i[0].full_title, value=i[0].asin) choices.append(c) - + answer = await questionary.checkbox( f"Found the following matches for '{t}'. Which you want to remove?", choices=choices diff --git a/src/audible_cli/config.py b/src/audible_cli/config.py index 50d5492..e9a778c 100644 --- a/src/audible_cli/config.py +++ b/src/audible_cli/config.py @@ -29,11 +29,11 @@ class ConfigFile: Instantiate a :class:`~audible_cli.config.ConfigFile` will load the file content by default. To create a new config file, the ``file_exists`` argument must be set to ``False``. - + Audible-cli configuration files are written in the toml markup language. It has a main section named `APP` and sections for each profile named `profile.`. - + Args: filename: The file path to the config file file_exists: If ``True``, the file must exist and the file content @@ -88,7 +88,7 @@ class ConfigFile: def has_profile(self, name: str) -> bool: """Check if a profile with this name are in the configuration data - + Args: name: The name of the profile """ @@ -96,7 +96,7 @@ class ConfigFile: def get_profile(self, name: str) -> Dict[str, str]: """Returns the configuration data for these profile name - + Args: name: The name of the profile """ @@ -117,11 +117,11 @@ class ConfigFile: default: Optional[str] = None ) -> str: """Returns the value for an option for the given profile. - + Looks first, if an option is in the ``profile`` section. If not, it searches for the option in the ``APP`` section. If not found, it returns the ``default``. - + Args: profile: The name of the profile option: The name of the option to search for @@ -144,7 +144,7 @@ class ConfigFile: **additional_options ) -> None: """Adds a new profile to the config - + Args: name: The name of the profile auth_file: The name of the auth_file @@ -175,7 +175,7 @@ class ConfigFile: def delete_profile(self, name: str, write_config: bool = True) -> None: """Deletes a profile from config - + Args: name: The name of the profile write_config: If ``True``, save the config to file @@ -198,7 +198,7 @@ class ConfigFile: filename: Optional[Union[str, pathlib.Path]] = None ) -> None: """Write the config data to file - + Args: filename: If not ``None`` the config is written to these file path instead of ``self.filename`` @@ -279,12 +279,12 @@ class Session: password: Optional[str] = None ) -> audible.Authenticator: """Returns an Authenticator for a profile - + If an Authenticator for this profile is already loaded, it will return the Authenticator without reloading it. This way a session can hold multiple Authenticators for different profiles. Commands can use this to make API requests for more than one profile. - + Args: profile: The name of the profile password: The password of the auth file @@ -338,7 +338,7 @@ class Session: ) -> AsyncClient: auth = self.get_auth_for_profile(profile, password) kwargs.setdefault("timeout", self.params.get("timeout", 5)) - return AsyncClient(auth=auth, **kwargs) + return AsyncClient(auth=auth, **kwargs) def get_client(self, **kwargs) -> AsyncClient: profile = self.selected_profile diff --git a/src/audible_cli/exceptions.py b/src/audible_cli/exceptions.py index 0a2f665..6d06840 100644 --- a/src/audible_cli/exceptions.py +++ b/src/audible_cli/exceptions.py @@ -9,6 +9,10 @@ class NotFoundError(AudibleCliException): """Raised if an item is not found""" +class NotDownloadableAsAAX(AudibleCliException): + """Raised if an item is not downloadable in aax format""" + + class FileDoesNotExists(AudibleCliException): """Raised if a file does not exist""" diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index fe1b231..75bb646 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -11,7 +11,7 @@ from audible.aescipher import decrypt_voucher_from_licenserequest from audible.client import convert_response_content from .constants import CODEC_HIGH_QUALITY, CODEC_NORMAL_QUALITY -from .exceptions import AudibleCliException +from .exceptions import AudibleCliException, NotDownloadableAsAAX from .utils import full_response_callback, LongestSubString @@ -82,16 +82,16 @@ class BaseItem: if "ascii" in mode: base_filename = self.full_title_slugify - + elif "unicode" in mode: base_filename = unicodedata.normalize("NFKD", self.full_title) - + else: base_filename = self.asin - + if "asin" in mode: base_filename = self.asin + "_" + base_filename - + return base_filename def substring_in_title_accuracy(self, substring): @@ -211,23 +211,22 @@ class LibraryItem(BaseItem): def is_downloadable(self): # customer_rights must be in response_groups if self.customer_rights is not None: - if not self.customer_rights["is_consumable_offline"]: - return False - else: + if self.customer_rights["is_consumable_offline"]: return True + return False async def get_aax_url_old(self, quality: str = "high"): if not self.is_downloadable(): raise AudibleCliException( - f"{self.full_title} is not downloadable. Skip item." + f"{self.full_title} is not downloadable." ) codec, codec_name = self._get_codec(quality) - if codec is None: - raise AudibleCliException( + if codec is None or self.is_ayce: + raise NotDownloadableAsAAX( f"{self.full_title} is not downloadable in AAX format" ) - + url = ( "https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/" "FSDownloadContent" @@ -260,8 +259,8 @@ class LibraryItem(BaseItem): ) codec, codec_name = self._get_codec(quality) - if codec is None: - raise AudibleCliException( + if codec is None or self.is_ayce: + raise NotDownloadableAsAAX( f"{self.full_title} is not downloadable in AAX format" ) From 70af33a258567cd34d626df2e72f16f93db64311 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Mon, 9 May 2022 19:49:49 +0200 Subject: [PATCH 52/53] Update CHANGELOG.md --- CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4813c..613ea72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- `--aax-fallback` option to `download` command to download books in aax format and fallback to aaxc, if the book is not available as aax +- `--annotation` option to `download` command to get bookmarks and notes - `questionary` package to dependencies -- `add` and `remove` subcommand to wishlist +- `add` and `remove` subcommands to wishlist - `full_response_callback` to `utils` - `export_to_csv` to `utils` - `run_async` to `decorators` @@ -18,16 +20,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `password_option` to `decorators` - `timeout_option` to `decorators` - `bunch_size_option` to `decorators` +- `ConfigFile.get_profile_option` get the value for an option for a given profile +- `Session.selected.profile` to get the profile name for the current session +- `Session.get_auth_for_profile` to get an auth file for a given profile +- `models.BaseItem.create_base_filename` to build a filename in given mode +- `models.LibraryItem.get_annotations` to get annotations for a library item ### Changed - bump `audible` to v0.8.1 - rework plugin examples in `plugin_cmds` +- rename `config.Config` to `config.ConfigFile` - move `click_verbosity_logger` from `_logging` to `decorators` and rename it to `verbosity_option` - move `wrap_async` from `utils` to `decorators` - move `add_param_to_session` from `config` to `decorators` - move `pass_session` from `config` to `decorators` -- `download` command can now get `annotations` like bookmarks and notes - `download` command let you now select items when using `--title` option ### Fixed @@ -52,7 +59,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - the `--version` option now checks if an update for `audible-cli` is available -- build macOS release in onedir mode +- build macOS releases in onedir mode ### Bugfix From d1beda664a692a6dacbc46d07fce93d21d0682b1 Mon Sep 17 00:00:00 2001 From: mkb79 Date: Tue, 17 May 2022 08:27:25 +0200 Subject: [PATCH 53/53] Go to beta stage --- src/audible_cli/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audible_cli/_version.py b/src/audible_cli/_version.py index fbef9ac..0590716 100644 --- a/src/audible_cli/_version.py +++ b/src/audible_cli/_version.py @@ -1,7 +1,7 @@ __title__ = "audible-cli" __description__ = "Command line interface (cli) for the audible package." __url__ = "https://github.com/mkb79/audible-cli" -__version__ = "0.2.a2" +__version__ = "0.2.b1" __author__ = "mkb79" __author_email__ = "mkb79@hackitall.de" __license__ = "AGPL"